From f656266e5c38622fb8f01c925d93bf8a7c698e1d Mon Sep 17 00:00:00 2001 From: raguirref Date: Wed, 8 Apr 2026 11:30:19 -0600 Subject: [PATCH 01/89] :sparkles: Fix builder bool and media handling Fixes three concrete builder issues in common/files/builder:\n- Use bool type from shape when selecting style source for difference bools\n- Persist :strokes correctly (fix typo :stroks)\n- Validate add-file-media params after assigning default id\n\nAlso adds regression tests in common-tests.files-builder-test and registers them in runner. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: raguirref --- common/src/app/common/files/builder.cljc | 12 ++-- .../test/common_tests/files_builder_test.cljc | 72 +++++++++++++++++++ common/test/common_tests/runner.cljc | 2 + dev_server.pid | 1 + 4 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 common/test/common_tests/files_builder_test.cljc create mode 100644 dev_server.pid diff --git a/common/src/app/common/files/builder.cljc b/common/src/app/common/files/builder.cljc index 4354986b8d..84c0381f0d 100644 --- a/common/src/app/common/files/builder.cljc +++ b/common/src/app/common/files/builder.cljc @@ -356,7 +356,7 @@ :code :empty-children :hint "expected a group with at least one shape for creating a bool")) - (let [head (if (= type :difference) + (let [head (if (= (:bool-type bool-shape) :difference) (first children) (last children)) fills (if (and (contains? head :svg-attrs) (empty? (:fills head))) @@ -364,7 +364,7 @@ (get head :fills))] (-> bool-shape (assoc :fills fills) - (assoc :stroks (get head :strokes)))))) + (assoc :strokes (get head :strokes)))))) (defn add-bool [state params] @@ -573,10 +573,10 @@ file-id (get state ::current-file-id) - {:keys [id width height name]} - (-> params - (update :id default-uuid) - (check-add-file-media params))] + {:keys [id width height name]} + (-> params + (update :id default-uuid) + (check-add-file-media))] (-> state (update ::blobs assoc media-id blob) diff --git a/common/test/common_tests/files_builder_test.cljc b/common/test/common_tests/files_builder_test.cljc new file mode 100644 index 0000000000..23dd6c78cc --- /dev/null +++ b/common/test/common_tests/files_builder_test.cljc @@ -0,0 +1,72 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns common-tests.files-builder-test + (:require + [app.common.files.builder :as fb] + [app.common.uuid :as uuid] + [clojure.test :as t])) + +(defn- stroke + [color] + [{:stroke-style :solid + :stroke-alignment :inner + :stroke-width 1 + :stroke-color color + :stroke-opacity 1}]) + +(t/deftest add-bool-uses-difference-head-style + (let [file-id (uuid/next) + page-id (uuid/next) + group-id (uuid/next) + child-a (uuid/next) + child-b (uuid/next) + state (-> (fb/create-state) + (fb/add-file {:id file-id :name "Test file"}) + (fb/add-page {:id page-id :name "Page 1"}) + (fb/add-group {:id group-id :name "Group A"}) + (fb/add-shape {:id child-a + :type :rect + :name "A" + :x 0 + :y 0 + :width 10 + :height 10 + :strokes (stroke "#ff0000")}) + (fb/add-shape {:id child-b + :type :rect + :name "B" + :x 20 + :y 0 + :width 10 + :height 10 + :strokes (stroke "#00ff00")}) + (fb/close-group) + (fb/add-bool {:group-id group-id + :type :difference})) + bool (fb/get-shape state group-id)] + (t/is (= :bool (:type bool))) + (t/is (= (stroke "#ff0000") (:strokes bool))))) + +(t/deftest add-file-media-validates-and-persists-media + (let [file-id (uuid/next) + page-id (uuid/next) + image-id (uuid/next) + state (-> (fb/create-state) + (fb/add-file {:id file-id :name "Test file"}) + (fb/add-page {:id page-id :name "Page 1"}) + (fb/add-file-media {:id image-id + :name "Image" + :width 128 + :height 64} + (fb/map->BlobWrapper {:mtype "image/png" + :size 42 + :blob nil}))) + media (get-in state [::fb/file-media image-id])] + (t/is (= image-id (::fb/last-id state))) + (t/is (= "Image" (:name media))) + (t/is (= 128 (:width media))) + (t/is (= 64 (:height media))))) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index b8a9fc8934..489e71f7ef 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -11,6 +11,7 @@ [common-tests.colors-test] [common-tests.data-test] [common-tests.files-changes-test] + [common-tests.files-builder-test] [common-tests.files-migrations-test] [common-tests.geom-align-test] [common-tests.geom-bounds-map-test] @@ -82,6 +83,7 @@ 'common-tests.colors-test 'common-tests.data-test 'common-tests.files-changes-test + 'common-tests.files-builder-test 'common-tests.files-migrations-test 'common-tests.geom-align-test 'common-tests.geom-bounds-map-test diff --git a/dev_server.pid b/dev_server.pid new file mode 100644 index 0000000000..a8cd695ffa --- /dev/null +++ b/dev_server.pid @@ -0,0 +1 @@ +31390 From 94c6045dd99f2c09a3a26a492a60c9f21a1dcd25 Mon Sep 17 00:00:00 2001 From: raguirref Date: Wed, 8 Apr 2026 11:30:31 -0600 Subject: [PATCH 02/89] :fire: Remove accidental dev_server.pid Remove unrelated local pid file that was accidentally included in previous commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: raguirref --- dev_server.pid | 1 - 1 file changed, 1 deletion(-) delete mode 100644 dev_server.pid diff --git a/dev_server.pid b/dev_server.pid deleted file mode 100644 index a8cd695ffa..0000000000 --- a/dev_server.pid +++ /dev/null @@ -1 +0,0 @@ -31390 From e46b34efc7122178fc16b6789cc55d92a90519d5 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 13 Apr 2026 15:41:38 +0200 Subject: [PATCH 03/89] :paperclip: Fix formatting issues --- common/src/app/common/files/builder.cljc | 8 ++++---- common/test/common_tests/runner.cljc | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/common/src/app/common/files/builder.cljc b/common/src/app/common/files/builder.cljc index 84c0381f0d..cc3dd11879 100644 --- a/common/src/app/common/files/builder.cljc +++ b/common/src/app/common/files/builder.cljc @@ -573,10 +573,10 @@ file-id (get state ::current-file-id) - {:keys [id width height name]} - (-> params - (update :id default-uuid) - (check-add-file-media))] + {:keys [id width height name]} + (-> params + (update :id default-uuid) + (check-add-file-media))] (-> state (update ::blobs assoc media-id blob) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index 489e71f7ef..6df8243077 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -10,8 +10,8 @@ [common-tests.buffer-test] [common-tests.colors-test] [common-tests.data-test] - [common-tests.files-changes-test] [common-tests.files-builder-test] + [common-tests.files-changes-test] [common-tests.files-migrations-test] [common-tests.geom-align-test] [common-tests.geom-bounds-map-test] From c39609b99154a5654dacd46d9c5d81fe1142c271 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 10:48:30 +0200 Subject: [PATCH 04/89] :recycle: Use shared singleton containers for React portals (#8957) Refactor use-portal-container to allocate one persistent
per logical category (:modal, :popup, :tooltip, :default) instead of creating a new div for every component instance. This keeps the DOM clean with at most four fixed portal containers and eliminates the arbitrary growth of empty
elements on document.body while preserving the removeChild race condition fix. --- .../src/app/main/ui/ds/tooltip/tooltip.cljs | 2 +- frontend/src/app/main/ui/hooks.cljs | 38 ++++++++++++++----- frontend/src/app/main/ui/modal.cljs | 2 +- .../tokens/management/context_menu.cljs | 2 +- .../tokens/management/node_context_menu.cljs | 2 +- .../tokens/themes/theme_selector.cljs | 2 +- 6 files changed, 33 insertions(+), 15 deletions(-) diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs index 4751d81dcf..05246f7f23 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs @@ -160,7 +160,7 @@ tooltip-ref (mf/use-ref nil) - container (hooks/use-portal-container) + container (hooks/use-portal-container :tooltip) id (d/nilv id internal-id) diff --git a/frontend/src/app/main/ui/hooks.cljs b/frontend/src/app/main/ui/hooks.cljs index 42560cd8fe..ae8ebd30d5 100644 --- a/frontend/src/app/main/ui/hooks.cljs +++ b/frontend/src/app/main/ui/hooks.cljs @@ -380,17 +380,35 @@ state)) +(defn- get-or-create-portal-container + "Returns the singleton container div for the given category, creating + and appending it to document.body on first access." + [category] + (let [body (dom/get-body) + id (str "portal-container-" category)] + (or (dom/query body (str "#" id)) + (let [container (dom/create-element "div")] + (dom/set-attribute! container "id" id) + (dom/append-child! body container) + container)))) + (defn use-portal-container - "Creates a dedicated div container for React portals. The container - is appended to document.body on mount and removed on cleanup, preventing - removeChild race conditions when multiple portals target the same body." - [] - (let [container (mf/use-memo #(dom/create-element "div"))] - (mf/with-effect [] - (let [body (dom/get-body)] - (dom/append-child! body container) - #(dom/remove-child! body container))) - container)) + "Returns a shared singleton container div for React portals, identified + by a logical category. Available categories: + + :modal — modal dialogs + :popup — popups, dropdowns, context menus + :tooltip — tooltips + :default — general portal use (default) + + All portals in the same category share one
on document.body, + keeping the DOM clean and avoiding removeChild race conditions." + ([] + (use-portal-container :default)) + ([category] + (let [category (name category)] + (mf/with-memo [category] + (get-or-create-portal-container category))))) (defn use-dynamic-grid-item-width ([] (use-dynamic-grid-item-width nil)) diff --git a/frontend/src/app/main/ui/modal.cljs b/frontend/src/app/main/ui/modal.cljs index 5df1cc3daa..6e9b1df7d4 100644 --- a/frontend/src/app/main/ui/modal.cljs +++ b/frontend/src/app/main/ui/modal.cljs @@ -84,7 +84,7 @@ (mf/defc modal-container* {::mf/props :obj} [] - (let [container (hooks/use-portal-container)] + (let [container (hooks/use-portal-container :modal)] (when-let [modal (mf/deref ref:modal)] (mf/portal (mf/html [:> modal-wrapper* {:data modal :key (dm/str (:id modal))}]) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs index ab0dc6326d..c870baf9fb 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs @@ -517,7 +517,7 @@ dropdown-direction-change* (mf/use-ref 0) top (+ (get-in mdata [:position :y]) 5) left (+ (get-in mdata [:position :x]) 5) - container (hooks/use-portal-container)] + container (hooks/use-portal-container :popup)] (mf/use-effect (mf/deps is-open?) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs index d37e628d02..f150240cf1 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs @@ -36,7 +36,7 @@ dropdown-direction-change* (mf/use-ref 0) top (+ (get-in mdata [:position :y]) 5) left (+ (get-in mdata [:position :x]) 5) - container (hooks/use-portal-container) + container (hooks/use-portal-container :popup) delete-node (mf/use-fn (mf/deps mdata) diff --git a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs index a8687c9719..d688588e2f 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs @@ -114,7 +114,7 @@ :is-open? true :rect rect)))))) - container (hooks/use-portal-container)] + container (hooks/use-portal-container :popup)] [:div {:on-click on-open-dropdown :disabled (not can-edit?) From a81cded0aa46756cae35d02dc43710e9c88fb8e5 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 10:48:58 +0200 Subject: [PATCH 05/89] :sparkles: Make the common fressian module more testable (#8859) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :sparkles: Add exhaustive unit tests for app.common.fressian encode/decode Add a JVM-only test suite (41 tests, 172 assertions) for the fressian serialisation layer, covering: - All custom handlers: char, clj/keyword, clj/symbol, clj/vector, clj/set, clj/map, clj/seq, clj/ratio, clj/bigint, java/instant, OffsetDateTime, linked/map (order preserved), linked/set (order preserved) - Built-in types: nil, boolean, int, long, double (NaN, ±Inf, boundaries), String, byte[], UUID - Edge cases: empty collections, nil values, ArrayMap/HashMap size boundary, mixed key types - Penpot-domain structures: shape maps with UUID keys, nested objects maps - Correctness: encode→decode→encode idempotency, independent encode calls * :recycle: Extract fressian handler helpers to private top-level functions Extract adapt-write-handler, adapt-read-handler, and merge-handlers out of the letfn in add-handlers! into reusable private functions. Also creates xf:adapt-write-handler and xf:adapt-read-handler transducers and adds overwrite-read-handlers and overwrite-write-handlers for advanced handler override use cases. --- common/src/app/common/fressian.clj | 85 +++- common/test/common_tests/fressian_test.clj | 526 +++++++++++++++++++++ common/test/common_tests/runner.cljc | 2 + 3 files changed, 587 insertions(+), 26 deletions(-) create mode 100644 common/test/common_tests/fressian_test.clj diff --git a/common/src/app/common/fressian.clj b/common/src/app/common/fressian.clj index 7e35f3116e..98c8b1b323 100644 --- a/common/src/app/common/fressian.clj +++ b/common/src/app/common/fressian.clj @@ -118,6 +118,36 @@ (d/ordered-map) (partition-all 2 (seq kvs))))) + +(defn- adapt-write-handler + [{:keys [name class wfn]}] + [class {name (reify WriteHandler + (write [_ w o] + (wfn name w o)))}]) + +(defn- adapt-read-handler + [{:keys [name rfn]}] + [name (reify ReadHandler + (read [_ rdr _ _] + (rfn rdr)))]) + +(defn- merge-handlers + [m1 m2] + (-> (merge m1 m2) + (d/without-nils))) + +(def ^:private + xf:adapt-write-handler + (comp + (filter :wfn) + (map adapt-write-handler))) + +(def ^:private + xf:adapt-read-handler + (comp + (filter :rfn) + (map adapt-read-handler))) + (def ^:dynamic *write-handler-lookup* nil) (def ^:dynamic *read-handler-lookup* nil) @@ -126,36 +156,39 @@ (defn add-handlers! [& handlers] - (letfn [(adapt-write-handler [{:keys [name class wfn]}] - [class {name (reify WriteHandler - (write [_ w o] - (wfn name w o)))}]) + (let [write-handlers' + (into {} xf:adapt-write-handler handlers) - (adapt-read-handler [{:keys [name rfn]}] - [name (reify ReadHandler - (read [_ rdr _ _] - (rfn rdr)))]) + read-handlers' + (into {} xf:adapt-read-handler handlers) - (merge-and-clean [m1 m2] - (-> (merge m1 m2) - (d/without-nils)))] + write-handlers' + (swap! write-handlers merge-handlers write-handlers') - (let [whs (into {} - (comp - (filter :wfn) - (map adapt-write-handler)) - handlers) - rhs (into {} - (comp - (filter :rfn) - (map adapt-read-handler)) - handlers) - cwh (swap! write-handlers merge-and-clean whs) - crh (swap! read-handlers merge-and-clean rhs)] + read-handlers' + (swap! read-handlers merge-handlers read-handlers')] - (alter-var-root #'*write-handler-lookup* (constantly (-> cwh fres/associative-lookup fres/inheritance-lookup))) - (alter-var-root #'*read-handler-lookup* (constantly (-> crh fres/associative-lookup))) - nil))) + (alter-var-root #'*write-handler-lookup* + (constantly + (-> write-handlers' fres/associative-lookup fres/inheritance-lookup))) + + (alter-var-root #'*read-handler-lookup* + (constantly (-> read-handlers' fres/associative-lookup))) + + nil)) + +(defn overwrite-read-handlers + [& handlers] + (->> (into {} xf:adapt-read-handler handlers) + (merge-handlers @read-handlers) + (fres/associative-lookup))) + +(defn overwrite-write-handlers + [& handlers] + (->> (into {} xf:adapt-write-handler handlers) + (merge-handlers @write-handlers) + (fres/associative-lookup) + (fres/inheritance-lookup))) (defn write-char [n w o] diff --git a/common/test/common_tests/fressian_test.clj b/common/test/common_tests/fressian_test.clj new file mode 100644 index 0000000000..9af54464a5 --- /dev/null +++ b/common/test/common_tests/fressian_test.clj @@ -0,0 +1,526 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns common-tests.fressian-test + "Exhaustive unit tests for app.common.fressian encode/decode functions. + + Tests cover every custom handler registered in the fressian namespace + (char, java/instant, clj/ratio, clj/map, linked/map, clj/keyword, + clj/symbol, clj/bigint, clj/set, clj/vector, clj/list, clj/seq, + linked/set) plus the built-in Fressian primitives (nil, boolean, + integer, long, double, string, bytes, UUID). + + The file is JVM-only because Fressian is a JVM library." + (:require + [app.common.data :as d] + [app.common.fressian :as fres] + [clojure.test :as t]) + (:import + java.time.Instant + java.time.OffsetDateTime + java.time.ZoneOffset)) + +;; --------------------------------------------------------------------------- +;; Helpers +;; --------------------------------------------------------------------------- + +(defn roundtrip + "Encode then decode a value; the result must equal the original." + [v] + (-> v fres/encode fres/decode)) + +(defn roundtrip= + "Returns true when encode→decode produces an equal value." + [v] + (= v (roundtrip v))) + +;; --------------------------------------------------------------------------- +;; Encode returns a byte array +;; --------------------------------------------------------------------------- + +(t/deftest encode-returns-byte-array + (t/is (bytes? (fres/encode nil))) + (t/is (bytes? (fres/encode 42))) + (t/is (bytes? (fres/encode "hello"))) + (t/is (bytes? (fres/encode {:a 1}))) + (t/is (bytes? (fres/encode []))) + (t/is (pos? (alength ^bytes (fres/encode 0)))) + (t/testing "different values produce different byte arrays" + (t/is (not= (vec (fres/encode 1)) (vec (fres/encode 2)))))) + +;; --------------------------------------------------------------------------- +;; nil +;; --------------------------------------------------------------------------- + +(t/deftest nil-roundtrip + (t/is (nil? (roundtrip nil)))) + +;; --------------------------------------------------------------------------- +;; Booleans +;; --------------------------------------------------------------------------- + +(t/deftest boolean-roundtrip + (t/is (true? (roundtrip true))) + (t/is (false? (roundtrip false)))) + +;; --------------------------------------------------------------------------- +;; Integers and longs +;; --------------------------------------------------------------------------- + +(t/deftest integer-roundtrip + (t/is (= 0 (roundtrip 0))) + (t/is (= 1 (roundtrip 1))) + (t/is (= -1 (roundtrip -1))) + (t/is (= 42 (roundtrip 42))) + (t/is (= Integer/MAX_VALUE (roundtrip Integer/MAX_VALUE))) + (t/is (= Integer/MIN_VALUE (roundtrip Integer/MIN_VALUE)))) + +(t/deftest long-roundtrip + (t/is (= Long/MAX_VALUE (roundtrip Long/MAX_VALUE))) + (t/is (= Long/MIN_VALUE (roundtrip Long/MIN_VALUE))) + (t/is (= 1000000000000 (roundtrip 1000000000000)))) + +;; --------------------------------------------------------------------------- +;; Doubles / floats +;; --------------------------------------------------------------------------- + +(t/deftest double-roundtrip + (t/is (= 0.0 (roundtrip 0.0))) + (t/is (= 3.14 (roundtrip 3.14))) + (t/is (= -2.718 (roundtrip -2.718))) + (t/is (= Double/MAX_VALUE (roundtrip Double/MAX_VALUE))) + (t/is (= Double/MIN_VALUE (roundtrip Double/MIN_VALUE))) + (t/is (Double/isInfinite ^double (roundtrip Double/POSITIVE_INFINITY))) + (t/is (Double/isInfinite ^double (roundtrip Double/NEGATIVE_INFINITY))) + (t/is (Double/isNaN ^double (roundtrip Double/NaN)))) + +;; --------------------------------------------------------------------------- +;; Strings +;; --------------------------------------------------------------------------- + +(t/deftest string-roundtrip + (t/is (= "" (roundtrip ""))) + (t/is (= "hello" (roundtrip "hello"))) + (t/is (= "hello world" (roundtrip "hello world"))) + (t/is (= "αβγδ" (roundtrip "αβγδ"))) + (t/is (= "emoji: 🎨" (roundtrip "emoji: 🎨"))) + (t/is (= (apply str (repeat 10000 "x")) (roundtrip (apply str (repeat 10000 "x")))))) + +;; --------------------------------------------------------------------------- +;; Characters (custom "char" handler) +;; --------------------------------------------------------------------------- + +(t/deftest char-roundtrip + (t/is (= \a (roundtrip \a))) + (t/is (= \A (roundtrip \A))) + (t/is (= \space (roundtrip \space))) + (t/is (= \newline (roundtrip \newline))) + (t/is (= \0 (roundtrip \0))) + (t/is (= \ü (roundtrip \ü))) + (t/testing "char type is preserved" + (t/is (char? (roundtrip \x))))) + +;; --------------------------------------------------------------------------- +;; Keywords (custom "clj/keyword" handler) +;; --------------------------------------------------------------------------- + +(t/deftest keyword-roundtrip + (t/is (= :foo (roundtrip :foo))) + (t/is (= :bar (roundtrip :bar))) + (t/is (= :ns/foo (roundtrip :ns/foo))) + (t/is (= :app.common.data/something (roundtrip :app.common.data/something))) + (t/testing "keyword? is preserved" + (t/is (keyword? (roundtrip :anything)))) + (t/testing "namespace is preserved" + (let [kw :my-ns/my-name] + (t/is (= (namespace kw) (namespace (roundtrip kw)))) + (t/is (= (name kw) (name (roundtrip kw))))))) + +;; --------------------------------------------------------------------------- +;; Symbols (custom "clj/symbol" handler) +;; --------------------------------------------------------------------------- + +(t/deftest symbol-roundtrip + (t/is (= 'foo (roundtrip 'foo))) + (t/is (= 'bar (roundtrip 'bar))) + (t/is (= 'ns/foo (roundtrip 'ns/foo))) + (t/is (= 'clojure.core/map (roundtrip 'clojure.core/map))) + (t/testing "symbol? is preserved" + (t/is (symbol? (roundtrip 'anything)))) + (t/testing "namespace is preserved" + (let [sym 'my-ns/my-name] + (t/is (= (namespace sym) (namespace (roundtrip sym)))) + (t/is (= (name sym) (name (roundtrip sym))))))) + +;; --------------------------------------------------------------------------- +;; Vectors (custom "clj/vector" handler) +;; --------------------------------------------------------------------------- + +(t/deftest vector-roundtrip + (t/is (= [] (roundtrip []))) + (t/is (= [1 2 3] (roundtrip [1 2 3]))) + (t/is (= [:a :b :c] (roundtrip [:a :b :c]))) + (t/is (= [nil nil nil] (roundtrip [nil nil nil]))) + (t/is (= [[1 2] [3 4]] (roundtrip [[1 2] [3 4]]))) + (t/is (= ["hello" :world 42] (roundtrip ["hello" :world 42]))) + (t/testing "vector? is preserved" + (t/is (vector? (roundtrip [1 2 3]))))) + +;; --------------------------------------------------------------------------- +;; Sets (custom "clj/set" handler) +;; --------------------------------------------------------------------------- + +(t/deftest set-roundtrip + (t/is (= #{} (roundtrip #{}))) + (t/is (= #{1 2 3} (roundtrip #{1 2 3}))) + (t/is (= #{:a :b :c} (roundtrip #{:a :b :c}))) + (t/is (= #{"x" "y"} (roundtrip #{"x" "y"}))) + (t/testing "set? is preserved" + (t/is (set? (roundtrip #{:foo}))))) + +;; --------------------------------------------------------------------------- +;; Maps (custom "clj/map" handler) +;; --------------------------------------------------------------------------- + +(t/deftest small-map-roundtrip + "Maps with fewer than 8 entries decode as PersistentArrayMap." + (t/is (= {} (roundtrip {}))) + (t/is (= {:a 1} (roundtrip {:a 1}))) + (t/is (= {:a 1 :b 2} (roundtrip {:a 1 :b 2}))) + (t/is (= {:a 1 :b 2 :c 3 :d 4 :e 5 :f 6 :g 7} (roundtrip {:a 1 :b 2 :c 3 :d 4 :e 5 :f 6 :g 7}))) + (t/testing "map? is preserved" + (t/is (map? (roundtrip {:x 1}))))) + +(t/deftest large-map-roundtrip + "Maps with 8+ entries decode as PersistentHashMap (>= 16 kvs in list)." + (let [large (into {} (map (fn [i] [(keyword (str "k" i)) i]) (range 20)))] + (t/is (= large (roundtrip large))) + (t/is (map? (roundtrip large))))) + +(t/deftest map-with-mixed-keys-roundtrip + (let [m {:keyword-key 1 + "string-key" 2 + 42 3}] + (t/is (= m (roundtrip m))))) + +(t/deftest map-with-nil-value-roundtrip + (t/is (= {:a nil :b 2} (roundtrip {:a nil :b 2})))) + +;; --------------------------------------------------------------------------- +;; Sequences (custom "clj/seq" handler) +;; --------------------------------------------------------------------------- + +(t/deftest seq-roundtrip + (let [s (seq [1 2 3])] + (t/is (= (sequence s) (roundtrip s)))) + (let [s (map inc [1 2 3])] + (t/is (= (sequence s) (roundtrip s)))) + (t/testing "result is a sequence" + (t/is (seq? (roundtrip (seq [1 2 3])))))) + +;; --------------------------------------------------------------------------- +;; Ratio (custom "clj/ratio" handler) +;; --------------------------------------------------------------------------- + +(t/deftest ratio-roundtrip + (t/is (= 1/3 (roundtrip 1/3))) + (t/is (= 22/7 (roundtrip 22/7))) + (t/is (= -5/6 (roundtrip -5/6))) + (t/is (= 1/1000000 (roundtrip 1/1000000))) + (t/testing "ratio? is preserved" + (t/is (ratio? (roundtrip 1/3))))) + +;; --------------------------------------------------------------------------- +;; BigInt (custom "clj/bigint" handler) +;; --------------------------------------------------------------------------- + +(t/deftest bigint-roundtrip + (t/is (= 0N (roundtrip 0N))) + (t/is (= 1N (roundtrip 1N))) + (t/is (= -1N (roundtrip -1N))) + (t/is (= 123456789012345678901234567890N (roundtrip 123456789012345678901234567890N))) + (t/is (= -999999999999999999999999999999N (roundtrip -999999999999999999999999999999N))) + (t/testing "bigint? is preserved" + (t/is (instance? clojure.lang.BigInt (roundtrip 42N))))) + +;; --------------------------------------------------------------------------- +;; java.time.Instant (custom "java/instant" handler) +;; --------------------------------------------------------------------------- + +(t/deftest instant-roundtrip + (let [now (Instant/now)] + (t/is (= (.toEpochMilli now) (.toEpochMilli ^Instant (roundtrip now))))) + (t/testing "epoch zero" + (let [epoch (Instant/ofEpochMilli 0)] + (t/is (= epoch (roundtrip epoch))))) + (t/testing "far past" + (let [past (Instant/ofEpochMilli -62135596800000)] + (t/is (= past (roundtrip past))))) + (t/testing "far future" + (let [future (Instant/ofEpochMilli 32503680000000)] + (t/is (= future (roundtrip future))))) + (t/testing "result type is Instant" + (t/is (instance? Instant (roundtrip (Instant/now)))))) + +;; --------------------------------------------------------------------------- +;; java.time.OffsetDateTime (written as "java/instant", read back as Instant) +;; --------------------------------------------------------------------------- + +(t/deftest offset-date-time-roundtrip + (t/testing "OffsetDateTime is written and decoded as Instant (millis preserved)" + (let [odt (OffsetDateTime/now ZoneOffset/UTC) + millis (.toEpochMilli (.toInstant odt)) + result (roundtrip odt)] + (t/is (instance? Instant result)) + (t/is (= millis (.toEpochMilli ^Instant result))))) + (t/testing "non-UTC offset" + (let [odt (OffsetDateTime/now (ZoneOffset/ofHours 5)) + millis (.toEpochMilli (.toInstant odt)) + result (roundtrip odt)] + (t/is (= millis (.toEpochMilli ^Instant result)))))) + +;; --------------------------------------------------------------------------- +;; Ordered map (custom "linked/map" handler) +;; --------------------------------------------------------------------------- + +(t/deftest ordered-map-roundtrip + (t/is (= (d/ordered-map) (roundtrip (d/ordered-map)))) + (t/is (= (d/ordered-map :a 1) (roundtrip (d/ordered-map :a 1)))) + (t/is (= (d/ordered-map :a 1 :b 2 :c 3) (roundtrip (d/ordered-map :a 1 :b 2 :c 3)))) + (t/testing "ordered-map? is preserved" + (t/is (d/ordered-map? (roundtrip (d/ordered-map :x 1 :y 2))))) + (t/testing "insertion order is preserved" + (let [om (d/ordered-map :c 3 :a 1 :b 2) + rt (roundtrip om)] + (t/is (= [:c :a :b] (vec (keys rt)))))) + (t/testing "large ordered-map" + (let [om (reduce (fn [m i] (assoc m (keyword (str "k" i)) i)) + (d/ordered-map) + (range 20)) + rt (roundtrip om)] + (t/is (d/ordered-map? rt)) + (t/is (= om rt)) + (t/is (= (keys om) (keys rt)))))) + +;; --------------------------------------------------------------------------- +;; Ordered set (custom "linked/set" handler) +;; --------------------------------------------------------------------------- + +(t/deftest ordered-set-roundtrip + (t/is (= (d/ordered-set) (roundtrip (d/ordered-set)))) + (t/is (= (d/ordered-set :a) (roundtrip (d/ordered-set :a)))) + (t/is (= (d/ordered-set :a :b :c) (roundtrip (d/ordered-set :a :b :c)))) + (t/testing "ordered-set? is preserved" + (t/is (d/ordered-set? (roundtrip (d/ordered-set :x :y))))) + (t/testing "insertion order is preserved" + (let [os (d/ordered-set :c :a :b) + rt (roundtrip os)] + (t/is (= [:c :a :b] (vec rt))))) + (t/testing "large ordered-set" + (let [os (reduce conj (d/ordered-set) (range 20)) + rt (roundtrip os)] + (t/is (d/ordered-set? rt)) + (t/is (= os rt))))) + +;; --------------------------------------------------------------------------- +;; UUID (handled by built-in Fressian handlers) +;; --------------------------------------------------------------------------- + +(t/deftest uuid-roundtrip + (let [id (java.util.UUID/randomUUID)] + (t/is (= id (roundtrip id)))) + (t/testing "nil UUID" + (let [nil-uuid (java.util.UUID/fromString "00000000-0000-0000-0000-000000000000")] + (t/is (= nil-uuid (roundtrip nil-uuid))))) + (t/testing "max UUID" + (let [max-uuid (java.util.UUID/fromString "ffffffff-ffff-ffff-ffff-ffffffffffff")] + (t/is (= max-uuid (roundtrip max-uuid))))) + (t/testing "specific well-known UUID" + (let [id (java.util.UUID/fromString "550e8400-e29b-41d4-a716-446655440000")] + (t/is (= id (roundtrip id))))) + (t/testing "uuid? is preserved" + (t/is (uuid? (roundtrip (java.util.UUID/randomUUID)))))) + +;; --------------------------------------------------------------------------- +;; Nested and mixed structures +;; --------------------------------------------------------------------------- + +(t/deftest nested-map-roundtrip + (let [nested {:a {:b {:c 42 :d [1 2 3]} :e :keyword} :f "string"}] + (t/is (= nested (roundtrip nested))))) + +(t/deftest map-with-vector-values + (let [m {:shapes [1 2 3] :colors [:red :green :blue]}] + (t/is (= m (roundtrip m))))) + +(t/deftest vector-of-maps + (let [v [{:id 1 :name "a"} {:id 2 :name "b"} {:id 3 :name "c"}]] + (t/is (= v (roundtrip v))))) + +(t/deftest mixed-collection-types + (let [data {:vec [1 2 3] + :set #{:a :b :c} + :map {:nested true} + :kw :some/keyword + :sym 'some/symbol + :bigint 12345678901234567890N + :ratio 22/7 + :str "hello" + :num 42 + :bool true + :nil-val nil}] + (t/is (= data (roundtrip data))))) + +(t/deftest deeply-nested-structure + (let [data (reduce (fn [acc i] {:level i :child acc}) + {:leaf true} + (range 20))] + (t/is (= data (roundtrip data))))) + +(t/deftest penpot-like-shape-map + "Simulates a Penpot shape-like structure with UUIDs, keywords, and nested maps." + (let [id (java.util.UUID/fromString "550e8400-e29b-41d4-a716-446655440001") + frame-id (java.util.UUID/fromString "550e8400-e29b-41d4-a716-446655440002") + shape {:id id + :frame-id frame-id + :type :rect + :name "My Shape" + :x 100.5 + :y 200.0 + :width 300.0 + :height 150.0 + :fills [{:fill-color "#FF0000" :fill-opacity 1.0}] + :strokes [] + :hidden false + :blocked false}] + (t/is (= shape (roundtrip shape))))) + +(t/deftest penpot-like-objects-map + "Simulates a Penpot page objects map with multiple shapes." + (let [ids (mapv #(java.util.UUID/fromString + (format "550e8400-e29b-41d4-a716-%012d" %)) + (range 5)) + objs (into {} (map (fn [id] [id {:id id :type :rect :name (str id)}]) ids)) + data {:objects objs}] + (t/is (= data (roundtrip data))))) + +;; --------------------------------------------------------------------------- +;; Idempotency: encode→decode→encode must yield equal bytes +;; --------------------------------------------------------------------------- + +(t/deftest encode-idempotency + (doseq [v [nil true false 0 1 -1 42 Long/MAX_VALUE 3.14 "" "hello" + :kw :ns/kw 'sym 'ns/sym + [] [1 2 3] #{} #{:a} {} {:a 1} + 1/3 42N]] + (let [enc1 (fres/encode v) + enc2 (-> v fres/encode fres/decode fres/encode)] + (t/is (= (vec enc1) (vec enc2)) + (str "Idempotency failed for: " (pr-str v)))))) + +;; --------------------------------------------------------------------------- +;; Multiple encode/decode roundtrips in sequence (regression / ordering) +;; --------------------------------------------------------------------------- + +(t/deftest multiple-roundtrips-are-independent + (t/testing "encoding multiple values independently does not cross-contaminate" + (let [a (fres/encode {:key :val-a}) + b (fres/encode {:key :val-b}) + da (fres/decode a) + db (fres/decode b)] + (t/is (= {:key :val-a} da)) + (t/is (= {:key :val-b} db)) + (t/is (not= da db))))) + +;; --------------------------------------------------------------------------- +;; Edge cases: empty collections +;; --------------------------------------------------------------------------- + +(t/deftest empty-collections-roundtrip + (t/is (= {} (roundtrip {}))) + (t/is (= [] (roundtrip []))) + (t/is (= #{} (roundtrip #{}))) + (t/is (= "" (roundtrip ""))) + (t/is (= (d/ordered-map) (roundtrip (d/ordered-map)))) + (t/is (= (d/ordered-set) (roundtrip (d/ordered-set))))) + +;; --------------------------------------------------------------------------- +;; Edge cases: collections containing nil +;; --------------------------------------------------------------------------- + +(t/deftest collections-with-nil-roundtrip + (t/is (= [nil] (roundtrip [nil]))) + (t/is (= [nil nil nil] (roundtrip [nil nil nil]))) + (t/is (= {:a nil :b nil} (roundtrip {:a nil :b nil}))) + (t/is (= [1 nil 3] (roundtrip [1 nil 3])))) + +;; --------------------------------------------------------------------------- +;; Edge cases: single-element collections +;; --------------------------------------------------------------------------- + +(t/deftest single-element-collections + (t/is (= [42] (roundtrip [42]))) + (t/is (= #{:only} (roundtrip #{:only}))) + (t/is (= {:only-key "only-val"} (roundtrip {:only-key "only-val"})))) + +;; --------------------------------------------------------------------------- +;; Edge cases: boundary map sizes (ArrayMap/HashMap threshold) +;; --------------------------------------------------------------------------- + +(t/deftest map-size-boundary + (t/testing "7-entry map (below threshold → ArrayMap)" + (let [m (into {} (map (fn [i] [(keyword (str "k" i)) i]) (range 7)))] + (t/is (= m (roundtrip m))))) + (t/testing "8-entry map (at/above threshold → may become HashMap)" + (let [m (into {} (map (fn [i] [(keyword (str "k" i)) i]) (range 8)))] + (t/is (= m (roundtrip m))))) + (t/testing "16-entry map (well above threshold)" + (let [m (into {} (map (fn [i] [(keyword (str "k" i)) i]) (range 16)))] + (t/is (= m (roundtrip m)))))) + +;; --------------------------------------------------------------------------- +;; Edge cases: byte arrays +;; --------------------------------------------------------------------------- + +(t/deftest byte-array-roundtrip + (let [data (byte-array [0 1 2 3 127 -128 -1])] + (t/is (= (vec data) (vec ^bytes (roundtrip data)))))) + +;; --------------------------------------------------------------------------- +;; Ordered-map key ordering survives large number of keys +;; --------------------------------------------------------------------------- + +(t/deftest ordered-map-key-ordering-stress + (let [keys-in-order (mapv #(keyword (str "key-" (format "%03d" %))) (range 50)) + om (reduce (fn [m k] (assoc m k (name k))) (d/ordered-map) keys-in-order) + rt (roundtrip om)] + (t/is (= keys-in-order (vec (keys rt)))))) + +;; --------------------------------------------------------------------------- +;; Ordered-set element ordering survives large number of elements +;; --------------------------------------------------------------------------- + +(t/deftest ordered-set-element-ordering-stress + (let [elems-in-order (mapv #(keyword (str "elem-" (format "%03d" %))) (range 50)) + os (reduce conj (d/ordered-set) elems-in-order) + rt (roundtrip os)] + (t/is (= elems-in-order (vec rt))))) + +;; --------------------------------------------------------------------------- +;; Complex Penpot-domain: ordered-map with UUID keys and shape values +;; --------------------------------------------------------------------------- + +(t/deftest ordered-map-with-uuid-keys + (let [ids (mapv #(java.util.UUID/fromString + (format "550e8400-e29b-41d4-a716-%012d" %)) + (range 5)) + om (reduce (fn [m id] (assoc m id {:type :rect :id id})) + (d/ordered-map) + ids) + rt (roundtrip om)] + (t/is (d/ordered-map? rt)) + (t/is (= om rt)) + (t/is (= (keys om) (keys rt))))) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index b8a9fc8934..31a2eab331 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -6,6 +6,7 @@ (ns common-tests.runner (:require + #?(:clj [common-tests.fressian-test]) [clojure.test :as t] [common-tests.buffer-test] [common-tests.colors-test] @@ -81,6 +82,7 @@ 'common-tests.buffer-test 'common-tests.colors-test 'common-tests.data-test + #?(:clj 'common-tests.fressian-test) 'common-tests.files-changes-test 'common-tests.files-migrations-test 'common-tests.geom-align-test From 3264bc746f6cc80b70eb546788ddbb1141a1b2d6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:33:10 +0200 Subject: [PATCH 06/89] :wrench: Backport ci configuration changes from develop --- .github/workflows/build-docker-devenv.yml | 6 +- .github/workflows/build-docker.yml | 18 ++-- .github/workflows/commit-checker.yml | 3 + .github/workflows/plugins-deploy-api-doc.yml | 2 +- .github/workflows/plugins-deploy-package.yml | 2 +- .github/workflows/plugins-deploy-packages.yml | 2 +- .../workflows/plugins-deploy-styles-doc.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/tests-mcp.yml | 6 +- .github/workflows/tests.yml | 89 ++++++------------- frontend/scripts/test-e2e | 2 +- 11 files changed, 50 insertions(+), 84 deletions(-) diff --git a/.github/workflows/build-docker-devenv.yml b/.github/workflows/build-docker-devenv.yml index d48e401a86..3ba45267a5 100644 --- a/.github/workflows/build-docker-devenv.yml +++ b/.github/workflows/build-docker-devenv.yml @@ -19,16 +19,16 @@ jobs: uses: actions/checkout@v6 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.PUB_DOCKER_USERNAME }} password: ${{ secrets.PUB_DOCKER_PASSWORD }} - name: Build and push DevEnv Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'penpotapp/devenv' with: diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index ff6375b13e..18ac6aec9f 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -63,10 +63,10 @@ jobs: popd - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ secrets.DOCKER_REGISTRY }} username: ${{ secrets.DOCKER_USERNAME }} @@ -76,14 +76,14 @@ jobs: # images from DockerHub for unregistered users. # https://docs.docker.com/docker-hub/usage/ - name: Login to DockerHub Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.PUB_DOCKER_USERNAME }} password: ${{ secrets.PUB_DOCKER_PASSWORD }} - name: Extract metadata (tags, labels) id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: frontend @@ -95,7 +95,7 @@ jobs: bundle_version=${{ steps.bundles.outputs.bundle_version }} - name: Build and push Backend Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'backend' BUNDLE_PATH: './bundle-backend' @@ -110,7 +110,7 @@ jobs: cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max - name: Build and push Frontend Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'frontend' BUNDLE_PATH: './bundle-frontend' @@ -125,7 +125,7 @@ jobs: cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max - name: Build and push Exporter Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'exporter' BUNDLE_PATH: './bundle-exporter' @@ -140,7 +140,7 @@ jobs: cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max - name: Build and push Storybook Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'storybook' BUNDLE_PATH: './bundle-storybook' @@ -155,7 +155,7 @@ jobs: cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max - name: Build and push MCP Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'mcp' BUNDLE_PATH: './bundle-mcp' diff --git a/.github/workflows/commit-checker.yml b/.github/workflows/commit-checker.yml index f7126a40cb..a80e6e4cc0 100644 --- a/.github/workflows/commit-checker.yml +++ b/.github/workflows/commit-checker.yml @@ -6,12 +6,14 @@ on: - edited - reopened - synchronize + - ready_for_review pull_request_target: types: - opened - edited - reopened - synchronize + - ready_for_review push: branches: - main @@ -20,6 +22,7 @@ on: jobs: check-commit-message: + if: ${{ !github.event.pull_request.draft }} name: Check Commit Message runs-on: ubuntu-latest steps: diff --git a/.github/workflows/plugins-deploy-api-doc.yml b/.github/workflows/plugins-deploy-api-doc.yml index 815553749d..51be85e45e 100644 --- a/.github/workflows/plugins-deploy-api-doc.yml +++ b/.github/workflows/plugins-deploy-api-doc.yml @@ -62,7 +62,7 @@ jobs: run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT - name: Cache pnpm store - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ steps.pnpm-store.outputs.STORE_PATH }} key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }} diff --git a/.github/workflows/plugins-deploy-package.yml b/.github/workflows/plugins-deploy-package.yml index f8f558569d..137ba6f7fa 100644 --- a/.github/workflows/plugins-deploy-package.yml +++ b/.github/workflows/plugins-deploy-package.yml @@ -62,7 +62,7 @@ jobs: run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT - name: Cache pnpm store - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ steps.pnpm-store.outputs.STORE_PATH }} key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }} diff --git a/.github/workflows/plugins-deploy-packages.yml b/.github/workflows/plugins-deploy-packages.yml index 01f9284972..943e4b790d 100644 --- a/.github/workflows/plugins-deploy-packages.yml +++ b/.github/workflows/plugins-deploy-packages.yml @@ -38,7 +38,7 @@ jobs: steps: - uses: actions/checkout@v6 - id: filter - uses: dorny/paths-filter@v3 + uses: dorny/paths-filter@v4 with: filters: | colors_to_tokens: diff --git a/.github/workflows/plugins-deploy-styles-doc.yml b/.github/workflows/plugins-deploy-styles-doc.yml index 9fbcac880e..47f0d1cc24 100644 --- a/.github/workflows/plugins-deploy-styles-doc.yml +++ b/.github/workflows/plugins-deploy-styles-doc.yml @@ -60,7 +60,7 @@ jobs: run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT - name: Cache pnpm store - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ steps.pnpm-store.outputs.STORE_PATH }} key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 538cd9d5a0..21c0eb6de2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,7 +93,7 @@ jobs: # --- Create GitHub release --- - name: Create GitHub release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/.github/workflows/tests-mcp.yml b/.github/workflows/tests-mcp.yml index 9f2a4ed589..0ab2909b72 100644 --- a/.github/workflows/tests-mcp.yml +++ b/.github/workflows/tests-mcp.yml @@ -10,6 +10,7 @@ on: types: - opened - synchronize + - ready_for_review paths: - 'mcp/**' @@ -24,8 +25,9 @@ on: - 'mcp/**' jobs: - test: - name: "Test" + test-mcp: + if: ${{ !github.event.pull_request.draft }} + name: "Test MCP" runs-on: penpot-runner-02 container: penpotapp/devenv:latest diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 623f164aeb..afcffb0ae7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,6 +9,7 @@ on: types: - opened - synchronize + - ready_for_review push: branches: - develop @@ -20,6 +21,7 @@ concurrency: jobs: lint: + if: ${{ !github.event.pull_request.draft }} name: "Linter" runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -79,6 +81,7 @@ jobs: pnpm run lint test-common: + if: ${{ !github.event.pull_request.draft }} name: "Common Tests" runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -93,6 +96,7 @@ jobs: ./scripts/test test-plugins: + if: ${{ !github.event.pull_request.draft }} name: Plugins Runtime Linter & Tests runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -143,21 +147,15 @@ jobs: run: pnpm run build:styles-example test-frontend: + if: ${{ !github.event.pull_request.draft }} name: "Frontend Tests" runs-on: penpot-runner-02 container: penpotapp/devenv:latest - needs: test-render-wasm steps: - name: Checkout repository uses: actions/checkout@v6 - - name: Restore shared.js - uses: actions/cache/restore@v4 - with: - key: "render-wasm-shared-js-${{ github.sha }}" - path: frontend/src/app/render_wasm/api/shared.js - - name: Unit Tests working-directory: ./frontend run: | @@ -171,6 +169,7 @@ jobs: ./scripts/test-components test-render-wasm: + if: ${{ !github.event.pull_request.draft }} name: "Render WASM Tests" runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -194,20 +193,8 @@ jobs: run: | ./test - - name: Copy shared.js artifact - working-directory: ./render-wasm - run: | - SHARED_FILE=$(find target -name render_wasm_shared.js | head -n 1); - mkdir -p ../frontend/src/app/render_wasm/api; - cp $SHARED_FILE ../frontend/src/app/render_wasm/api/shared.js; - - - name: Cache shared.js - uses: actions/cache@v4 - with: - key: "render-wasm-shared-js-${{ github.sha }}" - path: frontend/src/app/render_wasm/api/shared.js - test-backend: + if: ${{ !github.event.pull_request.draft }} name: "Backend Tests" runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -247,6 +234,7 @@ jobs: clojure -M:dev:test --reporter kaocha.report/documentation test-library: + if: ${{ !github.event.pull_request.draft }} name: "Library Tests" runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -261,6 +249,7 @@ jobs: ./scripts/test build-integration: + if: ${{ !github.event.pull_request.draft }} name: "Build Integration Bundle" runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -275,14 +264,14 @@ jobs: ./scripts/build - name: Store Bundle Cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: key: "integration-bundle-${{ github.sha }}" path: frontend/resources/public - test-integration-1: - name: "Integration Tests 1/4" + if: ${{ !github.event.pull_request.draft }} + name: "Integration Tests 1/3" runs-on: penpot-runner-02 container: penpotapp/devenv:latest needs: build-integration @@ -292,7 +281,7 @@ jobs: uses: actions/checkout@v6 - name: Restore Cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: key: "integration-bundle-${{ github.sha }}" path: frontend/resources/public @@ -300,10 +289,10 @@ jobs: - name: Run Tests working-directory: ./frontend run: | - ./scripts/test-e2e --shard="1/4"; + ./scripts/test-e2e --shard="1/3"; - name: Upload test result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: integration-tests-result-1 @@ -312,7 +301,8 @@ jobs: retention-days: 3 test-integration-2: - name: "Integration Tests 2/4" + if: ${{ !github.event.pull_request.draft }} + name: "Integration Tests 2/3" runs-on: penpot-runner-02 container: penpotapp/devenv:latest needs: build-integration @@ -322,7 +312,7 @@ jobs: uses: actions/checkout@v6 - name: Restore Cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: key: "integration-bundle-${{ github.sha }}" path: frontend/resources/public @@ -330,10 +320,10 @@ jobs: - name: Run Tests working-directory: ./frontend run: | - ./scripts/test-e2e --shard="2/4"; + ./scripts/test-e2e --shard="2/3"; - name: Upload test result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: integration-tests-result-2 @@ -342,7 +332,8 @@ jobs: retention-days: 3 test-integration-3: - name: "Integration Tests 3/4" + if: ${{ !github.event.pull_request.draft }} + name: "Integration Tests 3/3" runs-on: penpot-runner-02 container: penpotapp/devenv:latest needs: build-integration @@ -352,7 +343,7 @@ jobs: uses: actions/checkout@v6 - name: Restore Cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: key: "integration-bundle-${{ github.sha }}" path: frontend/resources/public @@ -360,43 +351,13 @@ jobs: - name: Run Tests working-directory: ./frontend run: | - ./scripts/test-e2e --shard="3/4"; + ./scripts/test-e2e --shard="3/3"; - name: Upload test result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: integration-tests-result-3 path: frontend/test-results/ overwrite: true retention-days: 3 - - test-integration-4: - name: "Integration Tests 4/4" - runs-on: penpot-runner-02 - container: penpotapp/devenv:latest - needs: build-integration - - steps: - - name: Checkout Repository - uses: actions/checkout@v6 - - - name: Restore Cache - uses: actions/cache/restore@v4 - with: - key: "integration-bundle-${{ github.sha }}" - path: frontend/resources/public - - - name: Run Tests - working-directory: ./frontend - run: | - ./scripts/test-e2e --shard="4/4"; - - - name: Upload test result - uses: actions/upload-artifact@v4 - if: always() - with: - name: integration-tests-result-4 - path: frontend/test-results/ - overwrite: true - retention-days: 3 diff --git a/frontend/scripts/test-e2e b/frontend/scripts/test-e2e index dd25bed989..fca7cf941e 100755 --- a/frontend/scripts/test-e2e +++ b/frontend/scripts/test-e2e @@ -5,4 +5,4 @@ SCRIPT_DIR=$(dirname $0); set -ex $SCRIPT_DIR/setup; -pnpm run test:e2e -x --workers=2 --reporter=list "$@"; +pnpm run test:e2e -x --workers=1 --reporter=list "$@"; From 62f34546079a9f5c738e08cf2ec9fbaba6125feb Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:33:10 +0200 Subject: [PATCH 07/89] :wrench: Backport ci configuration changes from develop --- .github/workflows/build-docker-devenv.yml | 6 +- .github/workflows/build-docker.yml | 18 ++--- .github/workflows/commit-checker.yml | 3 + .github/workflows/plugins-deploy-api-doc.yml | 2 +- .github/workflows/plugins-deploy-package.yml | 2 +- .github/workflows/plugins-deploy-packages.yml | 2 +- .../workflows/plugins-deploy-styles-doc.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/tests-mcp.yml | 6 +- .github/workflows/tests.yml | 69 +++++++------------ frontend/scripts/test-e2e | 2 +- 11 files changed, 50 insertions(+), 64 deletions(-) diff --git a/.github/workflows/build-docker-devenv.yml b/.github/workflows/build-docker-devenv.yml index d48e401a86..3ba45267a5 100644 --- a/.github/workflows/build-docker-devenv.yml +++ b/.github/workflows/build-docker-devenv.yml @@ -19,16 +19,16 @@ jobs: uses: actions/checkout@v6 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.PUB_DOCKER_USERNAME }} password: ${{ secrets.PUB_DOCKER_PASSWORD }} - name: Build and push DevEnv Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'penpotapp/devenv' with: diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index ff6375b13e..18ac6aec9f 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -63,10 +63,10 @@ jobs: popd - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ secrets.DOCKER_REGISTRY }} username: ${{ secrets.DOCKER_USERNAME }} @@ -76,14 +76,14 @@ jobs: # images from DockerHub for unregistered users. # https://docs.docker.com/docker-hub/usage/ - name: Login to DockerHub Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.PUB_DOCKER_USERNAME }} password: ${{ secrets.PUB_DOCKER_PASSWORD }} - name: Extract metadata (tags, labels) id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: frontend @@ -95,7 +95,7 @@ jobs: bundle_version=${{ steps.bundles.outputs.bundle_version }} - name: Build and push Backend Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'backend' BUNDLE_PATH: './bundle-backend' @@ -110,7 +110,7 @@ jobs: cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max - name: Build and push Frontend Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'frontend' BUNDLE_PATH: './bundle-frontend' @@ -125,7 +125,7 @@ jobs: cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max - name: Build and push Exporter Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'exporter' BUNDLE_PATH: './bundle-exporter' @@ -140,7 +140,7 @@ jobs: cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max - name: Build and push Storybook Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'storybook' BUNDLE_PATH: './bundle-storybook' @@ -155,7 +155,7 @@ jobs: cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max - name: Build and push MCP Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'mcp' BUNDLE_PATH: './bundle-mcp' diff --git a/.github/workflows/commit-checker.yml b/.github/workflows/commit-checker.yml index f7126a40cb..a80e6e4cc0 100644 --- a/.github/workflows/commit-checker.yml +++ b/.github/workflows/commit-checker.yml @@ -6,12 +6,14 @@ on: - edited - reopened - synchronize + - ready_for_review pull_request_target: types: - opened - edited - reopened - synchronize + - ready_for_review push: branches: - main @@ -20,6 +22,7 @@ on: jobs: check-commit-message: + if: ${{ !github.event.pull_request.draft }} name: Check Commit Message runs-on: ubuntu-latest steps: diff --git a/.github/workflows/plugins-deploy-api-doc.yml b/.github/workflows/plugins-deploy-api-doc.yml index 815553749d..51be85e45e 100644 --- a/.github/workflows/plugins-deploy-api-doc.yml +++ b/.github/workflows/plugins-deploy-api-doc.yml @@ -62,7 +62,7 @@ jobs: run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT - name: Cache pnpm store - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ steps.pnpm-store.outputs.STORE_PATH }} key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }} diff --git a/.github/workflows/plugins-deploy-package.yml b/.github/workflows/plugins-deploy-package.yml index f8f558569d..137ba6f7fa 100644 --- a/.github/workflows/plugins-deploy-package.yml +++ b/.github/workflows/plugins-deploy-package.yml @@ -62,7 +62,7 @@ jobs: run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT - name: Cache pnpm store - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ steps.pnpm-store.outputs.STORE_PATH }} key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }} diff --git a/.github/workflows/plugins-deploy-packages.yml b/.github/workflows/plugins-deploy-packages.yml index 01f9284972..943e4b790d 100644 --- a/.github/workflows/plugins-deploy-packages.yml +++ b/.github/workflows/plugins-deploy-packages.yml @@ -38,7 +38,7 @@ jobs: steps: - uses: actions/checkout@v6 - id: filter - uses: dorny/paths-filter@v3 + uses: dorny/paths-filter@v4 with: filters: | colors_to_tokens: diff --git a/.github/workflows/plugins-deploy-styles-doc.yml b/.github/workflows/plugins-deploy-styles-doc.yml index 9fbcac880e..47f0d1cc24 100644 --- a/.github/workflows/plugins-deploy-styles-doc.yml +++ b/.github/workflows/plugins-deploy-styles-doc.yml @@ -60,7 +60,7 @@ jobs: run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT - name: Cache pnpm store - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ steps.pnpm-store.outputs.STORE_PATH }} key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 538cd9d5a0..21c0eb6de2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,7 +93,7 @@ jobs: # --- Create GitHub release --- - name: Create GitHub release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/.github/workflows/tests-mcp.yml b/.github/workflows/tests-mcp.yml index 9f2a4ed589..0ab2909b72 100644 --- a/.github/workflows/tests-mcp.yml +++ b/.github/workflows/tests-mcp.yml @@ -10,6 +10,7 @@ on: types: - opened - synchronize + - ready_for_review paths: - 'mcp/**' @@ -24,8 +25,9 @@ on: - 'mcp/**' jobs: - test: - name: "Test" + test-mcp: + if: ${{ !github.event.pull_request.draft }} + name: "Test MCP" runs-on: penpot-runner-02 container: penpotapp/devenv:latest diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 472fc36656..afcffb0ae7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,6 +9,7 @@ on: types: - opened - synchronize + - ready_for_review push: branches: - develop @@ -20,6 +21,7 @@ concurrency: jobs: lint: + if: ${{ !github.event.pull_request.draft }} name: "Linter" runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -79,6 +81,7 @@ jobs: pnpm run lint test-common: + if: ${{ !github.event.pull_request.draft }} name: "Common Tests" runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -93,6 +96,7 @@ jobs: ./scripts/test test-plugins: + if: ${{ !github.event.pull_request.draft }} name: Plugins Runtime Linter & Tests runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -143,6 +147,7 @@ jobs: run: pnpm run build:styles-example test-frontend: + if: ${{ !github.event.pull_request.draft }} name: "Frontend Tests" runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -164,6 +169,7 @@ jobs: ./scripts/test-components test-render-wasm: + if: ${{ !github.event.pull_request.draft }} name: "Render WASM Tests" runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -188,6 +194,7 @@ jobs: ./test test-backend: + if: ${{ !github.event.pull_request.draft }} name: "Backend Tests" runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -227,6 +234,7 @@ jobs: clojure -M:dev:test --reporter kaocha.report/documentation test-library: + if: ${{ !github.event.pull_request.draft }} name: "Library Tests" runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -241,6 +249,7 @@ jobs: ./scripts/test build-integration: + if: ${{ !github.event.pull_request.draft }} name: "Build Integration Bundle" runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -255,14 +264,14 @@ jobs: ./scripts/build - name: Store Bundle Cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: key: "integration-bundle-${{ github.sha }}" path: frontend/resources/public - test-integration-1: - name: "Integration Tests 1/4" + if: ${{ !github.event.pull_request.draft }} + name: "Integration Tests 1/3" runs-on: penpot-runner-02 container: penpotapp/devenv:latest needs: build-integration @@ -272,7 +281,7 @@ jobs: uses: actions/checkout@v6 - name: Restore Cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: key: "integration-bundle-${{ github.sha }}" path: frontend/resources/public @@ -280,10 +289,10 @@ jobs: - name: Run Tests working-directory: ./frontend run: | - ./scripts/test-e2e --shard="1/4"; + ./scripts/test-e2e --shard="1/3"; - name: Upload test result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: integration-tests-result-1 @@ -292,7 +301,8 @@ jobs: retention-days: 3 test-integration-2: - name: "Integration Tests 2/4" + if: ${{ !github.event.pull_request.draft }} + name: "Integration Tests 2/3" runs-on: penpot-runner-02 container: penpotapp/devenv:latest needs: build-integration @@ -302,7 +312,7 @@ jobs: uses: actions/checkout@v6 - name: Restore Cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: key: "integration-bundle-${{ github.sha }}" path: frontend/resources/public @@ -310,10 +320,10 @@ jobs: - name: Run Tests working-directory: ./frontend run: | - ./scripts/test-e2e --shard="2/4"; + ./scripts/test-e2e --shard="2/3"; - name: Upload test result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: integration-tests-result-2 @@ -322,7 +332,8 @@ jobs: retention-days: 3 test-integration-3: - name: "Integration Tests 3/4" + if: ${{ !github.event.pull_request.draft }} + name: "Integration Tests 3/3" runs-on: penpot-runner-02 container: penpotapp/devenv:latest needs: build-integration @@ -332,7 +343,7 @@ jobs: uses: actions/checkout@v6 - name: Restore Cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: key: "integration-bundle-${{ github.sha }}" path: frontend/resources/public @@ -340,43 +351,13 @@ jobs: - name: Run Tests working-directory: ./frontend run: | - ./scripts/test-e2e --shard="3/4"; + ./scripts/test-e2e --shard="3/3"; - name: Upload test result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: integration-tests-result-3 path: frontend/test-results/ overwrite: true retention-days: 3 - - test-integration-4: - name: "Integration Tests 4/4" - runs-on: penpot-runner-02 - container: penpotapp/devenv:latest - needs: build-integration - - steps: - - name: Checkout Repository - uses: actions/checkout@v6 - - - name: Restore Cache - uses: actions/cache/restore@v4 - with: - key: "integration-bundle-${{ github.sha }}" - path: frontend/resources/public - - - name: Run Tests - working-directory: ./frontend - run: | - ./scripts/test-e2e --shard="4/4"; - - - name: Upload test result - uses: actions/upload-artifact@v4 - if: always() - with: - name: integration-tests-result-4 - path: frontend/test-results/ - overwrite: true - retention-days: 3 diff --git a/frontend/scripts/test-e2e b/frontend/scripts/test-e2e index dd25bed989..fca7cf941e 100755 --- a/frontend/scripts/test-e2e +++ b/frontend/scripts/test-e2e @@ -5,4 +5,4 @@ SCRIPT_DIR=$(dirname $0); set -ex $SCRIPT_DIR/setup; -pnpm run test:e2e -x --workers=2 --reporter=list "$@"; +pnpm run test:e2e -x --workers=1 --reporter=list "$@"; From dc5f22223013a0ebd61d90e19f78a1e03cebb3fb Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Tue, 14 Apr 2026 12:18:38 +0200 Subject: [PATCH 08/89] :bug: Improve change token set performance --- .../app/main/data/workspace/wasm_text.cljs | 2 +- frontend/src/app/util/browser_history.js | 2 +- .../frontend_tests/tokens/helpers/state.cljs | 4 ++- .../tokens/logic/token_actions_test.cljs | 30 +++++++++++++------ 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs index c175b46bdf..0f8fc23652 100644 --- a/frontend/src/app/main/data/workspace/wasm_text.cljs +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -194,4 +194,4 @@ ptk/WatchEvent (watch [_ _ _] (->> (rx/from ids) - (rx/map resize-wasm-text))))) + (rx/map resize-wasm-text-debounce))))) diff --git a/frontend/src/app/util/browser_history.js b/frontend/src/app/util/browser_history.js index d206b83b7f..074da03f70 100644 --- a/frontend/src/app/util/browser_history.js +++ b/frontend/src/app/util/browser_history.js @@ -44,6 +44,6 @@ goog.scope(function() { } self.replace_token_BANG_ = function(instance, token) { - instance.replaceToken(token); + instance?.replaceToken(token); } }); diff --git a/frontend/test/frontend_tests/tokens/helpers/state.cljs b/frontend/test/frontend_tests/tokens/helpers/state.cljs index 9de2e773e5..79f0081e9f 100644 --- a/frontend/test/frontend_tests/tokens/helpers/state.cljs +++ b/frontend/test/frontend_tests/tokens/helpers/state.cljs @@ -43,7 +43,9 @@ (fn [stream] (->> stream #_(rx/tap #(prn (ptk/type %))) - (rx/filter #(ptk/type? event-type %))))) + (rx/filter #(ptk/type? event-type %)) + ;; Safeguard timeout + (rx/timeout 200 (rx/of :the/end))))) (def stop-on-send-update-indices "Stops on `send-update-indices` function being called, which should be the last function of an event chain." 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 0af65155bf..956a2977a0 100644 --- a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs +++ b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs @@ -13,6 +13,7 @@ [app.common.types.text :as txt] [app.common.types.tokens-lib :as ctob] [app.main.data.workspace.tokens.application :as dwta] + [app.main.data.workspace.wasm-text :as dwwt] [cljs.test :as t :include-macros true] [cuerdas.core :as str] [frontend-tests.helpers.pages :as thp] @@ -58,8 +59,11 @@ (ctob/add-token (cthi/id :set-a) (ctob/make-token reference-border-radius-token)))))) +(def debounce-text-stop + (tohs/stop-on ::dwwt/resize-wasm-text-debounce-commit)) + (t/deftest test-apply-token - (t/testing "applies token to shape and updates shape attributes to resolved value" + (t/testing "applies token to shape and updates shape attributes to resolved value" (t/async done (let [file (setup-file-with-tokens) @@ -553,7 +557,8 @@ (t/is (= (:font-size style-text-blocks) "24")) (t/testing "WASM text mocks were exercised" (t/is (pos? (thw/call-count :set-shape-text-content))) - (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) + (t/is (pos? (thw/call-count :get-text-dimensions)))))) + debounce-text-stop))))) (t/deftest test-apply-line-height (t/testing "applies line-height token and updates the text line-height" @@ -591,7 +596,8 @@ (t/is (= (:line-height style-text-blocks) 1.5)) (t/testing "WASM text mocks were exercised" (t/is (pos? (thw/call-count :set-shape-text-content))) - (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) + (t/is (pos? (thw/call-count :get-text-dimensions)))))) + debounce-text-stop))))) (t/deftest test-apply-letter-spacing (t/testing "applies letter-spacing token and updates the text letter-spacing" @@ -629,7 +635,8 @@ (t/is (= (:letter-spacing style-text-blocks) "2")) (t/testing "WASM text mocks were exercised" (t/is (pos? (thw/call-count :set-shape-text-content))) - (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) + (t/is (pos? (thw/call-count :get-text-dimensions)))))) + debounce-text-stop))))) (t/deftest test-apply-font-family (t/testing "applies font-family token and updates the text font-family" @@ -667,7 +674,8 @@ (t/is (= (:font-family style-text-blocks) (:font-id txt/default-text-attrs))) (t/testing "WASM text mocks were exercised" (t/is (pos? (thw/call-count :set-shape-text-content))) - (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) + (t/is (pos? (thw/call-count :get-text-dimensions)))))) + debounce-text-stop))))) (t/deftest test-apply-text-case (t/testing "applies text-case token and updates the text transform" @@ -775,7 +783,8 @@ (t/is (= (:font-weight style-text-blocks) "400")) (t/testing "WASM text mocks were exercised" (t/is (pos? (thw/call-count :set-shape-text-content))) - (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) + (t/is (pos? (thw/call-count :get-text-dimensions)))))) + debounce-text-stop))))) (t/deftest test-toggle-token-none (t/testing "should apply token to all selected items, where no item has the token applied" @@ -1001,7 +1010,8 @@ (t/is (= (:text-decoration style-text-blocks) "underline")) (t/testing "WASM text mocks were exercised" (t/is (pos? (thw/call-count :set-shape-text-content))) - (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) + (t/is (pos? (thw/call-count :get-text-dimensions)))))) + debounce-text-stop))))) (t/deftest test-apply-reference-typography-token (t/testing "applies typography (composite) tokens with references" @@ -1049,7 +1059,8 @@ (t/is (= (:font-family style-text-blocks) "Arial")) (t/testing "WASM text mocks were exercised" (t/is (pos? (thw/call-count :set-shape-text-content))) - (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) + (t/is (pos? (thw/call-count :get-text-dimensions)))))) + debounce-text-stop))))) (t/deftest test-unapply-atomic-tokens-on-composite-apply (t/testing "unapplies atomic typography tokens when applying composite token" @@ -1206,4 +1217,5 @@ (t/is (nil? (:typography-ref-file text-node-3))) (t/testing "WASM text mocks were exercised" (t/is (pos? (thw/call-count :set-shape-text-content))) - (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) + (t/is (pos? (thw/call-count :get-text-dimensions)))))) + debounce-text-stop))))) From 18f0ad246f44f442fa4b02fbd433c1bc2bf96cb4 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 13 Apr 2026 13:24:54 +0000 Subject: [PATCH 09/89] :bug: Fix parse-long crash when index query param is duplicated in URL lambdaisland/uri's query-string->map uses :multikeys :duplicates by default: a key that appears once yields a plain string, but the same key repeated yields a vector. cljs.core/parse-long only accepts strings and therefore threw "Expected string, got: object" whenever a URL contained a duplicate 'index' parameter. Add rt/get-query-param to app.main.router. The helper returns the scalar value of a query param key, taking the last element when the value is a sequential (i.e. the key was repeated). Use it at every call site that feeds a query-param value into parse-long, in both app.main.ui (page*) and app.main.data.viewer. --- frontend/src/app/main/data/viewer.cljs | 14 +++++++------- frontend/src/app/main/router.cljs | 10 ++++++++++ frontend/src/app/main/ui.cljs | 2 +- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/main/data/viewer.cljs b/frontend/src/app/main/data/viewer.cljs index c2d42d680c..c1f6083d13 100644 --- a/frontend/src/app/main/data/viewer.cljs +++ b/frontend/src/app/main/data/viewer.cljs @@ -204,7 +204,7 @@ (watch [_ state _] (let [route (:route state) qparams (:query-params route) - index (some-> (:index qparams) parse-long) + index (some-> (rt/get-query-param qparams :index) parse-long) frame-id (some-> (:frame-id qparams) uuid/parse)] (rx/merge (rx/of (case (:zoom qparams) @@ -301,7 +301,7 @@ (update [_ state] (let [params (rt/get-params state) page-id (some-> (:page-id params) uuid/parse) - index (some-> (:index params) parse-long) + index (some-> (rt/get-query-param params :index) parse-long) frames (dm/get-in state [:viewer :pages page-id :frames]) index (min (or index 0) (max 0 (dec (count frames)))) @@ -325,7 +325,7 @@ (let [params (rt/get-params state) page-id (some-> (:page-id params) uuid/parse) - index (some-> (:index params) parse-long) + index (some-> (rt/get-query-param params :index) parse-long) frames (dm/get-in state [:viewer :pages page-id :frames]) index (min (or index 0) (max 0 (dec (count frames)))) @@ -399,7 +399,7 @@ ptk/WatchEvent (watch [_ state _] (let [params (rt/get-params state) - index (some-> params :index parse-long)] + index (some-> (rt/get-query-param params :index) parse-long)] (when (pos? index) (rx/of (dcmt/close-thread) @@ -415,7 +415,7 @@ ptk/WatchEvent (watch [_ state _] (let [params (rt/get-params state) - index (some-> params :index parse-long) + index (some-> (rt/get-query-param params :index) parse-long) page-id (some-> params :page-id uuid/parse) total (count (get-in state [:viewer :pages page-id :frames]))] @@ -530,7 +530,7 @@ (let [route (:route state) qparams (:query-params route) page-id (some-> (:page-id qparams) uuid/parse) - index (some-> (:index qparams) parse-long) + index (some-> (rt/get-query-param qparams :index) parse-long) frames (get-in state [:viewer :pages page-id :frames]) frame (get frames index)] (cond-> state @@ -744,7 +744,7 @@ (let [route (:route state) qparams (:query-params route) page-id (some-> (:page-id qparams) uuid/parse) - index (some-> (:index qparams) parse-long) + index (some-> (rt/get-query-param qparams :index) parse-long) objects (get-in state [:viewer :pages page-id :objects]) frame-id (get-in state [:viewer :pages page-id :frames index :id]) diff --git a/frontend/src/app/main/router.cljs b/frontend/src/app/main/router.cljs index 1e234e8af1..405c8b6664 100644 --- a/frontend/src/app/main/router.cljs +++ b/frontend/src/app/main/router.cljs @@ -136,6 +136,16 @@ [state] (dm/get-in state [:route :params :query])) +(defn get-query-param + "Safely extracts a scalar value for a query param key from a params + map. When the same key appears multiple times in a URL, + query-string->map returns a vector for that key; this function + always returns a single (last) element in that case, so downstream + consumers such as parse-long always receive a plain string or nil." + [params k] + (let [v (get params k)] + (if (sequential? v) (peek v) v))) + (defn nav-back [] (ptk/reify ::nav-back diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index 3b00fe0d50..1a03943ba4 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -277,7 +277,7 @@ :viewer (let [params (get params :query) - index (some-> (:index params) parse-long) + index (some-> (rt/get-query-param params :index) parse-long) share-id (some-> (:share-id params) uuid/parse*) section (or (some-> (:section params) keyword) :interactions) From 6c90ba1582e4b5bab58232dae438a4fd701032eb Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 13 Apr 2026 12:43:13 +0000 Subject: [PATCH 10/89] :bug: Fix move-files allowing same project as target when multiple files selected The 'Move to' menu in the dashboard file context menu only filtered out the first selected file's project from the available target list. When multiple files from different projects were selected, the other files' projects still appeared as valid targets, causing a 400 'cant-move-to-same-project' backend error. Now all selected files' project IDs are collected and excluded from the available target projects. --- frontend/src/app/main/ui/dashboard/file_menu.cljs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/dashboard/file_menu.cljs b/frontend/src/app/main/ui/dashboard/file_menu.cljs index dfecbc779b..06f7b29c36 100644 --- a/frontend/src/app/main/ui/dashboard/file_menu.cljs +++ b/frontend/src/app/main/ui/dashboard/file_menu.cljs @@ -78,7 +78,8 @@ current-team (get teams current-team-id) other-teams (remove #(= (:id %) current-team-id) (vals teams)) - current-projects (remove #(= (:id %) (:project-id file)) + file-project-ids (into #{} (map :project-id) files) + current-projects (remove #(contains? file-project-ids (:id %)) (:projects current-team)) on-new-tab From f07b954b7e01b8a1af6ed4bbc9cf15f48c507d11 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 19:48:59 +0200 Subject: [PATCH 11/89] :zap: Add efficiency improvements to workspace components (refactor part 1) (#8887) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :recycle: Convert snap-points components to modern rumext format Migrate snap-point, snap-line, snap-feedback, and snap-points from legacy mf/defc format to modern * suffix format. This enables optimized props handling by the rumext macro, eliminating implicit JS object wrapping overhead on each render. All internal and external call sites updated to use [:> component* props] syntax. * :recycle: Convert frame-title to modern rumext format Migrate frame-title from legacy mf/defc format to modern * suffix format. The component was using legacy implicit props wrapping without ::mf/wrap-props false or ::mf/props :obj, causing unnecessary JS object conversion overhead on each render. The parent frame-titles* call site updated to use [:> frame-title* props] syntax. * :recycle: Convert interactions components to modern rumext format Migrate interaction-marker, interaction-path, interaction-handle, overlay-marker, and interactions from legacy mf/defc format to modern * suffix format. These five components had zero props optimization applied, causing implicit JS object wrapping on every render. All internal and external call sites updated to use [:> component* props] syntax. * :recycle: Convert rulers components to modern rumext format Migrate rulers-text, viewport-frame, and selection-area from legacy mf/defc format to modern * suffix format. These three components in the always-visible rulers layer had zero props optimization applied. Internal call sites in the parent rulers component updated to use [:> component* props] syntax. * :recycle: Convert frame-grid components to modern rumext format Migrate square-grid, layout-grid, grid-display-frame, and frame-grid from legacy mf/defc format to modern * suffix format. These four components render grid patterns per-frame with zero props optimization. All internal and external call sites updated to use [:> component* props] syntax. * :recycle: Convert gradient handler components to modern rumext format Migrate shadow, gradient-color-handler, and gradient-handler-transformed from legacy mf/defc format to modern * suffix format. These components are rendered during gradient editing with zero props optimization applied. Internal call sites in gradient-handler-transformed and gradient-handlers-impl updated to use [:> component* props] syntax. * :recycle: Rename ?-ending props in modernized workspace viewport components Apply prop naming rules to all * components migrated in the previous batch: - remove-snap? -> remove-snap in snap-feedback* (and get-snap helper) - selected? -> is-selected in interaction-path* - hover-disabled? -> is-hover-disabled in overlay-marker* and interactions* - show-rulers? -> show-rulers in viewport-frame* Update all internal and external call sites consistently. * :bug: Fix get-snap call in snap-feedback* using JS props object Modern rumext *-suffix components receive props as JS objects, not Clojure maps. snap-feedback* was pushing the raw props object into the rx/subject and get-snap was destructuring it as a Clojure map, causing all keys to resolve to nil. Fix by: - Changing get-snap to take positional arguments (coord, shapes, page-id, remove-snap, zoom) instead of a map-destructured opts arg - Building an explicit Clojure map from the bound locals before pushing to the subject - Destructuring that map inside the rx/switch-map callback and calling get-snap with positional args Also mark get-snap and add-point-to-snaps as private (defn-), consistent with the other helpers in the namespace — none are referenced externally. --- .../src/app/main/ui/workspace/viewport.cljs | 8 +- .../ui/workspace/viewport/frame_grid.cljs | 22 +-- .../main/ui/workspace/viewport/gradients.cljs | 12 +- .../ui/workspace/viewport/interactions.cljs | 164 +++++++++--------- .../main/ui/workspace/viewport/rulers.cljs | 22 +-- .../ui/workspace/viewport/snap_points.cljs | 65 +++---- .../main/ui/workspace/viewport/widgets.cljs | 22 +-- .../app/main/ui/workspace/viewport_wasm.cljs | 8 +- 8 files changed, 163 insertions(+), 160 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index b0e540ac71..40f4344d29 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -577,7 +577,7 @@ :tool drawing-tool}]) (when show-grids? - [:& frame-grid/frame-grid + [:> frame-grid/frame-grid* {:zoom zoom :selected selected :transform transform @@ -588,7 +588,7 @@ :zoom zoom}]) (when show-snap-points? - [:& snap-points/snap-points + [:> snap-points/snap-points* {:layout layout :transform transform :drawing drawing-obj @@ -689,13 +689,13 @@ :disabled (or drawing-tool @space?)}]))) (when show-prototypes? - [:& interactions/interactions + [:> interactions/interactions* {:selected selected :page-id page-id :zoom zoom :objects objects-modified :current-transform transform - :hover-disabled? hover-disabled?}])]) + :is-hover-disabled hover-disabled?}])]) (when show-gradient-handlers? [:> gradients/gradient-handlers* diff --git a/frontend/src/app/main/ui/workspace/viewport/frame_grid.cljs b/frontend/src/app/main/ui/workspace/viewport/frame_grid.cljs index 61246ea705..5fafd22a6e 100644 --- a/frontend/src/app/main/ui/workspace/viewport/frame_grid.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/frame_grid.cljs @@ -17,7 +17,7 @@ [app.main.refs :as refs] [rumext.v2 :as mf])) -(mf/defc square-grid [{:keys [frame zoom grid] :as props}] +(mf/defc square-grid* [{:keys [frame zoom grid]}] (let [grid-id (mf/use-memo #(uuid/next)) {:keys [size] :as params} (-> grid :params) {color-value :color color-opacity :opacity} (-> grid :params :color) @@ -45,7 +45,7 @@ :height (:height frame) :fill (str "url(#" grid-id ")")}]])) -(mf/defc layout-grid +(mf/defc layout-grid* [{:keys [key frame grid zoom]}] (let [{color-value :color color-opacity :opacity} (-> grid :params :color) ;; Support for old color format @@ -124,7 +124,7 @@ selrect parents)) -(mf/defc grid-display-frame +(mf/defc grid-display-frame* {::mf/wrap [mf/memo]} [{:keys [frame zoom transforming]}] (let [frame-id (:id frame) @@ -154,16 +154,16 @@ :zoom zoom :grid grid}] (case (:type grid) - :square [:> square-grid props] - :column [:> layout-grid props] - :row [:> layout-grid props])))]))) + :square [:> square-grid* props] + :column [:> layout-grid* props] + :row [:> layout-grid* props])))]))) (defn has-grid? [{:keys [grids]}] (and (some? grids) (d/not-empty? (->> grids (filter :display))))) -(mf/defc frame-grid +(mf/defc frame-grid* {::mf/wrap [mf/memo]} [{:keys [zoom transform selected focus]}] (let [frames (->> (mf/deref refs/workspace-frames) @@ -175,7 +175,7 @@ (when (and #_(not (is-transform? frame)) (not (ctst/rotated-frame? frame)) (or (empty? focus) (contains? focus (:id frame)))) - [:& grid-display-frame {:key (str "grid-" (:id frame)) - :zoom zoom - :frame frame - :transforming transforming}]))])) + [:> grid-display-frame* {:key (str "grid-" (:id frame)) + :zoom zoom + :frame frame + :transforming transforming}]))])) diff --git a/frontend/src/app/main/ui/workspace/viewport/gradients.cljs b/frontend/src/app/main/ui/workspace/viewport/gradients.cljs index 9573efab01..cd2623fd47 100644 --- a/frontend/src/app/main/ui/workspace/viewport/gradients.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/gradients.cljs @@ -43,7 +43,7 @@ (def gradient-endpoint-radius-selected 6) (def gradient-endpoint-radius-handler 20) -(mf/defc shadow [{:keys [id offset]}] +(mf/defc shadow* [{:keys [id offset]}] [:filter {:id id :x "-10%" :y "-10%" @@ -61,7 +61,7 @@ (def checkerboard "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAIAAAC0tAIdAAACvUlEQVQoFQGyAk39AeLi4gAAAAAAAB0dHQAAAAAAAOPj4wAAAAAAAB0dHQAAAAAAAOPj4wAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB////AAAAAAAA4+PjAAAAAAAAHR0dAAAAAAAA4+PjAAAAAAAAHR0dAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATj4+MAAAAAAAAdHR0AAAAAAADj4+MAAAAAAAAdHR0AAAAAAADj4+MAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAjScaa0cU7nIAAAAASUVORK5CYII=") -(mf/defc gradient-color-handler +(mf/defc gradient-color-handler* [{:keys [zoom point color angle selected index on-click on-pointer-down on-pointer-up on-pointer-move on-lost-pointer-capture]}] [:g {:filter "url(#gradient-drop-shadow)" @@ -118,7 +118,7 @@ :r (/ 2 zoom) :fill "var(--app-white)"}]]) -(mf/defc gradient-handler-transformed +(mf/defc gradient-handler-transformed* [{:keys [from-p to-p width-p @@ -270,7 +270,7 @@ [:g.gradient-handlers {:pointer-events "none"} [:defs - [:& shadow {:id "gradient-drop-shadow" :offset (/ 2 zoom)}]] + [:> shadow* {:id "gradient-drop-shadow" :offset (/ 2 zoom)}]] (let [lv (-> (gpt/to-vec from-p to-p) (gpt/unit)) @@ -425,7 +425,7 @@ (-> (gpt/to-vec from-p to-p) (gpt/scale (:offset stop))))] - [:& gradient-color-handler + [:> gradient-color-handler* {:key index :selected (= editing index) :zoom zoom @@ -505,7 +505,7 @@ (when (and norm-dist (d/num? norm-dist)) (change! {:width norm-dist})))))] - [:& gradient-handler-transformed + [:> gradient-handler-transformed* {:editing editing :from-p from-p :to-p to-p diff --git a/frontend/src/app/main/ui/workspace/viewport/interactions.cljs b/frontend/src/app/main/ui/workspace/viewport/interactions.cljs index daac5372e8..8617405003 100644 --- a/frontend/src/app/main/ui/workspace/viewport/interactions.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/interactions.cljs @@ -96,8 +96,8 @@ [orig-pos orig-x orig-y dest-pos dest-x dest-y])) -(mf/defc interaction-marker - [{:keys [x y stroke action-type arrow-dir zoom] :as props}] +(mf/defc interaction-marker* + [{:keys [x y stroke action-type arrow-dir zoom]}] (let [icon-pdata (case action-type :navigate (case arrow-dir :right "M -6.5 0 L 5.5 0 M 6.715 0.715 L -0.5 -6.5 M 6.715 -0.715 L -0.365 6.635" @@ -138,8 +138,8 @@ "translate(" (* zoom x) ", " (* zoom y) ")")}])])) -(mf/defc interaction-path - [{:keys [index level orig-shape dest-shape dest-point selected? action-type zoom] :as props}] +(mf/defc interaction-path* + [{:keys [index level orig-shape dest-shape dest-point is-selected action-type zoom]}] (let [[orig-pos orig-x orig-y dest-pos dest-x dest-y] (cond dest-shape @@ -162,7 +162,7 @@ arrow-dir (if (= dest-pos :left) :right :left)] - (if-not selected? + (if-not is-selected [:g {:on-pointer-down #(on-pointer-down % index orig-shape)} [:path {:stroke "var(--df-secondary)" :fill "none" @@ -170,13 +170,13 @@ :stroke-width (/ 2 zoom) :d pdata}] (when (not dest-shape) - [:& interaction-marker {:index index - :x dest-x - :y dest-y - :stroke "var(--df-secondary)" - :action-type action-type - :arrow-dir arrow-dir - :zoom zoom}])] + [:> interaction-marker* {:index index + :x dest-x + :y dest-y + :stroke "var(--df-secondary)" + :action-type action-type + :arrow-dir arrow-dir + :zoom zoom}])] [:g {:on-pointer-down #(on-pointer-down % index orig-shape)} [:path {:stroke "var(--color-accent-tertiary)" @@ -190,36 +190,36 @@ :shape dest-shape :color "var(--color-accent-tertiary)"}]) - [:& interaction-marker {:index index - :x orig-x - :y orig-y - :stroke "var(--color-accent-tertiary)" - :zoom zoom}] - [:& interaction-marker {:index index - :x dest-x - :y dest-y - :stroke "var(--color-accent-tertiary)" - :action-type action-type - :arrow-dir arrow-dir - :zoom zoom}]]))) + [:> interaction-marker* {:index index + :x orig-x + :y orig-y + :stroke "var(--color-accent-tertiary)" + :zoom zoom}] + [:> interaction-marker* {:index index + :x dest-x + :y dest-y + :stroke "var(--color-accent-tertiary)" + :action-type action-type + :arrow-dir arrow-dir + :zoom zoom}]]))) -(mf/defc interaction-handle - [{:keys [index shape zoom] :as props}] +(mf/defc interaction-handle* + [{:keys [index shape zoom]}] (let [shape-rect (:selrect shape) handle-x (+ (:x shape-rect) (:width shape-rect)) handle-y (+ (:y shape-rect) (/ (:height shape-rect) 2))] [:g {:on-pointer-down #(on-pointer-down % index shape)} - [:& interaction-marker {:x handle-x - :y handle-y - :stroke "var(--color-accent-tertiary)" - :action-type :navigate - :arrow-dir :right - :zoom zoom}]])) + [:> interaction-marker* {:x handle-x + :y handle-y + :stroke "var(--color-accent-tertiary)" + :action-type :navigate + :arrow-dir :right + :zoom zoom}]])) -(mf/defc overlay-marker - [{:keys [page-id index orig-shape dest-shape position objects hover-disabled?] :as props}] +(mf/defc overlay-marker* + [{:keys [page-id index orig-shape dest-shape position objects is-hover-disabled]}] (let [start-move-position (fn [_] (st/emit! (dw/start-move-overlay-pos index)))] @@ -250,8 +250,8 @@ (some? thumbnail-data) (assoc :thumbnail thumbnail-data))] [:g {:on-pointer-down start-move-position - :on-pointer-enter #(reset! hover-disabled? true) - :on-pointer-leave #(reset! hover-disabled? false)} + :on-pointer-enter #(reset! is-hover-disabled true) + :on-pointer-leave #(reset! is-hover-disabled false)} [:g {:transform (gmt/translate-matrix (gpt/point (- marker-x dest-x) (- marker-y dest-y)))} [:& (mf/provider muc/render-thumbnails) {:value true} [:& (mf/provider embed/context) {:value false} @@ -273,8 +273,8 @@ :r 8 :fill "var(--color-accent-tertiary)"}]])))) -(mf/defc interactions - [{:keys [current-transform objects zoom selected hover-disabled? page-id] :as props}] +(mf/defc interactions* + [{:keys [current-transform objects zoom selected is-hover-disabled page-id]}] (let [active-shapes (into [] (comp (filter #(seq (:interactions %)))) (vals objects)) @@ -305,26 +305,26 @@ selected? (contains? selected (:id shape)) level (calc-level index (:interactions shape))] (when-not selected? - [:& interaction-path {:key (dm/str "non-selected-" (:id shape) "-" index) - :index index - :level level - :orig-shape shape - :dest-shape dest-shape - :selected selected - :selected? false - :action-type (:action-type interaction) - :zoom zoom}]))))] + [:> interaction-path* {:key (dm/str "non-selected-" (:id shape) "-" index) + :index index + :level level + :orig-shape shape + :dest-shape dest-shape + :selected selected + :is-selected false + :action-type (:action-type interaction) + :zoom zoom}]))))] [:g.selected (when (and draw-interaction-to first-selected) - [:& interaction-path {:key "interactive" - :index nil - :orig-shape first-selected - :dest-point draw-interaction-to - :dest-shape draw-interaction-to-frame - :selected? true - :action-type :navigate - :zoom zoom}]) + [:> interaction-path* {:key "interactive" + :index nil + :orig-shape first-selected + :dest-point draw-interaction-to + :dest-shape draw-interaction-to-frame + :is-selected true + :action-type :navigate + :zoom zoom}]) (for [shape selected-shapes] (if (seq (:interactions shape)) (for [[index interaction] (d/enumerate (:interactions shape))] @@ -333,38 +333,38 @@ (get objects (:destination interaction))) level (calc-level index (:interactions shape))] [:g {:key (dm/str "interaction-path-" (:id shape) "-" index)} - [:& interaction-path {:index index - :level level - :orig-shape shape - :dest-shape dest-shape - :selected selected - :selected? true - :action-type (:action-type interaction) - :zoom zoom}] + [:> interaction-path* {:index index + :level level + :orig-shape shape + :dest-shape dest-shape + :selected selected + :is-selected true + :action-type (:action-type interaction) + :zoom zoom}] (when (and (or (= (:action-type interaction) :open-overlay) (= (:action-type interaction) :toggle-overlay)) (= (:overlay-pos-type interaction) :manual)) (if (and (some? move-overlay-to) (= move-overlay-index index)) - [:& overlay-marker {:page-id page-id - :index index - :orig-shape shape - :dest-shape dest-shape - :position move-overlay-to - :objects objects - :hover-disabled? hover-disabled?}] - [:& overlay-marker {:page-id page-id - :index index - :orig-shape shape - :dest-shape dest-shape - :position (:overlay-position interaction) - :objects objects - :hover-disabled? hover-disabled?}]))]))) + [:> overlay-marker* {:page-id page-id + :index index + :orig-shape shape + :dest-shape dest-shape + :position move-overlay-to + :objects objects + :is-hover-disabled is-hover-disabled}] + [:> overlay-marker* {:page-id page-id + :index index + :orig-shape shape + :dest-shape dest-shape + :position (:overlay-position interaction) + :objects objects + :is-hover-disabled is-hover-disabled}]))]))) (when (and shape (not (cfh/unframed-shape? shape)) (not (#{:move :rotate} current-transform))) - [:& interaction-handle {:key (:id shape) - :index nil - :shape shape - :selected selected - :zoom zoom}])))]])) + [:> interaction-handle* {:key (:id shape) + :index nil + :shape shape + :selected selected + :zoom zoom}])))]])) diff --git a/frontend/src/app/main/ui/workspace/viewport/rulers.cljs b/frontend/src/app/main/ui/workspace/viewport/rulers.cljs index bec8eaf330..6b23bb277e 100644 --- a/frontend/src/app/main/ui/workspace/viewport/rulers.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/rulers.cljs @@ -142,7 +142,7 @@ "Z")) -(mf/defc rulers-text +(mf/defc rulers-text* "Draws the text for the rulers in a specific axis" [{:keys [vbox step offset axis zoom-inverse]}] (let [clip-id (str "clip-ruler-" (d/name axis)) @@ -186,13 +186,13 @@ :style {:stroke font-color :stroke-width rulers-width}}]]))])) -(mf/defc viewport-frame - [{:keys [show-rulers? zoom zoom-inverse vbox offset-x offset-y]}] +(mf/defc viewport-frame* + [{:keys [show-rulers zoom zoom-inverse vbox offset-x offset-y]}] (let [{:keys [width height] x1 :x y1 :y} vbox x2 (+ x1 width) y2 (+ y1 height) - bw (if show-rulers? (* ruler-area-size zoom-inverse) 0) + bw (if show-rulers (* ruler-area-size zoom-inverse) 0) br (/ canvas-border-radius zoom) bs (* 4 zoom-inverse)] [:* @@ -214,13 +214,13 @@ :fill-rule "evenodd" :fill rulers-background}]] - (when show-rulers? + (when show-rulers (let [step (calculate-step-size zoom)] [:g.viewport-frame-rulers - [:& rulers-text {:vbox vbox :offset offset-x :step step :zoom-inverse zoom-inverse :axis :x}] - [:& rulers-text {:vbox vbox :offset offset-y :step step :zoom-inverse zoom-inverse :axis :y}]]))])) + [:> rulers-text* {:vbox vbox :offset offset-x :step step :zoom-inverse zoom-inverse :axis :x}] + [:> rulers-text* {:vbox vbox :offset offset-y :step step :zoom-inverse zoom-inverse :axis :y}]]))])) -(mf/defc selection-area +(mf/defc selection-area* [{:keys [vbox zoom-inverse selection-rect offset-x offset-y]}] ;; When using the format-number callls we consider if the guide is associated to a frame and we show the position relative to it with the offset [:g.selection-area @@ -332,8 +332,8 @@ (when (some? vbox) [:g.viewport-frame {:pointer-events "none"} - [:& viewport-frame - {:show-rulers? show-rulers? + [:> viewport-frame* + {:show-rulers show-rulers? :zoom zoom :zoom-inverse zoom-inverse :vbox vbox @@ -341,7 +341,7 @@ :offset-y offset-y}] (when (and show-rulers? (some? selection-rect)) - [:& selection-area + [:> selection-area* {:zoom zoom :zoom-inverse zoom-inverse :vbox vbox diff --git a/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs b/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs index d65ae80f06..64ce4ab55e 100644 --- a/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs @@ -25,7 +25,7 @@ ;; (def ^:private line-opacity 1 ) ;; (def ^:private line-width 2) -(mf/defc snap-point +(mf/defc snap-point* [{:keys [point zoom]}] (let [{:keys [x y]} point cross-width (/ 3 zoom)] @@ -41,7 +41,7 @@ :y2 (- y cross-width) :style {:stroke line-color :stroke-width (str (/ line-width zoom))}}]])) -(mf/defc snap-line +(mf/defc snap-line* [{:keys [snap point zoom]}] [:line {:x1 (:x snap) :y1 (:y snap) @@ -50,8 +50,8 @@ :style {:stroke line-color :stroke-width (str (/ line-width zoom))} :opacity line-opacity}]) -(defn get-snap - [coord {:keys [shapes page-id remove-snap? zoom]}] +(defn- get-snap + [coord shapes page-id remove-snap zoom] (let [bounds (gsh/shapes->rect shapes) frame-id (snap/snap-frame-id shapes)] @@ -63,7 +63,7 @@ (rx/merge-map (fn [[frame-id point]] - (->> (snap/get-snap-points page-id frame-id remove-snap? zoom point coord) + (->> (snap/get-snap-points page-id frame-id remove-snap zoom point coord) (rx/map #(mapcat second %)) (rx/map #(map :pt %)) (rx/map #(vector point % coord))))) @@ -74,7 +74,7 @@ [coord] (if (= coord :x) :y :x)) -(defn add-point-to-snaps +(defn- add-point-to-snaps [[point snaps coord]] (let [normalize-coord #(assoc % coord (get point coord))] (cons point (map normalize-coord snaps)))) @@ -100,8 +100,8 @@ (map (fn [[fixedv [minv maxv]]] [(hash-map coord fixedv (flip coord) minv) (hash-map coord fixedv (flip coord) maxv)])))) -(mf/defc snap-feedback - [{:keys [shapes remove-snap? zoom modifiers] :as props}] +(mf/defc snap-feedback* + [{:keys [shapes remove-snap zoom modifiers page-id]}] (let [state (mf/use-state []) subject (mf/use-memo #(rx/subject)) @@ -116,9 +116,9 @@ (fn [] (let [sub (->> subject (rx/switch-map - (fn [props] - (->> (get-snap :y props) - (rx/combine-latest (get-snap :x props))))) + (fn [{:keys [shapes page-id remove-snap zoom]}] + (->> (get-snap :y shapes page-id remove-snap zoom) + (rx/combine-latest (get-snap :x shapes page-id remove-snap zoom))))) (rx/map (fn [result] @@ -133,28 +133,31 @@ #(rx/dispose! sub)))) (mf/use-effect - (mf/deps shapes remove-snap? modifiers) + (mf/deps shapes remove-snap modifiers) (fn [] - (rx/push! subject props))) + (rx/push! subject {:shapes shapes + :page-id page-id + :remove-snap remove-snap + :zoom zoom}))) [:g.snap-feedback (for [[from-point to-point] snap-lines] - [:& snap-line {:key (str "line-" (:x from-point) - "-" (:y from-point) - "-" (:x to-point) - "-" (:y to-point) "-") - :snap from-point - :point to-point - :zoom zoom}]) + [:> snap-line* {:key (str "line-" (:x from-point) + "-" (:y from-point) + "-" (:x to-point) + "-" (:y to-point) "-") + :snap from-point + :point to-point + :zoom zoom}]) (for [point snap-points] - [:& snap-point {:key (str "point-" (:x point) - "-" (:y point)) - :point point - :zoom zoom}])])) + [:> snap-point* {:key (str "point-" (:x point) + "-" (:y point)) + :point point + :zoom zoom}])])) -(mf/defc snap-points +(mf/defc snap-points* {::mf/wrap [mf/memo]} - [{:keys [layout zoom objects selected page-id drawing focus] :as props}] + [{:keys [layout zoom objects selected page-id drawing focus]}] (dm/assert! (set? selected)) (let [shapes (into [] (keep (d/getf objects)) selected) @@ -165,7 +168,7 @@ (mf/with-memo [layout filter-shapes objects focus] (snap/make-remove-snap layout filter-shapes objects focus)) - remove-snap? + remove-snap (mf/use-callback (mf/deps remove-snap-base?) (fn [{:keys [type grid] :as snap}] @@ -176,8 +179,8 @@ shapes (if drawing [drawing] shapes) frame-id (snap/snap-frame-id shapes)] (when-not (ctl/any-layout? objects frame-id) - [:& snap-feedback {:shapes shapes - :page-id page-id - :remove-snap? remove-snap? - :zoom zoom}]))) + [:> snap-feedback* {:shapes shapes + :page-id page-id + :remove-snap remove-snap + :zoom zoom}]))) diff --git a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs index 7a25682209..a47897d2d6 100644 --- a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs @@ -78,7 +78,7 @@ :stroke-width (/ 1 zoom)}}])) -(mf/defc frame-title +(mf/defc frame-title* {::mf/wrap [mf/memo #(mf/deferred % ts/raf)] ::mf/forward-ref true} @@ -261,16 +261,16 @@ (not= id uuid/zero) (or (dbg/enabled? :shape-titles) (= parent-id uuid/zero)) (or (empty? focus) (contains? focus id))) - [:& frame-title {:key (dm/str "frame-title-" id) - :frame shape - :zoom zoom - :is-selected (contains? selected id) - :is-show-artboard-names is-show-artboard-names - :is-show-id (dbg/enabled? :shape-titles) - :is-grid-edition (and (= id edition) grid-edition?) - :on-frame-enter on-frame-enter - :on-frame-leave on-frame-leave - :on-frame-select on-frame-select}]))])) + [:> frame-title* {:key (dm/str "frame-title-" id) + :frame shape + :zoom zoom + :is-selected (contains? selected id) + :is-show-artboard-names is-show-artboard-names + :is-show-id (dbg/enabled? :shape-titles) + :is-grid-edition (and (= id edition) grid-edition?) + :on-frame-enter on-frame-enter + :on-frame-leave on-frame-leave + :on-frame-select on-frame-select}]))])) (mf/defc frame-flow* [{:keys [flow frame is-selected zoom on-frame-enter on-frame-leave on-frame-select]}] diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 091a00a3e7..09b7fa6282 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -636,7 +636,7 @@ :tool drawing-tool}]) (when show-grids? - [:& frame-grid/frame-grid + [:> frame-grid/frame-grid* {:zoom zoom :selected selected :transform transform @@ -647,7 +647,7 @@ :zoom zoom}]) (when show-snap-points? - [:& snap-points/snap-points + [:> snap-points/snap-points* {:layout layout :transform transform :drawing drawing-obj @@ -749,13 +749,13 @@ :disabled (or drawing-tool @space?)}]))) (when show-prototypes? - [:& interactions/interactions + [:> interactions/interactions* {:selected selected :page-id page-id :zoom zoom :objects objects-modified :current-transform transform - :hover-disabled? hover-disabled?}])]) + :is-hover-disabled hover-disabled?}])]) (when show-gradient-handlers? [:> gradients/gradient-handlers* From 7b0ea5968dc18e65c08e8262ba0199920f498214 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:30:22 +0000 Subject: [PATCH 12/89] :ambulance: Fix typo :podition in swap-shapes grid cell The key :podition was used instead of :position when updating the id-from cell in swap-shapes, silently discarding the position value and leaving the cell's :position as nil after every swap. Signed-off-by: Andrey Antukh --- common/src/app/common/types/shape/layout.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/app/common/types/shape/layout.cljc b/common/src/app/common/types/shape/layout.cljc index 384029a688..8ed7306c61 100644 --- a/common/src/app/common/types/shape/layout.cljc +++ b/common/src/app/common/types/shape/layout.cljc @@ -1439,7 +1439,7 @@ (update-in [:layout-grid-cells id-from] assoc :shapes (:shapes cell-to) - :podition (:position cell-to)) + :position (:position cell-to)) (update-in [:layout-grid-cells id-to] assoc :shapes (:shapes cell-from) From 08ca56166714a29bf7a4e2ea1435c7370091954c Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:30:30 +0000 Subject: [PATCH 13/89] :bug: Add better nil handling in interpolate-gradient when offset exceeds stops When no gradient stop satisfies (<= offset (:offset %)), d/index-of-pred returns nil. The previous code called (dec nil) in the start binding before the nil check, throwing a NullPointerException/ClassCastException. Guard the start binding with a cond that handles nil before attempting dec. Signed-off-by: Andrey Antukh --- common/src/app/common/types/color.cljc | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/common/src/app/common/types/color.cljc b/common/src/app/common/types/color.cljc index c4532c4ac0..ae56250d96 100644 --- a/common/src/app/common/types/color.cljc +++ b/common/src/app/common/types/color.cljc @@ -720,8 +720,10 @@ (defn- offset-spread [from to num] - (->> (range 0 num) - (map #(mth/precision (+ from (* (/ (- to from) (dec num)) %)) 2)))) + (if (<= num 1) + [from] + (->> (range 0 num) + (map #(mth/precision (+ from (* (/ (- to from) (dec num)) %)) 2))))) (defn uniform-spread? "Checks if the gradient stops are spread uniformly" @@ -750,6 +752,9 @@ (defn interpolate-gradient [stops offset] (let [idx (d/index-of-pred stops #(<= offset (:offset %))) - start (if (= idx 0) (first stops) (get stops (dec idx))) + start (cond + (nil? idx) (last stops) + (= idx 0) (first stops) + :else (get stops (dec idx))) end (if (nil? idx) (last stops) (get stops idx))] (interpolate-color start end offset))) From ff41d08e3c726a08b447066630d17ecc6769a22a Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:30:35 +0000 Subject: [PATCH 14/89] :bug: Fix stale accumulator in get-children-in-instance recursion get-children-rec passed the original children vector to each recursive call instead of the updated one that already includes the current shape. This caused descendant results to be accumulated from the wrong starting point, losing intermediate shapes. Pass children' (which includes the current shape) into every recursive call. Signed-off-by: Andrey Antukh --- common/src/app/common/types/container.cljc | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/common/src/app/common/types/container.cljc b/common/src/app/common/types/container.cljc index 324528854b..8bb0e7d969 100644 --- a/common/src/app/common/types/container.cljc +++ b/common/src/app/common/types/container.cljc @@ -106,8 +106,9 @@ (let [shape (get objects id)] (if (and (ctk/instance-head? shape) (seq children)) children - (into (conj children shape) - (mapcat #(get-children-rec children %) (:shapes shape))))))] + (let [children' (conj children shape)] + (into children' + (mapcat #(get-children-rec children' %) (:shapes shape)))))))] (get-children-rec [] id))) (defn get-component-shape From c30c85ff077179627401fc831b229362b5ca3064 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:30:50 +0000 Subject: [PATCH 15/89] :bug: Remove duplicate font-weight-keys in typography-keys union font-weight-keys was listed twice in the set/union call for typography-keys, a copy-paste error. The duplicate entry has no functional effect (sets deduplicate), but it is misleading and suggests a missing key such as font-style-keys in its place. Signed-off-by: Andrey Antukh --- common/src/app/common/types/token.cljc | 1 - 1 file changed, 1 deletion(-) diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc index e3e541da33..c3bb2b266d 100644 --- a/common/src/app/common/types/token.cljc +++ b/common/src/app/common/types/token.cljc @@ -345,7 +345,6 @@ (def typography-keys (set/union font-family-keys font-size-keys font-weight-keys - font-weight-keys letter-spacing-keys line-height-keys text-case-keys From 8253738f01c9709fef18e35a5aac412690a438ee Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:34:38 +0000 Subject: [PATCH 16/89] :bug: Fix reversed `get` args in convert-dtcg-shadow-composite \`(get "type" shadow)\` always returns nil because the map and key arguments were swapped. The correct call is \`(get shadow "type")\`, which allows the legacy innerShadow detection to work correctly. Update the test expectation accordingly. Signed-off-by: Andrey Antukh --- common/src/app/common/types/tokens_lib.cljc | 2 +- common/test/common_tests/types/tokens_lib_test.cljc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/common/src/app/common/types/tokens_lib.cljc b/common/src/app/common/types/tokens_lib.cljc index 5c392f2db9..8ab9c6bcd0 100644 --- a/common/src/app/common/types/tokens_lib.cljc +++ b/common/src/app/common/types/tokens_lib.cljc @@ -1637,7 +1637,7 @@ Will return a value that matches this schema: [value] (let [process-shadow (fn [shadow] (if (map? shadow) - (let [legacy-shadow-type (get "type" shadow)] + (let [legacy-shadow-type (get shadow "type")] (-> shadow (set/rename-keys {"x" :offset-x "offsetX" :offset-x diff --git a/common/test/common_tests/types/tokens_lib_test.cljc b/common/test/common_tests/types/tokens_lib_test.cljc index 150ffcfb08..e8c8a52ae5 100644 --- a/common/test/common_tests/types/tokens_lib_test.cljc +++ b/common/test/common_tests/types/tokens_lib_test.cljc @@ -1918,7 +1918,7 @@ (let [token (ctob/get-token-by-name lib "shadow-test" "test.shadow-with-type")] (t/is (some? token)) (t/is (= :shadow (:type token))) - (t/is (= [{:offset-x "0", :offset-y "4px", :blur "8px", :spread "0", :color "rgba(0,0,0,0.2)", :inset false}] + (t/is (= [{:offset-x "0", :offset-y "4px", :blur "8px", :spread "0", :color "rgba(0,0,0,0.2)", :inset true}] (:value token))))) (t/testing "shadow token with description" From 8b08c8ecc974eb805fcd59bfa7008eb6c6a58a4e Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:35:21 +0000 Subject: [PATCH 17/89] :bug: Fix wrong mapcat call in collect-main-shapes `(mapcat collect-main-shapes children objects)` passes `objects` as a second parallel collection instead of threading it as the second argument to `collect-main-shapes` for each child. Fix by using an anonymous fn: `(mapcat #(collect-main-shapes % objects) children)`. Signed-off-by: Andrey Antukh --- common/src/app/common/types/container.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/app/common/types/container.cljc b/common/src/app/common/types/container.cljc index 8bb0e7d969..b72ca11179 100644 --- a/common/src/app/common/types/container.cljc +++ b/common/src/app/common/types/container.cljc @@ -441,7 +441,7 @@ (if (ctk/main-instance? shape) [shape] (if-let [children (cfh/get-children objects (:id shape))] - (mapcat collect-main-shapes children objects) + (mapcat #(collect-main-shapes % objects) children) []))) (defn get-component-from-shape From 2b67e114b6ce894d3d74d6106c4ef4e630fda687 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:37:01 +0000 Subject: [PATCH 18/89] :bug: Fix inside-layout? passing id instead of shape to frame-shape? `(cfh/frame-shape? current-id)` passes a UUID to the single-arity overload of `frame-shape?`, which expects a shape map; it always returns false. Fix by passing `current` (the resolved shape) instead. Update the test to assert the correct behaviour. Signed-off-by: Andrey Antukh --- common/src/app/common/types/shape/layout.cljc | 2 +- common/test/common_tests/types/shape_layout_test.cljc | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/common/src/app/common/types/shape/layout.cljc b/common/src/app/common/types/shape/layout.cljc index 8ed7306c61..caea9d5f91 100644 --- a/common/src/app/common/types/shape/layout.cljc +++ b/common/src/app/common/types/shape/layout.cljc @@ -262,7 +262,7 @@ (or (nil? current) (= current-id parent-id)) false - (cfh/frame-shape? current-id) + (cfh/frame-shape? current) (:layout current) :else diff --git a/common/test/common_tests/types/shape_layout_test.cljc b/common/test/common_tests/types/shape_layout_test.cljc index d677ed5d09..62935b21dc 100644 --- a/common/test/common_tests/types/shape_layout_test.cljc +++ b/common/test/common_tests/types/shape_layout_test.cljc @@ -186,13 +186,9 @@ flex (make-flex-frame :parent-id root-id) child (make-shape :parent-id (:id flex))] - ;; Note: inside-layout? calls (cfh/frame-shape? current-id) with a UUID id, - ;; but frame-shape? checks (:type uuid) which is nil for a UUID value. - ;; The function therefore always returns false regardless of structure. - ;; These tests document the actual (not the intended) behavior. - (t/testing "returns false when child is under a flex frame" + (t/testing "returns true when child is under a flex frame" (let [objects {root-id root (:id flex) flex (:id child) child}] - (t/is (not (layout/inside-layout? objects child))))) + (t/is (layout/inside-layout? objects child)))) (t/testing "returns false for root shape" (let [objects {root-id root (:id flex) flex (:id child) child}] From 6da39bc9c74539697b8d07162fa03d329976a6e7 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:41:00 +0000 Subject: [PATCH 19/89] :bug: Fix ObjectsMap CLJS negative cache keyed on 'key' fn instead of 'k' In the CLJS -lookup implementation, when a key is absent from data the negative cache entry was stored under 'key' (the built-in map-entry key function) rather than the 'k' parameter. As a result every subsequent lookup of any missing key bypassed the cache and repeated the full lookup path, making the negative-cache optimization entirely ineffective. Signed-off-by: Andrey Antukh --- common/src/app/common/types/objects_map.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/app/common/types/objects_map.cljc b/common/src/app/common/types/objects_map.cljc index d08330765c..3604961f11 100644 --- a/common/src/app/common/types/objects_map.cljc +++ b/common/src/app/common/types/objects_map.cljc @@ -278,7 +278,7 @@ (set! (.-cache this) (c/-assoc cache k v)) v) (do - (set! (.-cache this) (assoc cache key nil)) + (set! (.-cache this) (assoc cache k nil)) nil)))) (-lookup [this k not-found] From 30931839b5d136f1e543665061a47d7f4fee27c6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:43:32 +0000 Subject: [PATCH 20/89] :bug: Fix reversed d/in-range? args in CLJS Fills -nth with default In the ClojureScript Fills deftype, the two-arity -nth implementation called (d/in-range? i size) but the signature is (d/in-range? size i). This meant -nth always fell through to the default value for any valid index when called with an explicit default, since i < size is the condition but the args were swapped. The no-default -nth sibling on line 378 and both CLJ nth impls on lines 286 and 291 had the correct argument order. Signed-off-by: Andrey Antukh --- common/src/app/common/types/fills/impl.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/app/common/types/fills/impl.cljc b/common/src/app/common/types/fills/impl.cljc index 06475d183f..b429c67b9c 100644 --- a/common/src/app/common/types/fills/impl.cljc +++ b/common/src/app/common/types/fills/impl.cljc @@ -380,7 +380,7 @@ nil)) (-nth [_ i default] - (if (d/in-range? i size) + (if (d/in-range? size i) (read-fill dbuffer mbuffer i) default)) From caac452cd4ddc45b368bfda8764453baf81da6de Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:44:58 +0000 Subject: [PATCH 21/89] :bug: Fix wrong extremity point in calculate-extremities for line-to In the :line-to branch of calculate-extremities, move-p (the subpath start point) was being added to the extremities set instead of from-p (the actual previous point). For all line segments beyond the first one in a subpath this produced an incorrect bounding-box start point. The :curve-to branch correctly used from-p; align :line-to to match. Signed-off-by: Andrey Antukh --- common/src/app/common/types/path/segment.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/app/common/types/path/segment.cljc b/common/src/app/common/types/path/segment.cljc index bcbbe8eeda..45fc1ba2bb 100644 --- a/common/src/app/common/types/path/segment.cljc +++ b/common/src/app/common/types/path/segment.cljc @@ -812,7 +812,7 @@ :line-to (recur (cond-> points (and from-p to-p) - (-> (conj! move-p) + (-> (conj! from-p) (conj! to-p))) (not-empty (subvec content 1)) to-p From db7c6465681435e180bf0d4c148fd2e324d64aa9 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:48:07 +0000 Subject: [PATCH 22/89] :sparkles: Add missing tests for session bug fixes and uniform-spread? Add indexed-access-with-default in fill_test.cljc to cover the two-arity (nth fills i default) form on both valid and out-of-range indices, directly exercising the CLJS Fills -nth path fixed in 593cf125. Add segment-content->selrect-multi-line in path_data_test.cljc to cover content->selrect on a subpath with multiple consecutive line-to commands where move-p diverges from from-p, confirming the bounding box matches both the expected coordinates and the reference implementation; this guards the calculate-extremities fix in bb5a04c7. Add types-uniform-spread? in colors_test.cljc to cover app.common.types.color/uniform-spread?, which had no dedicated tests. Exercises the uniform case (via uniform-spread), the two-stop edge case, wrong-offset detection, and wrong-color detection. Signed-off-by: Andrey Antukh --- common/test/common_tests/colors_test.cljc | 21 ++++++++++++++++ common/test/common_tests/types/fill_test.cljc | 15 +++++++++++ .../common_tests/types/path_data_test.cljc | 25 +++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/common/test/common_tests/colors_test.cljc b/common/test/common_tests/colors_test.cljc index de505fd540..aa1edd450a 100644 --- a/common/test/common_tests/colors_test.cljc +++ b/common/test/common_tests/colors_test.cljc @@ -426,6 +426,27 @@ {:color "#ffffff" :opacity 1.0 :offset 1.0}]] (t/is (false? (c/uniform-spread? stops))))) +(t/deftest types-uniform-spread? + (t/testing "uniformly spread stops are detected as uniform" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + stops (colors/uniform-spread c1 c2 3)] + (t/is (true? (colors/uniform-spread? stops))))) + (t/testing "two-stop gradient is uniform by definition" + (let [stops [{:color "#ff0000" :opacity 1.0 :offset 0.0} + {:color "#0000ff" :opacity 1.0 :offset 1.0}]] + (t/is (true? (colors/uniform-spread? stops))))) + (t/testing "stops with wrong offset are not uniform" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#888888" :opacity 0.5 :offset 0.3} + {:color "#ffffff" :opacity 1.0 :offset 1.0}]] + (t/is (false? (colors/uniform-spread? stops))))) + (t/testing "stops with correct offset but wrong color are not uniform" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#aaaaaa" :opacity 0.5 :offset 0.5} + {:color "#ffffff" :opacity 1.0 :offset 1.0}]] + (t/is (false? (colors/uniform-spread? stops)))))) + (t/deftest ac-interpolate-gradient (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} {:color "#ffffff" :opacity 1.0 :offset 1.0}]] diff --git a/common/test/common_tests/types/fill_test.cljc b/common/test/common_tests/types/fill_test.cljc index 308778bcc1..f9968e8aed 100644 --- a/common/test/common_tests/types/fill_test.cljc +++ b/common/test/common_tests/types/fill_test.cljc @@ -207,3 +207,18 @@ fill1 (nth fills1 1)] (t/is (nil? fill1)) (t/is (equivalent-fill? fill0 sample-fill-6)))) + +(t/deftest indexed-access-with-default + (t/testing "nth with default returns fill for valid index" + ;; Regression: CLJS -nth with default had reversed d/in-range? args, + ;; so it always fell through to the default even for valid indices. + (let [fills (types.fills/from-plain [sample-fill-6]) + sentinel ::not-found + result (nth fills 0 sentinel)] + (t/is (not= sentinel result)) + (t/is (equivalent-fill? result sample-fill-6)))) + (t/testing "nth with default returns default for out-of-range index" + (let [fills (types.fills/from-plain [sample-fill-6]) + sentinel ::not-found] + (t/is (= sentinel (nth fills 1 sentinel))) + (t/is (= sentinel (nth fills -1 sentinel)))))) diff --git a/common/test/common_tests/types/path_data_test.cljc b/common/test/common_tests/types/path_data_test.cljc index e4d2881b18..6dc7fa5207 100644 --- a/common/test/common_tests/types/path_data_test.cljc +++ b/common/test/common_tests/types/path_data_test.cljc @@ -973,6 +973,31 @@ (t/is (mth/close? 10.0 (:x2 rect) 0.1)) (t/is (mth/close? 10.0 (:y2 rect) 0.1)))) +(t/deftest segment-content->selrect-multi-line + ;; Regression: calculate-extremities used move-p instead of from-p in + ;; the :line-to branch. For a subpath with multiple consecutive line-to + ;; commands, the selrect must still match the reference implementation. + (let [;; A subpath that starts away from the origin and has three + ;; line-to segments so that move-p diverges from from-p for the + ;; later segments. + segments [{:command :move-to :params {:x 5.0 :y 5.0}} + {:command :line-to :params {:x 15.0 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 8.0}} + {:command :line-to :params {:x 10.0 :y 12.0}}] + content (path/content segments) + rect (path.segment/content->selrect content) + ref-pts (calculate-extremities segments)] + + ;; Bounding box must enclose all four vertices exactly. + (t/is (some? rect)) + (t/is (mth/close? 5.0 (:x1 rect) 0.1)) + (t/is (mth/close? 0.0 (:y1 rect) 0.1)) + (t/is (mth/close? 20.0 (:x2 rect) 0.1)) + (t/is (mth/close? 12.0 (:y2 rect) 0.1)) + + ;; Must agree with the reference implementation. + (t/is (= ref-pts (calculate-extremities content))))) + (t/deftest segment-content-center (let [content (path/content sample-content-square) center (path.segment/content-center content)] From 1e0f10814ed65b32d09cf6943983b3210c3425a6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 19:04:04 +0000 Subject: [PATCH 23/89] :fire: Remove duplicate gradient helpers from app.common.colors The five functions interpolate-color, offset-spread, uniform-spread?, uniform-spread, and interpolate-gradient duplicated the canonical implementations in app.common.types.color. The copies in colors.cljc also contained two bugs: a division-by-zero in offset-spread when num=1, and a crash on nil idx in interpolate-gradient. All production callers already use app.common.types.color. The duplicate tests that exercised the old copies are removed; their coverage is absorbed into expanded tests under the types-* suite, including a new nil-idx guard test and a single-stop no-crash test. Signed-off-by: Andrey Antukh --- common/src/app/common/colors.cljc | 59 -------------- common/test/common_tests/colors_test.cljc | 94 ++++++++++++----------- 2 files changed, 50 insertions(+), 103 deletions(-) diff --git a/common/src/app/common/colors.cljc b/common/src/app/common/colors.cljc index e16acf94a3..ab7c7e2a76 100644 --- a/common/src/app/common/colors.cljc +++ b/common/src/app/common/colors.cljc @@ -487,62 +487,3 @@ b (+ (* bh 100) (* bv 10))] (compare a b))) -(defn interpolate-color - [c1 c2 offset] - (cond - (<= offset (:offset c1)) (assoc c1 :offset offset) - (>= offset (:offset c2)) (assoc c2 :offset offset) - - :else - (let [tr-offset (/ (- offset (:offset c1)) (- (:offset c2) (:offset c1))) - [r1 g1 b1] (hex->rgb (:color c1)) - [r2 g2 b2] (hex->rgb (:color c2)) - a1 (:opacity c1) - a2 (:opacity c2) - r (+ r1 (* (- r2 r1) tr-offset)) - g (+ g1 (* (- g2 g1) tr-offset)) - b (+ b1 (* (- b2 b1) tr-offset)) - a (+ a1 (* (- a2 a1) tr-offset))] - {:color (rgb->hex [r g b]) - :opacity a - :r r - :g g - :b b - :alpha a - :offset offset}))) - -(defn- offset-spread - [from to num] - (->> (range 0 num) - (map #(mth/precision (+ from (* (/ (- to from) (dec num)) %)) 2)))) - -(defn uniform-spread? - "Checks if the gradient stops are spread uniformly" - [stops] - (let [cs (count stops) - from (first stops) - to (last stops) - expect-vals (offset-spread (:offset from) (:offset to) cs) - - calculate-expected - (fn [expected-offset stop] - (and (mth/close? (:offset stop) expected-offset) - (let [ec (interpolate-color from to expected-offset)] - (and (= (:color ec) (:color stop)) - (= (:opacity ec) (:opacity stop))))))] - (->> (map calculate-expected expect-vals stops) - (every? true?)))) - -(defn uniform-spread - "Assign an uniform spread to the offset values for the gradient" - [from to num-stops] - (->> (offset-spread (:offset from) (:offset to) num-stops) - (mapv (fn [offset] - (interpolate-color from to offset))))) - -(defn interpolate-gradient - [stops offset] - (let [idx (d/index-of-pred stops #(<= offset (:offset %))) - start (if (= idx 0) (first stops) (get stops (dec idx))) - end (if (nil? idx) (last stops) (get stops idx))] - (interpolate-color start end offset))) diff --git a/common/test/common_tests/colors_test.cljc b/common/test/common_tests/colors_test.cljc index aa1edd450a..21f6af5bef 100644 --- a/common/test/common_tests/colors_test.cljc +++ b/common/test/common_tests/colors_test.cljc @@ -387,44 +387,41 @@ (t/is (= 0.25 (c/reduce-range 0.3 4))) (t/is (= 0.0 (c/reduce-range 0.0 10)))) -;; --- Gradient helpers +;; --- Gradient helpers (app.common.types.color) -(t/deftest ac-interpolate-color - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0}] - ;; At c1's offset → c1 with updated offset - (let [result (c/interpolate-color c1 c2 0.0)] +(t/deftest types-interpolate-color + (t/testing "at c1 offset returns c1 color" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + result (colors/interpolate-color c1 c2 0.0)] (t/is (= "#000000" (:color result))) - (t/is (= 0.0 (:opacity result)))) - ;; At c2's offset → c2 with updated offset - (let [result (c/interpolate-color c1 c2 1.0)] + (t/is (= 0.0 (:opacity result))))) + (t/testing "at c2 offset returns c2 color" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + result (colors/interpolate-color c1 c2 1.0)] (t/is (= "#ffffff" (:color result))) - (t/is (= 1.0 (:opacity result)))) - ;; At midpoint → gray - (let [result (c/interpolate-color c1 c2 0.5)] + (t/is (= 1.0 (:opacity result))))) + (t/testing "at midpoint returns interpolated gray" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + result (colors/interpolate-color c1 c2 0.5)] (t/is (= "#7f7f7f" (:color result))) (t/is (mth/close? (:opacity result) 0.5))))) -(t/deftest ac-uniform-spread - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} - stops (c/uniform-spread c1 c2 3)] - (t/is (= 3 (count stops))) - (t/is (= 0.0 (:offset (first stops)))) - (t/is (mth/close? 0.5 (:offset (second stops)))) - (t/is (= 1.0 (:offset (last stops)))))) - -(t/deftest ac-uniform-spread? - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} - stops (c/uniform-spread c1 c2 3)] - ;; A uniformly spread result should pass the predicate - (t/is (true? (c/uniform-spread? stops)))) - ;; Manual non-uniform stops should not pass - (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} - {:color "#888888" :opacity 0.5 :offset 0.3} - {:color "#ffffff" :opacity 1.0 :offset 1.0}]] - (t/is (false? (c/uniform-spread? stops))))) +(t/deftest types-uniform-spread + (t/testing "produces correct count and offsets" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + stops (colors/uniform-spread c1 c2 3)] + (t/is (= 3 (count stops))) + (t/is (= 0.0 (:offset (first stops)))) + (t/is (mth/close? 0.5 (:offset (second stops)))) + (t/is (= 1.0 (:offset (last stops)))))) + (t/testing "single stop returns a vector of one element (no division by zero)" + (let [c1 {:color "#ff0000" :opacity 1.0 :offset 0.0} + stops (colors/uniform-spread c1 c1 1)] + (t/is (= 1 (count stops)))))) (t/deftest types-uniform-spread? (t/testing "uniformly spread stops are detected as uniform" @@ -447,16 +444,25 @@ {:color "#ffffff" :opacity 1.0 :offset 1.0}]] (t/is (false? (colors/uniform-spread? stops)))))) -(t/deftest ac-interpolate-gradient - (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} - {:color "#ffffff" :opacity 1.0 :offset 1.0}]] - ;; At start - (let [result (c/interpolate-gradient stops 0.0)] - (t/is (= "#000000" (:color result)))) - ;; At end - (let [result (c/interpolate-gradient stops 1.0)] - (t/is (= "#ffffff" (:color result)))) - ;; In the middle - (let [result (c/interpolate-gradient stops 0.5)] - (t/is (= "#7f7f7f" (:color result)))))) +(t/deftest types-interpolate-gradient + (t/testing "at start offset returns first stop color" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 1.0}] + result (colors/interpolate-gradient stops 0.0)] + (t/is (= "#000000" (:color result))))) + (t/testing "at end offset returns last stop color" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 1.0}] + result (colors/interpolate-gradient stops 1.0)] + (t/is (= "#ffffff" (:color result))))) + (t/testing "at midpoint returns interpolated gray" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 1.0}] + result (colors/interpolate-gradient stops 0.5)] + (t/is (= "#7f7f7f" (:color result))))) + (t/testing "offset beyond last stop returns last stop color (nil idx guard)" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 0.5}] + result (colors/interpolate-gradient stops 1.0)] + (t/is (= "#ffffff" (:color result)))))) From 6d1d0445884c21312aadc46fd3893e84b805d9a3 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 19:16:19 +0000 Subject: [PATCH 24/89] :recycle: Move app.common.types.color tests to their own namespace Tests that exercise app.common.types.color were living inside common-tests.colors-test alongside the app.common.colors tests. Move them to common-tests.types.color-test so the test namespace mirrors the source namespace structure, consistent with the rest of the types/ test suite. The [app.common.types.color :as colors] require is removed from colors_test.cljc; the new file is registered in runner.cljc. Signed-off-by: Andrey Antukh --- common/test/common_tests/colors_test.cljc | 162 ----------------- common/test/common_tests/runner.cljc | 2 + .../test/common_tests/types/color_test.cljc | 166 ++++++++++++++++++ 3 files changed, 168 insertions(+), 162 deletions(-) create mode 100644 common/test/common_tests/types/color_test.cljc diff --git a/common/test/common_tests/colors_test.cljc b/common/test/common_tests/colors_test.cljc index 21f6af5bef..7d6b0f0e3d 100644 --- a/common/test/common_tests/colors_test.cljc +++ b/common/test/common_tests/colors_test.cljc @@ -9,91 +9,8 @@ #?(:cljs [goog.color :as gcolors]) [app.common.colors :as c] [app.common.math :as mth] - [app.common.types.color :as colors] [clojure.test :as t])) -(t/deftest valid-hex-color - (t/is (false? (colors/valid-hex-color? nil))) - (t/is (false? (colors/valid-hex-color? ""))) - (t/is (false? (colors/valid-hex-color? "#"))) - (t/is (false? (colors/valid-hex-color? "#qqqqqq"))) - (t/is (true? (colors/valid-hex-color? "#aaa"))) - (t/is (false? (colors/valid-hex-color? "#aaaa"))) - (t/is (true? (colors/valid-hex-color? "#fabada")))) - -(t/deftest valid-rgb-color - (t/is (false? (colors/valid-rgb-color? nil))) - (t/is (false? (colors/valid-rgb-color? ""))) - (t/is (false? (colors/valid-rgb-color? "()"))) - (t/is (true? (colors/valid-rgb-color? "(255, 30, 30)"))) - (t/is (true? (colors/valid-rgb-color? "rgb(255, 30, 30)")))) - -(t/deftest rgb-to-str - (t/is (= "rgb(1,2,3)" (colors/rgb->str [1 2 3]))) - (t/is (= "rgba(1,2,3,4)" (colors/rgb->str [1 2 3 4])))) - -(t/deftest rgb-to-hsv - ;; (prn (colors/rgb->hsv [1 2 3])) - ;; (prn (gcolors/rgbToHsv 1 2 3)) - (t/is (= [210.0 0.6666666666666666 3.0] (colors/rgb->hsv [1.0 2.0 3.0]))) - #?(:cljs (t/is (= (colors/rgb->hsv [1 2 3]) (vec (gcolors/rgbToHsv 1 2 3)))))) - -(t/deftest hsv-to-rgb - (t/is (= [1 2 3] - (colors/hsv->rgb [210 0.6666666666666666 3]))) - #?(:cljs - (t/is (= (colors/hsv->rgb [210 0.6666666666666666 3]) - (vec (gcolors/hsvToRgb 210 0.6666666666666666 3)))))) - -(t/deftest rgb-to-hex - (t/is (= "#010203" (colors/rgb->hex [1 2 3])))) - -(t/deftest hex-to-rgb - (t/is (= [0 0 0] (colors/hex->rgb "#kkk"))) - (t/is (= [1 2 3] (colors/hex->rgb "#010203")))) - -(t/deftest format-hsla - (t/is (= "210, 50%, 0.78%, 1" (colors/format-hsla [210.0 0.5 0.00784313725490196 1]))) - (t/is (= "220, 5%, 30%, 0.8" (colors/format-hsla [220.0 0.05 0.3 0.8])))) - -(t/deftest format-rgba - (t/is (= "210, 199, 12, 0.08" (colors/format-rgba [210 199 12 0.08]))) - (t/is (= "210, 199, 12, 1" (colors/format-rgba [210 199 12 1])))) - -(t/deftest rgb-to-hsl - (t/is (= [210.0 0.5 0.00784313725490196] (colors/rgb->hsl [1 2 3]))) - #?(:cljs (t/is (= (colors/rgb->hsl [1 2 3]) - (vec (gcolors/rgbToHsl 1 2 3)))))) - -(t/deftest hsl-to-rgb - (t/is (= [1 2 3] (colors/hsl->rgb [210.0 0.5 0.00784313725490196]))) - (t/is (= [210.0 0.5 0.00784313725490196] (colors/rgb->hsl [1 2 3]))) - #?(:cljs (t/is (= (colors/hsl->rgb [210 0.5 0.00784313725490196]) - (vec (gcolors/hslToRgb 210 0.5 0.00784313725490196)))))) - -(t/deftest expand-hex - (t/is (= "aaaaaa" (colors/expand-hex "a"))) - (t/is (= "aaaaaa" (colors/expand-hex "aa"))) - (t/is (= "aaaaaa" (colors/expand-hex "aaa"))) - (t/is (= "aaaa" (colors/expand-hex "aaaa")))) - -(t/deftest prepend-hash - (t/is "#aaa" (colors/prepend-hash "aaa")) - (t/is "#aaa" (colors/prepend-hash "#aaa"))) - -(t/deftest remove-hash - (t/is "aaa" (colors/remove-hash "aaa")) - (t/is "aaa" (colors/remove-hash "#aaa"))) - -(t/deftest color-string-pred - (t/is (true? (colors/color-string? "#aaa"))) - (t/is (true? (colors/color-string? "(10,10,10)"))) - (t/is (true? (colors/color-string? "rgb(10,10,10)"))) - (t/is (true? (colors/color-string? "magenta"))) - (t/is (false? (colors/color-string? nil))) - (t/is (false? (colors/color-string? ""))) - (t/is (false? (colors/color-string? "kkkkkk")))) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; app.common.colors tests ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -387,82 +304,3 @@ (t/is (= 0.25 (c/reduce-range 0.3 4))) (t/is (= 0.0 (c/reduce-range 0.0 10)))) -;; --- Gradient helpers (app.common.types.color) - -(t/deftest types-interpolate-color - (t/testing "at c1 offset returns c1 color" - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} - result (colors/interpolate-color c1 c2 0.0)] - (t/is (= "#000000" (:color result))) - (t/is (= 0.0 (:opacity result))))) - (t/testing "at c2 offset returns c2 color" - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} - result (colors/interpolate-color c1 c2 1.0)] - (t/is (= "#ffffff" (:color result))) - (t/is (= 1.0 (:opacity result))))) - (t/testing "at midpoint returns interpolated gray" - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} - result (colors/interpolate-color c1 c2 0.5)] - (t/is (= "#7f7f7f" (:color result))) - (t/is (mth/close? (:opacity result) 0.5))))) - -(t/deftest types-uniform-spread - (t/testing "produces correct count and offsets" - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} - stops (colors/uniform-spread c1 c2 3)] - (t/is (= 3 (count stops))) - (t/is (= 0.0 (:offset (first stops)))) - (t/is (mth/close? 0.5 (:offset (second stops)))) - (t/is (= 1.0 (:offset (last stops)))))) - (t/testing "single stop returns a vector of one element (no division by zero)" - (let [c1 {:color "#ff0000" :opacity 1.0 :offset 0.0} - stops (colors/uniform-spread c1 c1 1)] - (t/is (= 1 (count stops)))))) - -(t/deftest types-uniform-spread? - (t/testing "uniformly spread stops are detected as uniform" - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} - stops (colors/uniform-spread c1 c2 3)] - (t/is (true? (colors/uniform-spread? stops))))) - (t/testing "two-stop gradient is uniform by definition" - (let [stops [{:color "#ff0000" :opacity 1.0 :offset 0.0} - {:color "#0000ff" :opacity 1.0 :offset 1.0}]] - (t/is (true? (colors/uniform-spread? stops))))) - (t/testing "stops with wrong offset are not uniform" - (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} - {:color "#888888" :opacity 0.5 :offset 0.3} - {:color "#ffffff" :opacity 1.0 :offset 1.0}]] - (t/is (false? (colors/uniform-spread? stops))))) - (t/testing "stops with correct offset but wrong color are not uniform" - (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} - {:color "#aaaaaa" :opacity 0.5 :offset 0.5} - {:color "#ffffff" :opacity 1.0 :offset 1.0}]] - (t/is (false? (colors/uniform-spread? stops)))))) - -(t/deftest types-interpolate-gradient - (t/testing "at start offset returns first stop color" - (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} - {:color "#ffffff" :opacity 1.0 :offset 1.0}] - result (colors/interpolate-gradient stops 0.0)] - (t/is (= "#000000" (:color result))))) - (t/testing "at end offset returns last stop color" - (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} - {:color "#ffffff" :opacity 1.0 :offset 1.0}] - result (colors/interpolate-gradient stops 1.0)] - (t/is (= "#ffffff" (:color result))))) - (t/testing "at midpoint returns interpolated gray" - (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} - {:color "#ffffff" :opacity 1.0 :offset 1.0}] - result (colors/interpolate-gradient stops 0.5)] - (t/is (= "#7f7f7f" (:color result))))) - (t/testing "offset beyond last stop returns last stop color (nil idx guard)" - (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} - {:color "#ffffff" :opacity 1.0 :offset 0.5}] - result (colors/interpolate-gradient stops 1.0)] - (t/is (= "#ffffff" (:color result)))))) - diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index 6df8243077..2d9a216cbc 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -54,6 +54,7 @@ [common-tests.text-test] [common-tests.time-test] [common-tests.types.absorb-assets-test] + [common-tests.types.color-test] [common-tests.types.components-test] [common-tests.types.container-test] [common-tests.types.fill-test] @@ -126,6 +127,7 @@ 'common-tests.text-test 'common-tests.time-test 'common-tests.types.absorb-assets-test + 'common-tests.types.color-test 'common-tests.types.components-test 'common-tests.types.container-test 'common-tests.types.fill-test diff --git a/common/test/common_tests/types/color_test.cljc b/common/test/common_tests/types/color_test.cljc new file mode 100644 index 0000000000..9a3ab00ac9 --- /dev/null +++ b/common/test/common_tests/types/color_test.cljc @@ -0,0 +1,166 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns common-tests.types.color-test + (:require + [app.common.math :as mth] + [app.common.types.color :as colors] + [clojure.test :as t])) + +;; --- Predicates + +(t/deftest valid-hex-color + (t/is (false? (colors/valid-hex-color? nil))) + (t/is (false? (colors/valid-hex-color? ""))) + (t/is (false? (colors/valid-hex-color? "#"))) + (t/is (false? (colors/valid-hex-color? "#qqqqqq"))) + (t/is (true? (colors/valid-hex-color? "#aaa"))) + (t/is (false? (colors/valid-hex-color? "#aaaa"))) + (t/is (true? (colors/valid-hex-color? "#fabada")))) + +(t/deftest valid-rgb-color + (t/is (false? (colors/valid-rgb-color? nil))) + (t/is (false? (colors/valid-rgb-color? ""))) + (t/is (false? (colors/valid-rgb-color? "()"))) + (t/is (true? (colors/valid-rgb-color? "(255, 30, 30)"))) + (t/is (true? (colors/valid-rgb-color? "rgb(255, 30, 30)")))) + +;; --- Conversions + +(t/deftest rgb-to-str + (t/is (= "rgb(1,2,3)" (colors/rgb->str [1 2 3]))) + (t/is (= "rgba(1,2,3,4)" (colors/rgb->str [1 2 3 4])))) + +(t/deftest rgb-to-hsv + (t/is (= [210.0 0.6666666666666666 3.0] (colors/rgb->hsv [1.0 2.0 3.0])))) + +(t/deftest hsv-to-rgb + (t/is (= [1 2 3] + (colors/hsv->rgb [210 0.6666666666666666 3])))) + +(t/deftest rgb-to-hex + (t/is (= "#010203" (colors/rgb->hex [1 2 3])))) + +(t/deftest hex-to-rgb + (t/is (= [0 0 0] (colors/hex->rgb "#kkk"))) + (t/is (= [1 2 3] (colors/hex->rgb "#010203")))) + +(t/deftest format-hsla + (t/is (= "210, 50%, 0.78%, 1" (colors/format-hsla [210.0 0.5 0.00784313725490196 1]))) + (t/is (= "220, 5%, 30%, 0.8" (colors/format-hsla [220.0 0.05 0.3 0.8])))) + +(t/deftest format-rgba + (t/is (= "210, 199, 12, 0.08" (colors/format-rgba [210 199 12 0.08]))) + (t/is (= "210, 199, 12, 1" (colors/format-rgba [210 199 12 1])))) + +(t/deftest rgb-to-hsl + (t/is (= [210.0 0.5 0.00784313725490196] (colors/rgb->hsl [1 2 3])))) + +(t/deftest hsl-to-rgb + (t/is (= [1 2 3] (colors/hsl->rgb [210.0 0.5 0.00784313725490196]))) + (t/is (= [210.0 0.5 0.00784313725490196] (colors/rgb->hsl [1 2 3])))) + +(t/deftest expand-hex + (t/is (= "aaaaaa" (colors/expand-hex "a"))) + (t/is (= "aaaaaa" (colors/expand-hex "aa"))) + (t/is (= "aaaaaa" (colors/expand-hex "aaa"))) + (t/is (= "aaaa" (colors/expand-hex "aaaa")))) + +(t/deftest prepend-hash + (t/is "#aaa" (colors/prepend-hash "aaa")) + (t/is "#aaa" (colors/prepend-hash "#aaa"))) + +(t/deftest remove-hash + (t/is "aaa" (colors/remove-hash "aaa")) + (t/is "aaa" (colors/remove-hash "#aaa"))) + +(t/deftest color-string-pred + (t/is (true? (colors/color-string? "#aaa"))) + (t/is (true? (colors/color-string? "(10,10,10)"))) + (t/is (true? (colors/color-string? "rgb(10,10,10)"))) + (t/is (true? (colors/color-string? "magenta"))) + (t/is (false? (colors/color-string? nil))) + (t/is (false? (colors/color-string? ""))) + (t/is (false? (colors/color-string? "kkkkkk")))) + +;; --- Gradient helpers + +(t/deftest interpolate-color + (t/testing "at c1 offset returns c1 color" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + result (colors/interpolate-color c1 c2 0.0)] + (t/is (= "#000000" (:color result))) + (t/is (= 0.0 (:opacity result))))) + (t/testing "at c2 offset returns c2 color" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + result (colors/interpolate-color c1 c2 1.0)] + (t/is (= "#ffffff" (:color result))) + (t/is (= 1.0 (:opacity result))))) + (t/testing "at midpoint returns interpolated gray" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + result (colors/interpolate-color c1 c2 0.5)] + (t/is (= "#7f7f7f" (:color result))) + (t/is (mth/close? (:opacity result) 0.5))))) + +(t/deftest uniform-spread + (t/testing "produces correct count and offsets" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + stops (colors/uniform-spread c1 c2 3)] + (t/is (= 3 (count stops))) + (t/is (= 0.0 (:offset (first stops)))) + (t/is (mth/close? 0.5 (:offset (second stops)))) + (t/is (= 1.0 (:offset (last stops)))))) + (t/testing "single stop returns a vector of one element (no division by zero)" + (let [c1 {:color "#ff0000" :opacity 1.0 :offset 0.0} + stops (colors/uniform-spread c1 c1 1)] + (t/is (= 1 (count stops)))))) + +(t/deftest uniform-spread? + (t/testing "uniformly spread stops are detected as uniform" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + stops (colors/uniform-spread c1 c2 3)] + (t/is (true? (colors/uniform-spread? stops))))) + (t/testing "two-stop gradient is uniform by definition" + (let [stops [{:color "#ff0000" :opacity 1.0 :offset 0.0} + {:color "#0000ff" :opacity 1.0 :offset 1.0}]] + (t/is (true? (colors/uniform-spread? stops))))) + (t/testing "stops with wrong offset are not uniform" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#888888" :opacity 0.5 :offset 0.3} + {:color "#ffffff" :opacity 1.0 :offset 1.0}]] + (t/is (false? (colors/uniform-spread? stops))))) + (t/testing "stops with correct offset but wrong color are not uniform" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#aaaaaa" :opacity 0.5 :offset 0.5} + {:color "#ffffff" :opacity 1.0 :offset 1.0}]] + (t/is (false? (colors/uniform-spread? stops)))))) + +(t/deftest interpolate-gradient + (t/testing "at start offset returns first stop color" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 1.0}] + result (colors/interpolate-gradient stops 0.0)] + (t/is (= "#000000" (:color result))))) + (t/testing "at end offset returns last stop color" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 1.0}] + result (colors/interpolate-gradient stops 1.0)] + (t/is (= "#ffffff" (:color result))))) + (t/testing "at midpoint returns interpolated gray" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 1.0}] + result (colors/interpolate-gradient stops 0.5)] + (t/is (= "#7f7f7f" (:color result))))) + (t/testing "offset beyond last stop returns last stop color (nil idx guard)" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 0.5}] + result (colors/interpolate-gradient stops 1.0)] + (t/is (= "#ffffff" (:color result)))))) From 1d8299a9199d696f20d8f6dd77b903877c119cce Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Fri, 10 Apr 2026 11:51:40 +0200 Subject: [PATCH 25/89] :bug: Fix problem with component thumbnails --- .../app/main/data/workspace/libraries.cljs | 13 ++- .../main/data/workspace/thumbnails_wasm.cljs | 93 ++++++++++++++----- frontend/src/app/render_wasm/helpers.cljc | 1 + 3 files changed, 79 insertions(+), 28 deletions(-) diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index ef7bfd2f93..ca4362ef5b 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -411,9 +411,16 @@ (when id-ref (reset! id-ref component-id)) (when-not (empty? (:redo-changes changes)) - (rx/of (dch/commit-changes changes) - (dws/select-shapes (d/ordered-set (:id root))) - (ptk/data-event :layout/update {:ids parents})))))))))) + (rx/concat + (rx/of (dch/commit-changes changes) + (dws/select-shapes (d/ordered-set (:id root))) + (ptk/data-event :layout/update {:ids parents})) + + ;; When activated the wasm rendering we need to recreate its thumbnail on creation + (if (features/active-feature? state "render-wasm/v1") + (rx/of (dwt.wasm/render-thumbnail file-id page-id (:id root)) + (dwt.wasm/persist-thumbnail file-id page-id (:id root))) + (rx/empty))))))))))) (defn add-component "Add a new component to current file library, from the currently selected shapes. diff --git a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs index 44459e9df8..c64e9a2729 100644 --- a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs +++ b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs @@ -15,11 +15,15 @@ - persist-thumbnail: pushes current data-uri to the server (debounced)" (:require [app.common.data.macros :as dm] + [app.common.files.helpers :as cfh] [app.common.logging :as l] + [app.common.math :as mth] [app.common.thumbnails :as thc] [app.common.time :as ct] + [app.main.data.helpers :as dsh] [app.main.data.workspace.thumbnails :as dwt] [app.main.repo :as rp] + [app.main.store :as st] [app.render-wasm.api :as wasm.api] [app.util.webapi :as wapi] [beicon.v2.core :as rx] @@ -41,27 +45,38 @@ (fn [e] (reject e))) (.readAsDataURL reader blob))))) +;; This constant stores the target thumbnail minimum max-size so +;; the images doesn't lose quality when rendered +(def target-size 200) + (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." - [frame-id] + [file-id page-id frame-id] (rx/create (fn [subs] (js/requestAnimationFrame (fn [_] (try - (let [png-bytes (wasm.api/render-shape-pixels frame-id 1)] + (let [objects (dsh/lookup-page-objects @st/state file-id page-id) + frame (get objects frame-id) + {:keys [width height]} (:selrect frame) + max-size (mth/max width height) + scale (mth/max 1 (/ target-size max-size)) + png-bytes (wasm.api/render-shape-pixels frame-id scale)] (if (or (nil? png-bytes) (zero? (.-length png-bytes))) - (do (js/console.error "[thumbnails] render-shape-pixels returned empty for" (str frame-id)) - (rx/end! subs)) - (.then (png-bytes->data-uri png-bytes) - (fn [data-uri] - (rx/push! subs data-uri) - (rx/end! subs)) - (fn [err] - (rx/error! subs err))))) + (do + (js/console.error "[thumbnails] render-shape-pixels returned empty for" (str frame-id)) + (rx/end! subs)) + (.then + (png-bytes->data-uri png-bytes) + (fn [data-uri] + (rx/push! subs data-uri) + (rx/end! subs)) + (fn [err] + (rx/error! subs err))))) (catch :default err (rx/error! subs err))))) nil))) @@ -71,29 +86,57 @@ Does NOT persist to the server — persistence is handled separately by `persist-thumbnail` on a debounced schedule." [file-id page-id frame-id] + (let [object-id (thc/fmt-object-id file-id page-id frame-id "component")] (ptk/reify ::render-thumbnail cljs.core/IDeref (-deref [_] object-id) ptk/WatchEvent - (watch [_ _ stream] - (let [tp (ct/tpoint-ms)] - (->> (render-component-pixels frame-id) - (rx/map - (fn [data-uri] - (l/dbg :hint "component thumbnail rendered (wasm)" - :elapsed (dm/str (tp) "ms")) - (dwt/assoc-thumbnail object-id data-uri))) + (watch [_ state stream] + ;; When the component is removed it can arrived a render + ;; request with frame-id=null + (when (some? frame-id) + (letfn [(load-objects-stream + [] + (rx/create + (fn [subs] + (let [objects (dsh/lookup-page-objects state file-id page-id) - (rx/catch (fn [err] - (js/console.error "[thumbnails] error rendering component thumbnail" err) - (rx/empty))) + ;; retrieves a subtree with only the id and its children + ;; to be loaded before rendering the thumbnail + subtree + (into {} + (map #(vector (:id %) %)) + (cfh/get-children-with-self objects frame-id))] + (try + (wasm.api/set-objects subtree #(rx/push! subs %)) + (catch :default err + (rx/error! subs err))))))) - (rx/take-until - (->> stream - (rx/filter (ptk/type? ::dwt/clear-thumbnail)) - (rx/filter #(= (deref %) object-id)))))))))) + (do-render-thumbnail + [] + (let [tp (ct/tpoint-ms)] + (->> (render-component-pixels file-id page-id frame-id) + (rx/map + (fn [data-uri] + (l/dbg :hint "component thumbnail rendered (wasm)" + :elapsed (dm/str (tp) "ms")) + (dwt/assoc-thumbnail object-id data-uri))) + + (rx/catch (fn [err] + (js/console.error "[thumbnails] error rendering component thumbnail" err) + (rx/empty))) + + (rx/take-until + (->> stream + (rx/filter (ptk/type? ::dwt/clear-thumbnail)) + (rx/filter #(= (deref %) object-id)))))))] + + (if (not= page-id (:current-page-id state)) + (->> (load-objects-stream) + (rx/mapcat do-render-thumbnail)) + (do-render-thumbnail)))))))) (defn persist-thumbnail "Persists the current component thumbnail data-uri to the server. diff --git a/frontend/src/app/render_wasm/helpers.cljc b/frontend/src/app/render_wasm/helpers.cljc index 0ce16bd955..debf07eab9 100644 --- a/frontend/src/app/render_wasm/helpers.cljc +++ b/frontend/src/app/render_wasm/helpers.cljc @@ -24,6 +24,7 @@ cause-sym (gensym "cause")] `(let [~fn-sym (cljs.core/unchecked-get ~module ~name)] (try + ;; (prn ~name ~@params) (~fn-sym ~@params) (catch :default ~cause-sym (let [read-code-fn# (cljs.core/unchecked-get ~module "_read_error_code") From 988c277e372525ed0631c8ba23bd8e10d909d91f Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Mon, 13 Apr 2026 15:27:30 +0200 Subject: [PATCH 26/89] :bug: Post-review enhancements --- .../main/data/workspace/thumbnails_wasm.cljs | 52 ++++++++++--------- frontend/src/app/render_wasm/helpers.cljc | 1 - 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs index c64e9a2729..3695205985 100644 --- a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs +++ b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs @@ -47,7 +47,7 @@ ;; This constant stores the target thumbnail minimum max-size so ;; the images doesn't lose quality when rendered -(def target-size 200) +(def ^:private ^:const target-size 200) (defn- render-component-pixels "Renders a component frame using the workspace WASM context. @@ -57,29 +57,30 @@ [file-id page-id frame-id] (rx/create (fn [subs] - (js/requestAnimationFrame - (fn [_] - (try - (let [objects (dsh/lookup-page-objects @st/state file-id page-id) - frame (get objects frame-id) - {:keys [width height]} (:selrect frame) - max-size (mth/max width height) - scale (mth/max 1 (/ target-size max-size)) - png-bytes (wasm.api/render-shape-pixels frame-id scale)] - (if (or (nil? png-bytes) (zero? (.-length png-bytes))) - (do - (js/console.error "[thumbnails] render-shape-pixels returned empty for" (str frame-id)) - (rx/end! subs)) - (.then - (png-bytes->data-uri png-bytes) - (fn [data-uri] - (rx/push! subs data-uri) - (rx/end! subs)) - (fn [err] - (rx/error! subs err))))) - (catch :default err - (rx/error! subs err))))) - nil))) + (let [req-id + (js/requestAnimationFrame + (fn [_] + (try + (let [objects (dsh/lookup-page-objects @st/state file-id page-id) + frame (get objects frame-id) + {:keys [width height]} (:selrect frame) + max-size (mth/max width height) + scale (mth/max 1 (/ target-size max-size)) + png-bytes (wasm.api/render-shape-pixels frame-id scale)] + (if (or (nil? png-bytes) (zero? (.-length png-bytes))) + (do + (l/error :hint "render-shape-pixels returned empty" :frame-id (str frame-id)) + (rx/end! subs)) + (.then + (png-bytes->data-uri png-bytes) + (fn [data-uri] + (rx/push! subs data-uri) + (rx/end! subs)) + (fn [err] + (rx/error! subs err))))) + (catch :default err + (rx/error! subs err)))))] + #(js/cancelAnimationFrame req-id))))) (defn render-thumbnail "Renders a component thumbnail via WASM and updates the UI immediately. @@ -125,7 +126,8 @@ (dwt/assoc-thumbnail object-id data-uri))) (rx/catch (fn [err] - (js/console.error "[thumbnails] error rendering component thumbnail" err) + (js/console.error err) + (l/error :hint "error rendering component thumbnail" :frame-id (str frame-id)) (rx/empty))) (rx/take-until diff --git a/frontend/src/app/render_wasm/helpers.cljc b/frontend/src/app/render_wasm/helpers.cljc index debf07eab9..0ce16bd955 100644 --- a/frontend/src/app/render_wasm/helpers.cljc +++ b/frontend/src/app/render_wasm/helpers.cljc @@ -24,7 +24,6 @@ cause-sym (gensym "cause")] `(let [~fn-sym (cljs.core/unchecked-get ~module ~name)] (try - ;; (prn ~name ~@params) (~fn-sym ~@params) (catch :default ~cause-sym (let [read-code-fn# (cljs.core/unchecked-get ~module "_read_error_code") From a2e6abcb72b13843c82a7d88f6e56e166f259e3f Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:03:39 +0000 Subject: [PATCH 27/89] :bug: Fix spurious argument to dissoc in patch-object The patch-object function was calling (dissoc object key value) when handling nil values. Since dissoc treats each argument after the map as a key to remove, this was also removing nil as a key from the map. The correct call is (dissoc object key). --- common/src/app/common/data.cljc | 2 +- common/test/common_tests/data_test.cljc | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 4cb6cedc60..7f59c14ad6 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -377,7 +377,7 @@ (assoc object key nil) (nil? value) - (dissoc object key value) + (dissoc object key) :else (assoc object key value))) diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index 726fc8f377..873f0bb7d1 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -445,6 +445,8 @@ (t/is (= {:a {:x 10 :y 2}} (d/patch-object {:a {:x 1 :y 2}} {:a {:x 10}}))) ;; nested nil removes nested key (t/is (= {:a {:y 2}} (d/patch-object {:a {:x 1 :y 2}} {:a {:x nil}}))) + ;; nil value removes only the specified key, not other keys + (t/is (= {nil 0 :b 2} (d/patch-object {nil 0 :a 1 :b 2} {:a nil}))) ;; transducer arity (1-arg returns a fn) (let [f (d/patch-object {:a 99})] (t/is (= {:a 99 :b 2} (f {:a 1 :b 2}))))) From 057c6ddc0df47331de9d7dd7977317e8e54cb1e6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:04:36 +0000 Subject: [PATCH 28/89] :bug: Fix deep-mapm double-applying mfn on leaf entries The deep-mapm function was applying the mapping function twice on leaf entries (non-map, non-vector values): once when destructuring the entry, and again on the already-transformed result in the else branch. Now mfn is applied exactly once per entry. --- common/src/app/common/data.cljc | 7 ++----- common/test/common_tests/data_test.cljc | 17 ++++++++++------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 7f59c14ad6..93d66780f5 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -602,12 +602,9 @@ (let [do-map (fn [entry] (let [[k v] (mfn entry)] - (cond - (or (vector? v) (map? v)) + (if (or (vector? v) (map? v)) [k (deep-mapm mfn v)] - - :else - (mfn [k v]))))] + [k v])))] (cond (map? m) (into {} (map do-map) m) diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index 873f0bb7d1..7cad2da911 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -538,17 +538,20 @@ (into [] (d/distinct-xf :id) [{:id 1 :v "a"} {:id 2 :v "x"} {:id 2 :v "b"}])))) (t/deftest deep-mapm-test - ;; Note: mfn is called twice on leaf entries (once initially, once again - ;; after checking if the value is a map/vector), so a doubling fn applied - ;; to value 1 gives 1*2*2=4. - (t/is (= {:a 4 :b {:c 8}} + ;; mfn is applied once per entry + (t/is (= {:a 2 :b {:c 4}} (d/deep-mapm (fn [[k v]] [k (if (number? v) (* v 2) v)]) {:a 1 :b {:c 2}}))) - ;; Keyword renaming: keys are also transformed — and applied twice. - ;; Use an idempotent key transformation (uppercase once = uppercase twice). + ;; Keyword renaming: keys are transformed once per entry (let [result (d/deep-mapm (fn [[k v]] [(keyword (str (name k) "!")) v]) {:a 1})] - (t/is (contains? result (keyword "a!!"))))) + (t/is (contains? result (keyword "a!")))) + ;; Vectors inside maps are recursed into + (t/is (= {:items [{:x 10}]} + (d/deep-mapm (fn [[k v]] [k (if (number? v) (* v 10) v)]) + {:items [{:x 1}]}))) + ;; Plain scalar at top level map + (t/is (= {:a "hello"} (d/deep-mapm identity {:a "hello"})))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Numeric helpers From 92dd5d9954d981bfddd5bff5e2bb6300d21c365f Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:05:35 +0000 Subject: [PATCH 29/89] :bug: Fix index-of-pred early termination on nil elements The index-of-pred function used (nil? c) to detect end-of-collection, which caused premature termination when the collection contained nil values. Rewrite using (seq coll) / (next s) pattern to correctly distinguish between nil elements and end-of-sequence. --- common/src/app/common/data.cljc | 11 ++++------- common/test/common_tests/data_test.cljc | 11 +++++++++-- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 93d66780f5..5f4b4a0cf2 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -291,15 +291,12 @@ (defn index-of-pred [coll pred] - (loop [c (first coll) - coll (rest coll) + (loop [s (seq coll) index 0] - (if (nil? c) - nil - (if (pred c) + (when s + (if (pred (first s)) index - (recur (first coll) - (rest coll) + (recur (next s) (inc index)))))) (defn index-of diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index 7cad2da911..f0487ed71d 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -372,12 +372,19 @@ (t/is (= 0 (d/index-of-pred [1 2 3] odd?))) (t/is (= 1 (d/index-of-pred [2 3 4] odd?))) (t/is (nil? (d/index-of-pred [2 4 6] odd?))) - (t/is (nil? (d/index-of-pred [] odd?)))) + (t/is (nil? (d/index-of-pred [] odd?))) + ;; works correctly when collection contains nil elements + (t/is (= 2 (d/index-of-pred [nil nil 3] some?))) + (t/is (= 0 (d/index-of-pred [nil 1 2] nil?))) + ;; works correctly when collection contains false elements + (t/is (= 1 (d/index-of-pred [false true false] true?)))) (t/deftest index-of-test (t/is (= 0 (d/index-of [:a :b :c] :a))) (t/is (= 2 (d/index-of [:a :b :c] :c))) - (t/is (nil? (d/index-of [:a :b :c] :z)))) + (t/is (nil? (d/index-of [:a :b :c] :z))) + ;; works when searching for nil in a collection + (t/is (= 1 (d/index-of [:a nil :c] nil)))) (t/deftest replace-by-id-test (let [items [{:id 1 :v "a"} {:id 2 :v "b"} {:id 3 :v "c"}] From 1cc860807e2246a16bd00a99991fef04cdce3366 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:06:13 +0000 Subject: [PATCH 30/89] :zap: Use seq/next idiom in enumerate instead of empty?/rest Replace (empty? items) + (rest items) with (seq items) + (next items) in enumerate. The seq/next pattern is idiomatic Clojure and avoids the overhead of empty? which internally calls seq and then negates. --- common/src/app/common/data.cljc | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 5f4b4a0cf2..2da21aadb0 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -252,13 +252,13 @@ ([items] (enumerate items 0)) ([items start] (loop [idx start - items items + items (seq items) res (transient [])] - (if (empty? items) - (persistent! res) + (if items (recur (inc idx) - (rest items) - (conj! res [idx (first items)])))))) + (next items) + (conj! res [idx (first items)])) + (persistent! res))))) (defn group-by ([kf coll] (group-by kf identity [] coll)) From d73ab3ec92b4af8a445db1545f54af9de614a512 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:11:46 +0000 Subject: [PATCH 31/89] :bug: Fix safe-subvec 3-arity evaluating (count v) before nil check The 3-arity of safe-subvec called (count v) in a let binding before checking (some? v). While (count nil) returns 0 in Clojure and does not crash, the nil guard was dead code. Restructure to check (some? v) first with an outer when, then compute size inside the guarded block. --- common/src/app/common/data.cljc | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 2da21aadb0..6b2cc5d3b4 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -1149,11 +1149,11 @@ (> start 0) (< start (count v))) (subvec v start))) ([v start end] - (let [size (count v)] - (when (and (some? v) - (>= start 0) (< start size) - (>= end 0) (<= start end) (<= end size)) - (subvec v start end))))) + (when (some? v) + (let [size (count v)] + (when (and (>= start 0) (< start size) + (>= end 0) (<= start end) (<= end size)) + (subvec v start end)))))) (defn append-class [class current-class] From 29ea1cc49548d1a63c33bbdf44447f0d3c45e914 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:12:38 +0000 Subject: [PATCH 32/89] :books: Fix misleading without-obj docstring The docstring claimed the function removes nil values in addition to the specified object, but the implementation only removes elements equal to the given object. Fix the docstring in both data.cljc and the local copy in files/changes.cljc. --- common/src/app/common/data.cljc | 2 +- common/src/app/common/files/changes.cljc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 6b2cc5d3b4..19d4828d13 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -393,7 +393,7 @@ (subvec v (inc index)))) (defn without-obj - "Clear collection from specified obj and without nil values." + "Return a vector with all elements equal to `o` removed." [coll o] (into [] (filter #(not= % o)) coll)) diff --git a/common/src/app/common/files/changes.cljc b/common/src/app/common/files/changes.cljc index 8673ef81e3..c9793434b7 100644 --- a/common/src/app/common/files/changes.cljc +++ b/common/src/app/common/files/changes.cljc @@ -439,7 +439,7 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defn- without-obj - "Clear collection from specified obj and without nil values." + "Return a vector with all elements equal to `o` removed." [coll o] (into [] (filter #(not= % o)) coll)) From eca9b63d688c87e62a58bfdba9a683c91061ce29 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:13:20 +0000 Subject: [PATCH 33/89] :zap: Remove redundant map lookups in map-diff The :else branch of diff-attr was calling (get m1 key) and (get m2 key) again, but v1 and v2 were already bound to those exact values. Reuse the existing bindings to avoid the extra lookups. --- common/src/app/common/data.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 19d4828d13..1494132f1d 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -952,7 +952,7 @@ (assoc diff key (map-diff v1 v2)) :else - (assoc diff key [(get m1 key) (get m2 key)]))))] + (assoc diff key [v1 v2]))))] (->> keys (reduce diff-attr {})))) From 69e25a4998e006108cc17f6f2817cbc41403be82 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:14:00 +0000 Subject: [PATCH 34/89] :books: Fix typo in namespace docstring ('if' -> 'of') --- common/src/app/common/data.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 1494132f1d..e1efba5de6 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -5,7 +5,7 @@ ;; Copyright (c) KALEIDOS INC (ns app.common.data - "A collection if helpers for working with data structures and other + "A collection of helpers for working with data structures and other data resources." (:refer-clojure :exclude [read-string hash-map merge name update-vals parse-double group-by iteration concat mapcat From da8e44147c72c5b19b7e0295bd97b730fec8d9b9 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:15:39 +0000 Subject: [PATCH 35/89] :sparkles: Remove redundant str call in format-number format-precision already returns a string, so wrapping its result in an additional (str ...) call was unnecessary. --- common/src/app/common/data.cljc | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index e1efba5de6..70f775b4c3 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -1117,8 +1117,7 @@ ([value {:keys [precision] :or {precision 2}}] (let [value (if (string? value) (parse-double value) value)] (when (num? value) - (let [value (format-precision value precision)] - (str value)))))) + (format-precision value precision))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Util protocols From 83da487b24cf969d99cdba0ba292e406c93f0df6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:16:49 +0000 Subject: [PATCH 36/89] :bug: Fix append-class producing leading space for empty class When called with an empty string as the base class, append-class was producing " bar" (with a leading space) because (some? "") returns true. Use (seq class) instead to treat both nil and empty string as absent, avoiding invalid CSS class strings with leading whitespace. --- common/src/app/common/data.cljc | 5 +++-- common/test/common_tests/data_test.cljc | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 70f775b4c3..715bd97544 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -1156,5 +1156,6 @@ (defn append-class [class current-class] - (str (if (some? class) (str class " ") "") - current-class)) + (if (seq class) + (str class " " current-class) + current-class)) diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index f0487ed71d..d85e835af9 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -791,7 +791,8 @@ (t/deftest append-class-test (t/is (= "foo bar" (d/append-class "foo" "bar"))) (t/is (= "bar" (d/append-class nil "bar"))) - (t/is (= " bar" (d/append-class "" "bar")))) + ;; empty string is treated like nil — no leading space + (t/is (= "bar" (d/append-class "" "bar")))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Additional helpers (5th batch) From bba3610b7beb9e0f0063a9923fc92bfdbba03d54 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:17:48 +0000 Subject: [PATCH 37/89] :recycle: Rename shadowed 'fn' parameter to 'pred' in removev The removev function used 'fn' as its predicate parameter name, which shadows clojure.core/fn. Rename to 'pred' for clarity and to follow the naming convention used elsewhere in the namespace. --- common/src/app/common/data.cljc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 715bd97544..86a733ee28 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -420,9 +420,9 @@ coll))) (defn removev - "Returns a vector of the items in coll for which (fn item) returns logical false" - [fn coll] - (filterv (comp not fn) coll)) + "Returns a vector of the items in coll for which (pred item) returns logical false" + [pred coll] + (filterv (comp not pred) coll)) (defn filterm "Filter values of a map that satisfy a predicate" From 95d4d42c911e12294def92b09ce398de99801162 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:18:55 +0000 Subject: [PATCH 38/89] :bug: Add missing string? guard to num-string? on JVM The CLJS branch of num-string? checked (string? v) first, but the JVM branch did not. Passing non-string values (nil, keywords, etc.) would rely on exception handling inside parse-double for control flow. Add the string? check for consistency and to avoid using exceptions for normal control flow. --- common/src/app/common/data.cljc | 3 ++- common/test/common_tests/data_test.cljc | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 86a733ee28..e9bb3a918f 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -782,7 +782,8 @@ (not (js/isNaN v)) (not (js/isNaN (parse-double v)))) - :clj (not= (parse-double v :nan) :nan))) + :clj (and (string? v) + (not= (parse-double v :nan) :nan)))) (defn read-string [v] diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index d85e835af9..3228ec0298 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -841,6 +841,9 @@ (t/is (d/num-string? "-7")) (t/is (not (d/num-string? "hello"))) (t/is (not (d/num-string? nil))) + ;; non-string types always return false + (t/is (not (d/num-string? 42))) + (t/is (not (d/num-string? :keyword))) ;; In CLJS, js/isNaN("") → false (empty string coerces to 0), so "" is numeric #?(:clj (t/is (not (d/num-string? "")))) #?(:cljs (t/is (d/num-string? "")))) From b26ef158ef7f9abec223486504aa6e0ecf73704a Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:21:30 +0000 Subject: [PATCH 39/89] :books: Fix typos in vec2, zip-all, and map-perm docstrings --- common/src/app/common/data.cljc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index e9bb3a918f..258b506895 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -143,7 +143,7 @@ (oassoc-in o (cons k ks) v))) (defn vec2 - "Creates a optimized vector compatible type of length 2 backed + "Creates an optimized vector compatible type of length 2 backed internally with MapEntry impl because it has faster access method for its fields." [o1 o2] @@ -401,7 +401,7 @@ (map vector col1 col2)) (defn zip-all - "Return a zip of both collections, extended to the lenght of the longest one, + "Return a zip of both collections, extended to the length of the longest one, and padding the shorter one with nils as needed." [col1 col2] (let [diff (- (count col1) (count col2))] @@ -440,7 +440,7 @@ Optional parameters: `pred?` A predicate that if not satisfied won't process the pair - `target?` A collection that will be used as seed to be stored + `target` A collection that will be used as seed to be stored Example: (map-perm vector [1 2 3 4]) => [[1 2] [1 3] [1 4] [2 3] [2 4] [3 4]]" From 176edadb6f22ded8c7f1b0458b8b03f948d076c0 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 22:57:29 +0000 Subject: [PATCH 40/89] :bug: Fix nan? returning false for ##NaN on JVM Clojure's = uses .equals on doubles, and Double.equals(Double.NaN) returns true, so (not= v v) was always false for NaN. Use Double/isNaN with a number? guard instead. --- common/src/app/common/data.cljc | 2 +- common/test/common_tests/data_test.cljc | 22 +++++++--------------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 258b506895..4c03438ed2 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -718,7 +718,7 @@ (defn nan? [v] #?(:cljs (js/isNaN v) - :clj (not= v v))) + :clj (and (number? v) (Double/isNaN v)))) (defn- impl-parse-integer [v] diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index 3228ec0298..ad5d6a21a2 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -565,16 +565,13 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (t/deftest nan-test - ;; Note: nan? behaves differently per platform: - ;; - CLJS: uses js/isNaN, returns true for ##NaN - ;; - CLJ: uses (not= v v); Clojure's = uses .equals on doubles, - ;; so (= ##NaN ##NaN) is true and nan? returns false for ##NaN. - ;; Either way, nan? returns false for regular numbers and nil. + (t/is (d/nan? ##NaN)) (t/is (not (d/nan? 0))) (t/is (not (d/nan? 1))) (t/is (not (d/nan? nil))) - ;; Platform-specific: JS nan? correctly detects NaN - #?(:cljs (t/is (d/nan? ##NaN)))) + ;; CLJS js/isNaN coerces non-numbers; JVM Double/isNaN is number-only + #?(:cljs (t/is (d/nan? "hello"))) + #?(:clj (t/is (not (d/nan? "hello"))))) (t/deftest safe-plus-test (t/is (= 5 (d/safe+ 3 2))) @@ -618,18 +615,13 @@ (t/is (nil? (d/parse-uuid nil)))) (t/deftest coalesce-str-test - ;; On JVM: nan? uses (not= v v), which is false for all normal values. - ;; On CLJS: nan? uses js/isNaN, which is true for non-numeric strings. - ;; coalesce-str returns default when value is nil or nan?. (t/is (= "default" (d/coalesce-str nil "default"))) ;; Numbers always stringify on both platforms (t/is (= "42" (d/coalesce-str 42 "default"))) - ;; ##NaN: nan? is true in CLJS, returns default; - ;; nan? is false in CLJ, so str(##NaN)="NaN" is returned. - #?(:cljs (t/is (= "default" (d/coalesce-str ##NaN "default")))) - #?(:clj (t/is (= "NaN" (d/coalesce-str ##NaN "default")))) + ;; ##NaN returns default on both platforms now that nan? is fixed on JVM + (t/is (= "default" (d/coalesce-str ##NaN "default"))) ;; Strings: in CLJS js/isNaN("hello")=true so "default" is returned; - ;; in CLJ nan? is false so (str "hello")="hello" is returned. + ;; in CLJ nan? is false for strings so (str "hello")="hello" is returned. #?(:cljs (t/is (= "default" (d/coalesce-str "hello" "default")))) #?(:clj (t/is (= "hello" (d/coalesce-str "hello" "default"))))) From 2e97f0183817fc2a422975f0a983874edd68d5c2 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 22:58:13 +0000 Subject: [PATCH 41/89] :bug: Fix safe-subvec 2-arity rejecting start=0 The guard used (> start 0) instead of (>= start 0), so (safe-subvec v 0) returned nil instead of the full vector. --- common/src/app/common/data.cljc | 2 +- common/test/common_tests/data_test.cljc | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 4c03438ed2..75f103e496 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -1146,7 +1146,7 @@ "Wrapper around subvec so it doesn't throw an exception but returns nil instead" ([v start] (when (and (some? v) - (> start 0) (< start (count v))) + (>= start 0) (< start (count v))) (subvec v start))) ([v start end] (when (some? v) diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index ad5d6a21a2..b8283ca49e 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -325,6 +325,8 @@ (t/is (= [2 3] (d/safe-subvec [1 2 3 4] 1 3))) ;; single arg — from index to end (t/is (= [2 3 4] (d/safe-subvec [1 2 3 4] 1))) + ;; start=0 returns the full vector + (t/is (= [1 2 3 4] (d/safe-subvec [1 2 3 4] 0))) ;; out-of-range returns nil (t/is (nil? (d/safe-subvec [1 2 3] 5))) (t/is (nil? (d/safe-subvec [1 2 3] 0 5))) From b4ec0a6d55a1055a740a45d4973055a298d09b5b Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 2 Apr 2026 09:24:36 +0000 Subject: [PATCH 42/89] :bug: Add missing zoom and page-id dep on snap-feedback use-effect --- frontend/src/app/main/ui/workspace/viewport/rulers.cljs | 2 +- frontend/src/app/main/ui/workspace/viewport/snap_points.cljs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/viewport/rulers.cljs b/frontend/src/app/main/ui/workspace/viewport/rulers.cljs index 6b23bb277e..b23e71ade2 100644 --- a/frontend/src/app/main/ui/workspace/viewport/rulers.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/rulers.cljs @@ -187,8 +187,8 @@ :stroke-width rulers-width}}]]))])) (mf/defc viewport-frame* + {::mf/private true} [{:keys [show-rulers zoom zoom-inverse vbox offset-x offset-y]}] - (let [{:keys [width height] x1 :x y1 :y} vbox x2 (+ x1 width) y2 (+ y1 height) diff --git a/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs b/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs index 64ce4ab55e..ccc0660e2b 100644 --- a/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs @@ -101,6 +101,7 @@ (hash-map coord fixedv (flip coord) maxv)])))) (mf/defc snap-feedback* + {::mf/private true} [{:keys [shapes remove-snap zoom modifiers page-id]}] (let [state (mf/use-state []) subject (mf/use-memo #(rx/subject)) @@ -133,7 +134,7 @@ #(rx/dispose! sub)))) (mf/use-effect - (mf/deps shapes remove-snap modifiers) + (mf/deps shapes remove-snap modifiers page-id zoom) (fn [] (rx/push! subject {:shapes shapes :page-id page-id From b2f4e90a79350ec571d34b7db4af5ec7c18a5c0b Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 2 Apr 2026 16:04:17 +0000 Subject: [PATCH 43/89] :recycle: Convert shape-distance-segment to modern * component format Convert shape-distance-segment to shape-distance-segment* using {:keys [...]} destructuring and update its internal call site in shape-distance to use [:> ...]. --- .../src/app/main/ui/workspace/viewport/snap_distances.cljs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/viewport/snap_distances.cljs b/frontend/src/app/main/ui/workspace/viewport/snap_distances.cljs index c903a19389..97b8c905f8 100644 --- a/frontend/src/app/main/ui/workspace/viewport/snap_distances.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/snap_distances.cljs @@ -50,7 +50,7 @@ (def pill-text-border-radius 4) (def pill-text-padding 4) -(mf/defc shape-distance-segment +(mf/defc shape-distance-segment* "Displays a segment between two selrects with the distance between them" [{:keys [sr1 sr2 coord zoom]}] (let [from-c (mth/min (get sr1 (if (= :x coord) :x2 :y2)) @@ -268,7 +268,7 @@ #(rx/push! subject [selrect selected frame])) (for [[sr1 sr2] segments-to-display] - [:& shape-distance-segment + [:> shape-distance-segment* {:key (str/ffmt "%-%-%-%" (dm/get-prop sr1 :x) (dm/get-prop sr1 :y) From 175f122a0f14e4f75b24c11201186aa68f28ae4a Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 2 Apr 2026 16:04:23 +0000 Subject: [PATCH 44/89] :recycle: Convert viewport-scrollbars to modern * component format Convert viewport-scrollbars to viewport-scrollbars* using {:keys [...]} destructuring and update call sites in viewport.cljs and viewport_wasm.cljs to use [:> ...]. --- frontend/src/app/main/ui/workspace/viewport.cljs | 2 +- frontend/src/app/main/ui/workspace/viewport/scroll_bars.cljs | 2 +- frontend/src/app/main/ui/workspace/viewport_wasm.cljs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index 40f4344d29..7d1b778466 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -726,7 +726,7 @@ :view-only true}]))] [:g.scrollbar-wrapper {:clipPath "url(#clip-handlers)"} - [:& scroll-bars/viewport-scrollbars + [:> scroll-bars/viewport-scrollbars* {:objects base-objects :zoom zoom :vbox vbox diff --git a/frontend/src/app/main/ui/workspace/viewport/scroll_bars.cljs b/frontend/src/app/main/ui/workspace/viewport/scroll_bars.cljs index 87ee8d3656..3bf3e0a0e0 100644 --- a/frontend/src/app/main/ui/workspace/viewport/scroll_bars.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/scroll_bars.cljs @@ -26,7 +26,7 @@ (def other-height 100) -(mf/defc viewport-scrollbars +(mf/defc viewport-scrollbars* {::mf/wrap [mf/memo]} [{:keys [objects zoom vbox bottom-padding]}] diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 09b7fa6282..724c165c21 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -777,7 +777,7 @@ :view-only (not show-grid-editor?)}])] [:g.scrollbar-wrapper {:clipPath "url(#clip-handlers)"} - [:& scroll-bars/viewport-scrollbars + [:> scroll-bars/viewport-scrollbars* {:objects base-objects :zoom zoom :vbox vbox From bf7c12ae7516c533f423855915c9c92d67df0b67 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 2 Apr 2026 16:14:26 +0000 Subject: [PATCH 45/89] :recycle: Convert coordinates to modern * component format Convert coordinates to coordinates* using {:keys [...]} destructuring and rename prop colorpalette? to is-colorpalette. Update call site in workspace.cljs to use [:> ...] with new prop name. --- frontend/src/app/main/ui/workspace.cljs | 2 +- frontend/src/app/main/ui/workspace/coordinates.cljs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index 0d5ec76847..c0258ccb16 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -96,7 +96,7 @@ [:section {:class (stl/css :workspace-viewport)} (when (dbg/enabled? :coordinates) - [:& coordinates/coordinates {:colorpalette? colorpalette?}]) + [:> coordinates/coordinates* {:is-colorpalette colorpalette?}]) (when (dbg/enabled? :history-overlay) [:div {:class (stl/css :history-debug-overlay)} diff --git a/frontend/src/app/main/ui/workspace/coordinates.cljs b/frontend/src/app/main/ui/workspace/coordinates.cljs index 5ad5dfc572..05de77500a 100644 --- a/frontend/src/app/main/ui/workspace/coordinates.cljs +++ b/frontend/src/app/main/ui/workspace/coordinates.cljs @@ -11,10 +11,10 @@ [app.main.ui.hooks :as hooks] [rumext.v2 :as mf])) -(mf/defc coordinates - [{:keys [colorpalette?]}] +(mf/defc coordinates* + [{:keys [is-colorpalette]}] (let [coords (hooks/use-rxsub ms/mouse-position)] - [:div {:class (stl/css-case :container-color-palette-open colorpalette? + [:div {:class (stl/css-case :container-color-palette-open is-colorpalette :container true)} [:span {:alt "x" :class (stl/css :coordinate)} (str "X: " (:x coords "-"))] From fbee875d75efcd65f35744d97770d08cc2ea2768 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 2 Apr 2026 16:14:33 +0000 Subject: [PATCH 46/89] :recycle: Convert active-sessions to modern * component format Convert active-sessions to active-sessions* (zero-prop component). Update call site in right_header.cljs to use [:> ...] and update the :refer import accordingly. --- frontend/src/app/main/ui/workspace/presence.cljs | 2 +- frontend/src/app/main/ui/workspace/right_header.cljs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/presence.cljs b/frontend/src/app/main/ui/workspace/presence.cljs index 38302ed536..d7bcda046f 100644 --- a/frontend/src/app/main/ui/workspace/presence.cljs +++ b/frontend/src/app/main/ui/workspace/presence.cljs @@ -29,7 +29,7 @@ :style {:background-color color} :src (cfg/resolve-profile-photo-url profile)}]])) -(mf/defc active-sessions +(mf/defc active-sessions* {::mf/memo true} [] (let [profiles (mf/deref refs/profiles) diff --git a/frontend/src/app/main/ui/workspace/right_header.cljs b/frontend/src/app/main/ui/workspace/right_header.cljs index ce10de99cf..c73b13e4eb 100644 --- a/frontend/src/app/main/ui/workspace/right_header.cljs +++ b/frontend/src/app/main/ui/workspace/right_header.cljs @@ -25,7 +25,7 @@ [app.main.ui.exports.assets :refer [progress-widget]] [app.main.ui.formats :as fmt] [app.main.ui.icons :as deprecated-icon] - [app.main.ui.workspace.presence :refer [active-sessions]] + [app.main.ui.workspace.presence :refer [active-sessions*]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [okulary.core :as l] @@ -198,7 +198,7 @@ [:div {:class (stl/css :workspace-header-right)} [:div {:class (stl/css :users-section)} - [:& active-sessions]] + [:> active-sessions*]] [:& progress-widget] From 90d052464f7711242a6c6a260a20f5ecb17ce1b4 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 2 Apr 2026 16:14:40 +0000 Subject: [PATCH 47/89] :recycle: Convert text-palette components to modern * format Convert typography-item, palette and text-palette to typography-item*, palette* and text-palette* using {:keys [...]} destructuring. Rename prop name-only? to is-name-only in typography-item*. Update internal call sites to [:> ...] and update the :refer import in palette.cljs. --- .../src/app/main/ui/workspace/palette.cljs | 8 ++--- .../app/main/ui/workspace/text_palette.cljs | 35 +++++++++---------- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/palette.cljs b/frontend/src/app/main/ui/workspace/palette.cljs index 80c396989e..74f5d6901f 100644 --- a/frontend/src/app/main/ui/workspace/palette.cljs +++ b/frontend/src/app/main/ui/workspace/palette.cljs @@ -23,7 +23,7 @@ [app.main.ui.icons :as deprecated-icon] [app.main.ui.workspace.color-palette :refer [color-palette*]] [app.main.ui.workspace.color-palette-ctx-menu :refer [color-palette-ctx-menu*]] - [app.main.ui.workspace.text-palette :refer [text-palette]] + [app.main.ui.workspace.text-palette :refer [text-palette*]] [app.main.ui.workspace.text-palette-ctx-menu :refer [text-palette-ctx-menu]] [app.util.dom :as dom] [app.util.i18n :refer [tr]] @@ -207,9 +207,9 @@ :close-menu on-close-menu :on-select-palette on-select-text-palette-menu :selected selected-text}] - [:& text-palette {:size size - :selected selected-text - :width vport-width}]]) + [:> text-palette* {:size size + :selected selected-text + :width vport-width}]]) (when color-palette? [:* [:> color-palette-ctx-menu* {:show show-menu? diff --git a/frontend/src/app/main/ui/workspace/text_palette.cljs b/frontend/src/app/main/ui/workspace/text_palette.cljs index 5325102a05..1e20f48bcf 100644 --- a/frontend/src/app/main/ui/workspace/text_palette.cljs +++ b/frontend/src/app/main/ui/workspace/text_palette.cljs @@ -22,8 +22,9 @@ [potok.v2.core :as ptk] [rumext.v2 :as mf])) -(mf/defc typography-item - [{:keys [file-id selected-ids typography name-only? size current-file-id]}] +(mf/defc typography-item* + {::mf/private true} + [{:keys [file-id selected-ids typography size current-file-id]}] (let [font-data (f/get-font-data (:font-id typography)) font-variant-id (:font-variant-id typography) variant-data (->> font-data :variants (d/seek #(= (:id %) font-variant-id))) @@ -60,14 +61,12 @@ :font-weight (:font-weight typography) :font-style (:font-style typography)}} (:name typography)] - (when-not name-only? - [:* - [:div {:class (stl/css :typography-font)} - (:name font-data)] - [:div {:class (stl/css :typography-data)} - (str (:font-size typography) "px | " (:name variant-data))]])])) + [:div {:class (stl/css :typography-font)} + (:name font-data)] + [:div {:class (stl/css :typography-data)} + (str (:font-size typography) "px | " (:name variant-data))]])) -(mf/defc palette +(mf/defc palette* [{:keys [selected selected-ids current-file-id file-typographies libraries size width]}] (let [file-id (case selected @@ -165,7 +164,7 @@ :max-width (str width "px") :right (str (* offset-step offset) "px")}} (for [[idx item] (map-indexed vector current-typographies)] - [:& typography-item + [:> typography-item* {:key idx :file-id file-id :current-file-id current-file-id @@ -178,7 +177,7 @@ :disabled (= offset max-offset) :on-click on-right-arrow-click} deprecated-icon/arrow])])) -(mf/defc text-palette +(mf/defc text-palette* {::mf/wrap [mf/memo]} [{:keys [size width selected] :as props}] (let [selected-ids (mf/deref refs/selected-shapes) @@ -189,10 +188,10 @@ file-typographies (mf/deref refs/workspace-file-typography) libraries (mf/deref refs/files) current-file-id (mf/use-ctx ctx/current-file-id)] - [:& palette {:current-file-id current-file-id - :selected-ids selected-ids - :file-typographies file-typographies - :libraries libraries - :width width - :selected selected - :size size}])) + [:> palette* {:current-file-id current-file-id + :selected-ids selected-ids + :file-typographies file-typographies + :libraries libraries + :width width + :selected selected + :size size}])) From 628ce604c5fbdb34cee19002b0511982751cd724 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 2 Apr 2026 16:26:32 +0000 Subject: [PATCH 48/89] :recycle: Convert colorpicker and its sub-components to modern rumext * format slider-selector (slider_selector.cljs): - Rename to slider-selector* - Rename prop vertical? to is-vertical - Remove prop reverse? entirely: it was never passed by any callsite, so the related reversal logic in calculate-pos and handler positioning is also removed as dead code value-saturation-selector (ramp.cljs): - Rename to value-saturation-selector* - Update internal call site to [:> value-saturation-selector* ...] - Update slider-selector call sites to [:> slider-selector* ...] harmony-selector (harmony.cljs): - Rename to harmony-selector* - Update slider-selector call sites to [:> slider-selector* ...] with renamed is-vertical prop - Remove stale duplicate :vertical true prop - Fix spurious extra wrapping vector around the opacity slider in the when branch hsva-selector (hsva.cljs): - Rename to hsva-selector* - Update all four slider-selector call sites to [:> slider-selector* ...] - Remove no-op :reverse? false prop from the value slider color-inputs (color_inputs.cljs): - Rename to color-inputs* colorpicker.cljs: - Update :refer imports for color-inputs*, harmony-selector*, hsva-selector* and libraries* - Update all corresponding call sites from [:& ...] to [:> ...] --- .../app/main/ui/workspace/colorpicker.cljs | 42 +++++++++---------- .../workspace/colorpicker/color_inputs.cljs | 2 +- .../ui/workspace/colorpicker/harmony.cljs | 27 ++++++------ .../main/ui/workspace/colorpicker/hsva.cljs | 13 +++--- .../ui/workspace/colorpicker/libraries.cljs | 2 +- .../main/ui/workspace/colorpicker/ramp.cljs | 30 ++++++------- .../colorpicker/slider_selector.cljs | 17 +++----- 7 files changed, 61 insertions(+), 72 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/colorpicker.cljs b/frontend/src/app/main/ui/workspace/colorpicker.cljs index 3e930e9f81..c15f5d57ea 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker.cljs @@ -33,12 +33,12 @@ [app.main.ui.ds.layout.tab-switcher :refer [tab-switcher*]] [app.main.ui.hooks :as hooks] [app.main.ui.icons :as deprecated-icon] - [app.main.ui.workspace.colorpicker.color-inputs :refer [color-inputs]] + [app.main.ui.workspace.colorpicker.color-inputs :refer [color-inputs*]] [app.main.ui.workspace.colorpicker.color-tokens :refer [token-section*]] [app.main.ui.workspace.colorpicker.gradients :refer [gradients*]] - [app.main.ui.workspace.colorpicker.harmony :refer [harmony-selector]] - [app.main.ui.workspace.colorpicker.hsva :refer [hsva-selector]] - [app.main.ui.workspace.colorpicker.libraries :refer [libraries]] + [app.main.ui.workspace.colorpicker.harmony :refer [harmony-selector*]] + [app.main.ui.workspace.colorpicker.hsva :refer [hsva-selector*]] + [app.main.ui.workspace.colorpicker.libraries :refer [libraries*]] [app.main.ui.workspace.colorpicker.ramp :refer [ramp-selector*]] [app.main.ui.workspace.colorpicker.shortcuts :as sc] [app.util.dom :as dom] @@ -93,7 +93,7 @@ (dom/set-css-property! node "--saturation-grad-from" (format-hsl hsl-from)) (dom/set-css-property! node "--saturation-grad-to" (format-hsl hsl-to))))) -(mf/defc colorpicker +(mf/defc colorpicker* [{:keys [data disable-gradient disable-opacity disable-image on-change on-accept origin combined-tokens color-origin on-token-change tab applied-token]}] (let [state (mf/deref refs/colorpicker) node-ref (mf/use-ref) @@ -511,27 +511,27 @@ :on-finish-drag on-finish-drag}] "harmony" - [:& harmony-selector + [:> harmony-selector* {:color current-color :disable-opacity disable-opacity :on-change handle-change-color :on-start-drag on-start-drag}] "hsva" - [:& hsva-selector + [:> hsva-selector* {:color current-color :disable-opacity disable-opacity :on-change handle-change-color :on-start-drag on-start-drag :on-finish-drag on-finish-drag}]))]] - [:& color-inputs + [:> color-inputs* {:type type :disable-opacity disable-opacity :color current-color :on-change handle-change-color}] - [:& libraries + [:> libraries* {:state state :current-color current-color :disable-gradient disable-gradient @@ -786,15 +786,15 @@ :data-testid "colorpicker" :style style} - [:& colorpicker {:data data - :combined-tokens grouped-tokens-by-set - :disable-gradient disable-gradient - :disable-opacity disable-opacity - :disable-image disable-image - :on-token-change on-token-change - :applied-token applied-token - :on-change on-change' - :origin origin - :tab tab - :color-origin color-origin - :on-accept on-accept}]])) + [:> colorpicker* {:data data + :combined-tokens grouped-tokens-by-set + :disable-gradient disable-gradient + :disable-opacity disable-opacity + :disable-image disable-image + :on-token-change on-token-change + :applied-token applied-token + :on-change on-change' + :origin origin + :tab tab + :color-origin color-origin + :on-accept on-accept}]])) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.cljs b/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.cljs index fc384cdfdd..09ad2d0e8c 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.cljs @@ -28,7 +28,7 @@ [val] (* (/ val 255) 100)) -(mf/defc color-inputs [{:keys [type color disable-opacity on-change]}] +(mf/defc color-inputs* [{:keys [type color disable-opacity on-change]}] (let [{red :r green :g blue :b hue :h saturation :s value :v hex :hex alpha :alpha} color diff --git a/frontend/src/app/main/ui/workspace/colorpicker/harmony.cljs b/frontend/src/app/main/ui/workspace/colorpicker/harmony.cljs index c043899551..393e89df69 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/harmony.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/harmony.cljs @@ -11,7 +11,7 @@ [app.common.geom.point :as gpt] [app.common.math :as mth] [app.common.types.color :as cc] - [app.main.ui.workspace.colorpicker.slider-selector :refer [slider-selector]] + [app.main.ui.workspace.colorpicker.slider-selector :refer [slider-selector*]] [app.util.dom :as dom] [app.util.object :as obj] [cuerdas.core :as str] @@ -58,7 +58,7 @@ y (+ (/ canvas-side 2) (* comp-y (/ canvas-side 2)))] (gpt/point x y))) -(mf/defc harmony-selector [{:keys [color disable-opacity on-change on-start-drag on-finish-drag]}] +(mf/defc harmony-selector* [{:keys [color disable-opacity on-change on-start-drag on-finish-drag]}] (let [canvas-ref (mf/use-ref nil) canvas-side 192 {hue :h saturation :s value :v alpha :alpha} color @@ -134,24 +134,21 @@ :style {"--hue-from" (dm/str "hsl(" h1 ", " (* s1 100) "%, " (* l1 100) "%)") "--hue-to" (dm/str "hsl(" h2 ", " (* s2 100) "%, " (* l2 100) "%)")}} [:div {:class (stl/css :handlers-wrapper)} - [:& slider-selector {:type :value - :vertical? true - :reverse? false - :value value - :max-value 255 - :vertical true - :on-change on-change-value - :on-start-drag on-start-drag - :on-finish-drag on-finish-drag}] + [:> slider-selector* {:type :value + :is-vertical true + :value value + :max-value 255 + :on-change on-change-value + :on-start-drag on-start-drag + :on-finish-drag on-finish-drag}] (when (not disable-opacity) - [[:& slider-selector {:type :opacity - :vertical? true + [:> slider-selector* {:type :opacity + :is-vertical true :value alpha :max-value 1 - :vertical true :on-change on-change-opacity :on-start-drag on-start-drag - :on-finish-drag on-finish-drag}]])] + :on-finish-drag on-finish-drag}])] [:div {:class (stl/css :hue-wheel-wrapper)} [:canvas {:class (stl/css :hue-wheel) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/hsva.cljs b/frontend/src/app/main/ui/workspace/colorpicker/hsva.cljs index 9a7d240f55..807d976314 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/hsva.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/hsva.cljs @@ -8,10 +8,10 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.types.color :as cc] - [app.main.ui.workspace.colorpicker.slider-selector :refer [slider-selector]] + [app.main.ui.workspace.colorpicker.slider-selector :refer [slider-selector*]] [rumext.v2 :as mf])) -(mf/defc hsva-selector [{:keys [color disable-opacity on-change on-start-drag on-finish-drag]}] +(mf/defc hsva-selector* [{:keys [color disable-opacity on-change on-start-drag on-finish-drag]}] (let [{hue :h saturation :s value :v alpha :alpha} color handle-change-slider (fn [key] (fn [new-value] @@ -26,7 +26,7 @@ [:div {:class (stl/css :hsva-selector)} [:div {:class (stl/css :hsva-row)} [:span {:class (stl/css :hsva-selector-label)} "H"] - [:& slider-selector + [:> slider-selector* {:class (stl/css :hsva-bar) :type :hue :max-value 360 @@ -36,7 +36,7 @@ :on-finish-drag on-finish-drag}]] [:div {:class (stl/css :hsva-row)} [:span {:class (stl/css :hsva-selector-label)} "S"] - [:& slider-selector + [:> slider-selector* {:class (stl/css :hsva-bar) :type :saturation :max-value 1 @@ -46,10 +46,9 @@ :on-finish-drag on-finish-drag}]] [:div {:class (stl/css :hsva-row)} [:span {:class (stl/css :hsva-selector-label)} "V"] - [:& slider-selector + [:> slider-selector* {:class (stl/css :hsva-bar) :type :value - :reverse? false :max-value 255 :value value :on-change (handle-change-slider :v) @@ -58,7 +57,7 @@ (when (not disable-opacity) [:div {:class (stl/css :hsva-row)} [:span {:class (stl/css :hsva-selector-label)} "A"] - [:& slider-selector + [:> slider-selector* {:class (stl/css :hsva-bar) :type :opacity :max-value 1 diff --git a/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs b/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs index e565a05754..baf82c0f79 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs @@ -27,7 +27,7 @@ [potok.v2.core :as ptk] [rumext.v2 :as mf])) -(mf/defc libraries +(mf/defc libraries* [{:keys [state on-select-color on-add-library-color disable-gradient disable-opacity disable-image]}] (let [selected* (h/use-shared-state mdc/colorpicker-selected-broadcast-key :recent) selected (deref selected*) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/ramp.cljs b/frontend/src/app/main/ui/workspace/colorpicker/ramp.cljs index 154587fc7b..68eab222cd 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/ramp.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/ramp.cljs @@ -11,11 +11,11 @@ [app.common.math :as mth] [app.common.types.color :as cc] [app.main.ui.components.color-bullet :as cb] - [app.main.ui.workspace.colorpicker.slider-selector :refer [slider-selector]] + [app.main.ui.workspace.colorpicker.slider-selector :refer [slider-selector*]] [app.util.dom :as dom] [rumext.v2 :as mf])) -(mf/defc value-saturation-selector [{:keys [saturation value on-change on-start-drag on-finish-drag]}] +(mf/defc value-saturation-selector* [{:keys [saturation value on-change on-start-drag on-finish-drag]}] (let [dragging?* (mf/use-state false) dragging? (deref dragging?*) calculate-pos @@ -127,7 +127,7 @@ (reset! internal-color* (enrich-color-map color)))) [:* - [:& value-saturation-selector + [:> value-saturation-selector* {:hue h :saturation s :value v @@ -140,17 +140,17 @@ [:& cb/color-bullet {:color bullet-color :area true}] [:div {:class (stl/css :sliders-wrapper)} - [:& slider-selector {:type :hue - :max-value 360 - :value h - :on-change on-change-hue - :on-start-drag on-start-drag - :on-finish-drag on-finish-drag}] + [:> slider-selector* {:type :hue + :max-value 360 + :value h + :on-change on-change-hue + :on-start-drag on-start-drag + :on-finish-drag on-finish-drag}] (when (not disable-opacity) - [:& slider-selector {:type :opacity - :max-value 1 - :value alpha - :on-change on-change-opacity - :on-start-drag on-start-drag - :on-finish-drag on-finish-drag}])]]])) + [:> slider-selector* {:type :opacity + :max-value 1 + :value alpha + :on-change on-change-opacity + :on-start-drag on-start-drag + :on-finish-drag on-finish-drag}])]]])) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs b/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs index c69acfd703..f125b6368b 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs @@ -13,8 +13,8 @@ [app.util.object :as obj] [rumext.v2 :as mf])) -(mf/defc slider-selector - [{:keys [value class min-value max-value vertical? reverse? on-change on-start-drag on-finish-drag type]}] +(mf/defc slider-selector* + [{:keys [value class min-value max-value is-vertical on-change on-start-drag on-finish-drag type]}] (let [min-value (or min-value 0) max-value (or max-value 1) dragging? (mf/use-state false) @@ -42,17 +42,14 @@ (when on-change (let [{:keys [left right top bottom]} (-> ev dom/get-target dom/get-bounding-rect) {:keys [x y]} (-> ev dom/get-client-position) - unit-value (if vertical? + unit-value (if is-vertical (mth/clamp (/ (- bottom y) (- bottom top)) 0 1) (mth/clamp (/ (- x left) (- right left)) 0 1)) - unit-value (if reverse? - (mth/abs (- unit-value 1.0)) - unit-value) value (+ min-value (* unit-value (- max-value min-value)))] (on-change value))))] - [:div {:class (dm/str class (stl/css-case :vertical vertical? + [:div {:class (dm/str class (stl/css-case :vertical is-vertical :slider-selector true :hue (= type :hue) :opacity (= type :opacity) @@ -65,14 +62,10 @@ :on-pointer-move #(when @dragging? (calculate-pos %))} (let [value-percent (* (/ (- value min-value) (- max-value min-value)) 100) - - value-percent (if reverse? - (mth/abs (- value-percent 100)) - value-percent) value-percent-str (str value-percent "%") style-common #js {:pointerEvents "none"} style-horizontal (obj/merge! #js {:left value-percent-str} style-common) style-vertical (obj/merge! #js {:bottom value-percent-str} style-common)] [:div {:class (stl/css :handler) - :style (if vertical? style-vertical style-horizontal)}])])) + :style (if is-vertical style-vertical style-horizontal)}])])) From e8547ab6dd1defe65ec553c56fe3a689aebf04ff Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 18:43:17 +0000 Subject: [PATCH 49/89] :bug: Pass on-finish-drag to harmony-selector in colorpicker Without this, dragging the value/opacity sliders in the harmony tab never called on-finish-drag, so the undo transaction started by on-start-drag was never committed. --- frontend/src/app/main/ui/workspace/colorpicker.cljs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/workspace/colorpicker.cljs b/frontend/src/app/main/ui/workspace/colorpicker.cljs index c15f5d57ea..d6d4300848 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker.cljs @@ -515,7 +515,8 @@ {:color current-color :disable-opacity disable-opacity :on-change handle-change-color - :on-start-drag on-start-drag}] + :on-start-drag on-start-drag + :on-finish-drag on-finish-drag}] "hsva" [:> hsva-selector* From 8f30a95ca02baf4494d287cd9c728d6229a7880d Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 18:43:45 +0000 Subject: [PATCH 50/89] :bug: Guard against nil variant-data in typography-item When d/seek finds no matching font variant (e.g. the variant-id stored on the typography no longer exists in the font's variants list), variant-data is nil and (:name nil) produces nil, resulting in a malformed label like "14px | ". Fall back to "--" in that case. --- frontend/src/app/main/ui/workspace/text_palette.cljs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/workspace/text_palette.cljs b/frontend/src/app/main/ui/workspace/text_palette.cljs index 1e20f48bcf..7599a1baed 100644 --- a/frontend/src/app/main/ui/workspace/text_palette.cljs +++ b/frontend/src/app/main/ui/workspace/text_palette.cljs @@ -64,7 +64,7 @@ [:div {:class (stl/css :typography-font)} (:name font-data)] [:div {:class (stl/css :typography-data)} - (str (:font-size typography) "px | " (:name variant-data))]])) + (str (:font-size typography) "px | " (or (:name variant-data) "--"))]])) (mf/defc palette* [{:keys [selected selected-ids current-file-id file-typographies libraries size width]}] From 6fd264051af3a2ca3e496ea79a092bf08c811657 Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Mon, 13 Apr 2026 14:43:52 +0200 Subject: [PATCH 51/89] :bug: Fix v2/v3 wrong styling --- frontend/src/app/main/data/workspace/texts.cljs | 3 ++- frontend/src/app/render_wasm/api.cljs | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 6ba7cc903b..dde3e88322 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -811,7 +811,8 @@ (rx/of (update-text-attrs {:id id :attrs attrs})) (rx/empty))) - (when (features/active-feature? state "text-editor/v2") + (when (and (features/active-feature? state "text-editor/v2") + (not (features/active-feature? state "text-editor-wasm/v1"))) (rx/of (v2-update-text-editor-styles id attrs))) (when (features/active-feature? state "render-wasm/v1") diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 3d8fe0d0df..da5ce35e72 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -307,8 +307,9 @@ "Apply style attrs to the currently selected text spans. Updates the cached content, pushes to WASM, and returns {:shape-id :content} for saving." [attrs] - (text-editor/apply-styles-to-selection attrs use-shape set-shape-text-content) - (request-render "apply-styles-to-selection")) + (let [result (text-editor/apply-styles-to-selection attrs use-shape set-shape-text-content)] + (request-render "apply-styles-to-selection") + result)) (defn set-parent-id [id] From 77b4d07d1f18b5c4ee8100e0a4f5ea39db057ec7 Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Mon, 13 Apr 2026 15:50:31 +0200 Subject: [PATCH 52/89] :bug: Fix v3 text styles not being applied when inc/dec value --- .../main/ui/workspace/sidebar/options/menus/text.cljs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs index 7cc34e5713..daf8f09e7e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs @@ -14,6 +14,7 @@ [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.shortcuts :as sc] [app.main.data.workspace.texts :as dwt] + [app.main.data.workspace.texts-v3 :as dwt-v3] [app.main.data.workspace.undo :as dwu] [app.main.data.workspace.wasm-text :as dwwt] [app.main.features :as features] @@ -233,9 +234,12 @@ (mf/use-fn (mf/deps values) (fn [ids attrs] - (st/emit! (dwt/save-font (-> (merge (txt/get-default-text-attrs) values attrs) - (select-keys txt/text-node-attrs))) - (dwt/update-all-attrs ids attrs)))) + (let [updated-attrs (-> (merge (txt/get-default-text-attrs) values attrs) + (select-keys txt/text-node-attrs))] + (when (features/active-feature? @st/state "text-editor-wasm/v1") + (st/emit! (dwt-v3/v3-update-text-editor-styles (first ids) attrs))) + (st/emit! (dwt/save-font updated-attrs) + (dwt/update-all-attrs ids attrs))))) on-change (mf/use-fn From 424b689dcac88d61e633fa675dccbd96993f9266 Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Wed, 15 Apr 2026 12:07:52 +0200 Subject: [PATCH 53/89] :bug: Fix mixed fills issues --- .../src/app/main/data/workspace/texts.cljs | 22 +++--- frontend/src/app/render_wasm/text_editor.cljs | 72 +++++++++++-------- render-wasm/src/state/text_editor.rs | 21 +++--- render-wasm/src/wasm/text_editor.rs | 65 +++++++++-------- 4 files changed, 95 insertions(+), 85 deletions(-) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index dde3e88322..451b4fb8ae 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -503,13 +503,9 @@ ptk/WatchEvent (watch [_ state _] (when (or - (and (features/active-feature? state "text-editor-wasm/v1") - (nil? (get-in state [:workspace-wasm-editor-styles id]))) (and (features/active-feature? state "text-editor/v2") - (not (features/active-feature? state "text-editor-wasm/v1")) (nil? (:workspace-editor state))) (and (not (features/active-feature? state "text-editor/v2")) - (not (features/active-feature? state "text-editor-wasm/v1")) (nil? (get-in state [:workspace-editor-state id])))) (let [page-id (or (get options :page-id) (get state :current-page-id)) @@ -533,16 +529,20 @@ (-> shape (dissoc :fills) (d/update-when :content update-content)))] - (rx/of (dwsh/update-shapes shape-ids update-shape options))))) + + (rx/concat (rx/of (dwsh/update-shapes shape-ids update-shape options)) + (when (features/active-feature? state "text-editor-wasm/v1") + (let [styles ((comp update-node-fn migrate-node)) + result (wasm.api/apply-styles-to-selection styles)] + (when result + (rx/of (v2-update-text-shape-content + (:shape-id result) + (:content result) + :update-name? true))))))))) ptk/EffectEvent (effect [_ state _] - (cond - (features/active-feature? state "text-editor-wasm/v1") - (let [styles ((comp update-node-fn migrate-node))] - (wasm.api/apply-styles-to-selection styles)) - - (features/active-feature? state "text-editor/v2") + (when (features/active-feature? state "text-editor/v2") (when-let [instance (:workspace-editor state)] (let [styles (some-> (editor.v2/getCurrentStyle instance) (styles/get-styles-from-style-declaration :removed-mixed true) diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index cadbd72d31..1cfb5b834c 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -15,7 +15,7 @@ [app.render-wasm.mem :as mem] [app.render-wasm.wasm :as wasm])) -(def ^:const TEXT_EDITOR_STYLES_METADATA_SIZE (* 30 4)) +(def ^:const TEXT_EDITOR_STYLES_METADATA_SIZE (* 31 4)) (def ^:const TEXT_EDITOR_STYLES_FILL_SOLID 0) (def ^:const TEXT_EDITOR_STYLES_FILL_LINEAR_GRADIENT 1) (def ^:const TEXT_EDITOR_STYLES_FILL_RADIAL_GRADIENT 2) @@ -261,22 +261,23 @@ line-height-state (aget heap-u32 (+ u32-offset 9)) letter-spacing-state (aget heap-u32 (+ u32-offset 10)) num-fills (aget heap-u32 (+ u32-offset 11)) + multiple-fills (aget heap-u32 (+ u32-offset 12)) - text-align-value (aget heap-u32 (+ u32-offset 12)) - text-direction-value (aget heap-u32 (+ u32-offset 13)) - text-decoration-value (aget heap-u32 (+ u32-offset 14)) - text-transform-value (aget heap-u32 (+ u32-offset 15)) - font-family-id-a (aget heap-u32 (+ u32-offset 16)) - font-family-id-b (aget heap-u32 (+ u32-offset 17)) - font-family-id-c (aget heap-u32 (+ u32-offset 18)) - font-family-id-d (aget heap-u32 (+ u32-offset 19)) + text-align-value (aget heap-u32 (+ u32-offset 13)) + text-direction-value (aget heap-u32 (+ u32-offset 14)) + text-decoration-value (aget heap-u32 (+ u32-offset 15)) + text-transform-value (aget heap-u32 (+ u32-offset 16)) + font-family-id-a (aget heap-u32 (+ u32-offset 17)) + font-family-id-b (aget heap-u32 (+ u32-offset 18)) + font-family-id-c (aget heap-u32 (+ u32-offset 19)) + font-family-id-d (aget heap-u32 (+ u32-offset 20)) font-family-id-value (uuid/from-unsigned-parts font-family-id-a font-family-id-b font-family-id-c font-family-id-d) - font-family-style-value (aget heap-u32 (+ u32-offset 20)) - _font-family-weight-value (aget heap-u32 (+ u32-offset 21)) - font-size-value (aget heap-f32 (+ u32-offset 22)) - font-weight-value (aget heap-i32 (+ u32-offset 23)) - line-height-value (aget heap-f32 (+ u32-offset 28)) - letter-spacing-value (aget heap-f32 (+ u32-offset 29)) + font-family-style-value (aget heap-u32 (+ u32-offset 21)) + _font-family-weight-value (aget heap-u32 (+ u32-offset 22)) + font-size-value (aget heap-f32 (+ u32-offset 23)) + font-weight-value (aget heap-i32 (+ u32-offset 24)) + line-height-value (aget heap-f32 (+ u32-offset 29)) + letter-spacing-value (aget heap-f32 (+ u32-offset 30)) font-id (fonts/uuid->font-id font-family-id-value) font-style-value (text-editor-translate-font-style (text-editor-get-style-property font-family-state font-family-style-value)) font-variant-id-computed (text-editor-compute-font-variant-id font-id font-weight-value font-style-value) @@ -291,6 +292,11 @@ (filter some?) (into [])) + ;; The order of these two variables is important, do not + ;; reorder them. + selected-colors (if (= multiple-fills 1) fills nil) + fills (if (= multiple-fills 1) :multiple fills) + result {:vertical-align (text-editor-translate-vertical-align vertical-align) :text-align (text-editor-translate-text-align (text-editor-get-style-property text-align-state text-align-value)) :text-direction (text-editor-translate-text-direction (text-editor-get-style-property text-direction-state text-direction-value)) @@ -306,6 +312,7 @@ :font-variant-id (text-editor-get-style-property font-variant-id-state font-variant-id-computed) :typography-ref-file nil :typography-ref-id nil + :selected-colors selected-colors :fills fills}] (mem/free) @@ -471,6 +478,19 @@ ;; This is used as a intermediate cache between Clojure global state and WASM state. (def ^:private shape-text-contents (atom {})) +(defn cache-shape-text-content! + [shape-id content] + (when (some? content) + (swap! shape-text-contents assoc shape-id content))) + +(defn get-cached-content + [shape-id] + (get @shape-text-contents shape-id)) + +(defn update-cached-content! + [shape-id content] + (swap! shape-text-contents assoc shape-id content)) + (defn- merge-exported-texts-into-content "Merge exported span texts back into the existing content tree. @@ -522,26 +542,13 @@ new-texts (text-editor-export-content)] (when (and shape-id new-texts) (let [texts-clj (js->clj new-texts) - content (get @shape-text-contents shape-id)] + content (get-cached-content shape-id)] (when content (let [merged (merge-exported-texts-into-content content texts-clj)] (swap! shape-text-contents assoc shape-id merged) {:shape-id shape-id :content merged}))))))) -(defn cache-shape-text-content! - [shape-id content] - (when (some? content) - (swap! shape-text-contents assoc shape-id content))) - -(defn get-cached-content - [shape-id] - (get @shape-text-contents shape-id)) - -(defn update-cached-content! - [shape-id content] - (swap! shape-text-contents assoc shape-id content)) - (defn- normalize-selection "Given anchor/focus para+offset, return {:start-para :start-offset :end-para :end-offset} ordered so start <= end." @@ -558,6 +565,7 @@ Splits spans at boundaries as needed." [para sel-start sel-end attrs] (let [spans (:children para) + result (loop [spans spans pos 0 acc []] @@ -594,7 +602,7 @@ selection (text-editor-get-selection)] (when (and shape-id selection) - (let [content (get @shape-text-contents shape-id)] + (let [content (get-cached-content shape-id)] (when content (let [normalized-selection (normalize-selection selection) {:keys [start-para start-offset end-para end-offset]} normalized-selection @@ -630,11 +638,13 @@ (range (count paragraphs)) paragraphs)) + new-content (when new-paragraphs (assoc content :children [(assoc paragraph-set :children new-paragraphs)]))] + (when new-content - (swap! shape-text-contents assoc shape-id new-content) + (update-cached-content! shape-id new-content) (use-shape-fn shape-id) (set-shape-text-content-fn shape-id new-content) {:shape-id shape-id diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index 82e7daf1ad..062e56864c 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -117,6 +117,7 @@ pub struct TextEditorStyles { pub font_variant_id: Multiple, pub line_height: Multiple, pub letter_spacing: Multiple, + pub fills_are_multiple: bool, pub fills: Vec, } @@ -233,6 +234,7 @@ impl TextEditorStyles { font_variant_id: Multiple::empty(), line_height: Multiple::empty(), letter_spacing: Multiple::empty(), + fills_are_multiple: false, fills: Vec::new(), } } @@ -248,6 +250,7 @@ impl TextEditorStyles { self.font_variant_id.reset(); self.line_height.reset(); self.letter_spacing.reset(); + self.fills_are_multiple = false; self.fills.clear(); } } @@ -529,11 +532,7 @@ impl TextEditorState { let end_paragraph = end.paragraph.min(paragraphs.len() - 1); self.current_styles.reset(); - let mut has_selected_content = false; - let mut has_fills = false; - let mut fills_are_multiple = false; - for (para_idx, paragraph) in paragraphs .iter() .enumerate() @@ -606,14 +605,11 @@ impl TextEditorState { .letter_spacing .merge(Some(span.letter_spacing)); - if !fills_are_multiple { - if !has_fills { - self.current_styles.fills = span.fills.clone(); - has_fills = true; - } else if self.current_styles.fills != span.fills { - fills_are_multiple = true; - self.current_styles.fills.clear(); - } + if self.current_styles.fills.is_empty() { + self.current_styles.fills.append(&mut span.fills.clone()); + } else if self.current_styles.fills != span.fills { + self.current_styles.fills_are_multiple = true; + self.current_styles.fills.append(&mut span.fills.clone()); } } } @@ -630,6 +626,7 @@ impl TextEditorState { let current_offset = focus.offset; let current_text_span = find_text_span_at_offset(current_paragraph, current_offset); + self.current_styles.reset(); self.current_styles .text_align .set_single(Some(current_paragraph.text_align())); diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 9e364e8fb7..dd145bab64 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -769,6 +769,7 @@ pub extern "C" fn text_editor_get_current_styles() -> *mut u8 { } let mut fill_bytes = Vec::new(); + let fill_multiple = styles.fills_are_multiple; let mut fill_count: u32 = 0; for fill in &styles.fills { if let Ok(raw_fill) = RawFillData::try_from(fill) { @@ -781,39 +782,41 @@ pub extern "C" fn text_editor_get_current_styles() -> *mut u8 { // Layout: 48-byte fixed header + fixed values + serialized fills. let mut bytes = Vec::with_capacity(132 + fill_bytes.len()); - bytes.extend_from_slice(&vertical_align.to_le_bytes()); - bytes.extend_from_slice(&(*styles.text_align.state() as u32).to_le_bytes()); - bytes.extend_from_slice(&(*styles.text_direction.state() as u32).to_le_bytes()); - bytes.extend_from_slice(&(*styles.text_decoration.state() as u32).to_le_bytes()); - bytes.extend_from_slice(&(*styles.text_transform.state() as u32).to_le_bytes()); - bytes.extend_from_slice(&(*styles.font_family.state() as u32).to_le_bytes()); - bytes.extend_from_slice(&(*styles.font_size.state() as u32).to_le_bytes()); - bytes.extend_from_slice(&(*styles.font_weight.state() as u32).to_le_bytes()); - bytes.extend_from_slice(&(*styles.font_variant_id.state() as u32).to_le_bytes()); - bytes.extend_from_slice(&(*styles.line_height.state() as u32).to_le_bytes()); - bytes.extend_from_slice(&(*styles.letter_spacing.state() as u32).to_le_bytes()); - bytes.extend_from_slice(&fill_count.to_le_bytes()); + // Header data // offset // index + bytes.extend_from_slice(&vertical_align.to_le_bytes()); // 0 // 0 + bytes.extend_from_slice(&(*styles.text_align.state() as u32).to_le_bytes()); // 4 // 1 + bytes.extend_from_slice(&(*styles.text_direction.state() as u32).to_le_bytes()); // 8 // 2 + bytes.extend_from_slice(&(*styles.text_decoration.state() as u32).to_le_bytes()); // 12 // 3 + bytes.extend_from_slice(&(*styles.text_transform.state() as u32).to_le_bytes()); // 16 // 4 + bytes.extend_from_slice(&(*styles.font_family.state() as u32).to_le_bytes()); // 20 // 5 + bytes.extend_from_slice(&(*styles.font_size.state() as u32).to_le_bytes()); // 24 // 6 + bytes.extend_from_slice(&(*styles.font_weight.state() as u32).to_le_bytes()); // 28 // 7 + bytes.extend_from_slice(&(*styles.font_variant_id.state() as u32).to_le_bytes()); // 32 // 8 + bytes.extend_from_slice(&(*styles.line_height.state() as u32).to_le_bytes()); // 36 // 9 + bytes.extend_from_slice(&(*styles.letter_spacing.state() as u32).to_le_bytes()); // 40 // 10 + bytes.extend_from_slice(&fill_count.to_le_bytes()); // 44 // 11 + bytes.extend_from_slice(&(fill_multiple as u32).to_le_bytes()); // 48 // 12 // Value section. - bytes.extend_from_slice(&text_align.to_le_bytes()); - bytes.extend_from_slice(&text_direction.to_le_bytes()); - bytes.extend_from_slice(&text_decoration.to_le_bytes()); - bytes.extend_from_slice(&text_transform.to_le_bytes()); - bytes.extend_from_slice(&font_family_id[0].to_le_bytes()); - bytes.extend_from_slice(&font_family_id[1].to_le_bytes()); - bytes.extend_from_slice(&font_family_id[2].to_le_bytes()); - bytes.extend_from_slice(&font_family_id[3].to_le_bytes()); - bytes.extend_from_slice(&font_family_style.to_le_bytes()); - bytes.extend_from_slice(&font_family_weight.to_le_bytes()); - bytes.extend_from_slice(&font_size.to_le_bytes()); - bytes.extend_from_slice(&font_weight.to_le_bytes()); - bytes.extend_from_slice(&font_variant_id[0].to_le_bytes()); - bytes.extend_from_slice(&font_variant_id[1].to_le_bytes()); - bytes.extend_from_slice(&font_variant_id[2].to_le_bytes()); - bytes.extend_from_slice(&font_variant_id[3].to_le_bytes()); - bytes.extend_from_slice(&line_height.to_le_bytes()); - bytes.extend_from_slice(&letter_spacing.to_le_bytes()); - bytes.extend_from_slice(&fill_bytes); + bytes.extend_from_slice(&text_align.to_le_bytes()); // 52 // 13 + bytes.extend_from_slice(&text_direction.to_le_bytes()); // 56 // 14 + bytes.extend_from_slice(&text_decoration.to_le_bytes()); // 60 // 15 + bytes.extend_from_slice(&text_transform.to_le_bytes()); // 64 // 16 + bytes.extend_from_slice(&font_family_id[0].to_le_bytes()); // 68 // 17 + bytes.extend_from_slice(&font_family_id[1].to_le_bytes()); // 72 // 18 + bytes.extend_from_slice(&font_family_id[2].to_le_bytes()); // 76 // 19 + bytes.extend_from_slice(&font_family_id[3].to_le_bytes()); // 80 // 20 + bytes.extend_from_slice(&font_family_style.to_le_bytes()); // 84 // 21 + bytes.extend_from_slice(&font_family_weight.to_le_bytes()); // 88 // 22 + bytes.extend_from_slice(&font_size.to_le_bytes()); // 92 // 23 + bytes.extend_from_slice(&font_weight.to_le_bytes()); // 96 // 24 + bytes.extend_from_slice(&font_variant_id[0].to_le_bytes()); // 100 // 25 + bytes.extend_from_slice(&font_variant_id[1].to_le_bytes()); // 104 // 26 + bytes.extend_from_slice(&font_variant_id[2].to_le_bytes()); // 108 // 27 + bytes.extend_from_slice(&font_variant_id[3].to_le_bytes()); // 112 // 28 + bytes.extend_from_slice(&line_height.to_le_bytes()); // 116 // 29 + bytes.extend_from_slice(&letter_spacing.to_le_bytes()); // 120 // 30 + bytes.extend_from_slice(&fill_bytes); // 124 mem::write_bytes(bytes) }) From 3f5226485b600f1ea8d96102fd01efe7c38b64c4 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 8 Apr 2026 16:10:44 +0200 Subject: [PATCH 54/89] :bug: Fix problem when changing font and grow text --- .../src/app/main/data/workspace/texts.cljs | 25 +++++++++++++++---- frontend/src/app/render_wasm/api/fonts.cljs | 7 ++++-- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 451b4fb8ae..fe12f88804 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -400,7 +400,11 @@ shape-ids (cond (cfh/text-shape? shape) [id] (cfh/group-shape? shape) (cfh/get-children-ids objects id))] - (rx/of (dwsh/update-shapes shape-ids update-fn)))))) + (rx/concat + (rx/of (dwsh/update-shapes shape-ids update-fn)) + (if (features/active-feature? state "render-wasm/v1") + (dwwt/resize-wasm-text-debounce id) + (rx/empty))))))) (defn update-root-attrs [{:keys [id attrs]}] @@ -786,11 +790,18 @@ (rx/of (update-position-data id position-data)))) (rx/empty)))))) +(defn font-loaded-event? + [font-id] + (fn [event] + (and + (= :font-loaded (ptk/type event)) + (= (:font-id (deref event)) font-id)))) + (defn update-attrs [id attrs] (ptk/reify ::update-attrs ptk/WatchEvent - (watch [_ state _] + (watch [_ state stream] (let [text-editor-instance (:workspace-editor state)] (if (and (features/active-feature? state "text-editor/v2") (some? text-editor-instance)) @@ -828,9 +839,13 @@ (:shape-id result) (:content result) :update-name? true)))))))) ;; Resize (with delay for font-id changes) - (cond->> (rx/of (dwwt/resize-wasm-text id)) - (contains? attrs :font-id) - (rx/delay 200)))))))) + (if (contains? attrs :font-id) + (->> stream + (rx/filter (font-loaded-event? (:font-id attrs))) + (rx/take 1) + (rx/observe-on :async) + (rx/map #(dwwt/resize-wasm-text id))) + (rx/of (dwwt/resize-wasm-text id))))))))) ptk/EffectEvent (effect [_ state _] diff --git a/frontend/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs index 474a705979..3c011cd3db 100644 --- a/frontend/src/app/render_wasm/api/fonts.cljs +++ b/frontend/src/app/render_wasm/api/fonts.cljs @@ -21,7 +21,8 @@ [cuerdas.core :as str] [goog.object :as gobj] [lambdaisland.uri :as u] - [okulary.core :as l])) + [okulary.core :as l] + [potok.v2.core :as ptk])) (def ^:private fonts (l/derived :fonts st/state)) @@ -127,6 +128,7 @@ mem (js/Uint8Array. (.-buffer heap) ptr size)] (.set mem (js/Uint8Array. font-array-buffer)) + (st/emit! (ptk/data-event :font-loaded {:font-id (:font-id font-data)})) (h/call wasm/internal-module "_store_font" (aget font-id-buffer 0) (aget font-id-buffer 1) @@ -208,7 +210,8 @@ id-buffer (uuid/get-u32 (:wasm-id font-data)) font-data (assoc font-data :family-id-buffer id-buffer) font-stored? (font-stored? font-data emoji?)] - (when-not font-stored? + (if font-stored? + (st/async-emit! (ptk/data-event :font-loaded {:font-id (:font-id font-data)})) (fetch-font font-data uri emoji? fallback?))))) (defn serialize-font-style From e3bafab529eeca99131b1bf7ee3a98579a498ad7 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 8 Apr 2026 16:50:49 +0200 Subject: [PATCH 55/89] :bug: Fix problem with resizes in plugins --- frontend/src/app/main/data/workspace/wasm_text.cljs | 4 +++- frontend/src/app/plugins/text.cljs | 13 ++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs index c175b46bdf..e67cb77e97 100644 --- a/frontend/src/app/main/data/workspace/wasm_text.cljs +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -23,6 +23,8 @@ [beicon.v2.core :as rx] [potok.v2.core :as ptk])) +(def debounce-resize-text-time 40) + (defn get-wasm-text-new-size "Computes the new {width, height} for a text shape from WASM text layout. For :fixed grow-type, updates WASM content and returns current dimensions (no resize)." @@ -144,7 +146,7 @@ (rx/merge (->> stream (rx/filter (ptk/type? ::resize-wasm-text-debounce-inner)) - (rx/debounce 40) + (rx/debounce debounce-resize-text-time) (rx/take 1) (rx/map (fn [evt] (resize-wasm-text-debounce-commit diff --git a/frontend/src/app/plugins/text.cljs b/frontend/src/app/plugins/text.cljs index 1154af2366..aa4539c980 100644 --- a/frontend/src/app/plugins/text.cljs +++ b/frontend/src/app/plugins/text.cljs @@ -15,6 +15,8 @@ [app.common.types.text :as txt] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.texts :as dwt] + [app.main.data.workspace.wasm-text :as dwwt] + [app.main.features :as features] [app.main.fonts :as fonts] [app.main.store :as st] [app.plugins.format :as format] @@ -417,8 +419,10 @@ (st/emit! (dwt/update-editor-state shape editor))) :else - (st/emit! (dwsh/update-shapes [id] - #(update % :content txt/change-text value))))))} + (do + (st/emit! (dwsh/update-shapes [id] #(update % :content txt/change-text value))) + (when (features/active-feature? @st/state "render-wasm/v1") + (st/emit! (dwwt/resize-wasm-text-debounce id)))))))} {:name "growType" :get #(-> % u/proxy->shape :grow-type d/name) @@ -434,7 +438,10 @@ (u/not-valid plugin-id :growType "Plugin doesn't have 'content:write' permission") :else - (st/emit! (dwsh/update-shapes [id] #(assoc % :grow-type value))))))} + (st/emit! + (dwsh/update-shapes [id] #(assoc % :grow-type value)) + (when (features/active-feature? @st/state "render-wasm/v1") + (st/emit! (dwwt/resize-wasm-text-debounce id)))))))} {:name "fontId" :get #(-> % u/proxy->shape text-props :font-id format/format-mixed) From 4d2d5593835c96bf9b3c5499334a93c1e7221f75 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 15 Apr 2026 15:57:12 +0200 Subject: [PATCH 56/89] :bug: Fix problem with finish render callback --- frontend/src/app/render_wasm/api.cljs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index da5ce35e72..c23a872334 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -1120,11 +1120,13 @@ (if (or (seq pending-thumbnails) (seq pending-full)) (->> (rx/concat (->> (rx/from (vals pending-thumbnails)) - (rx/merge-map (fn [callback] (callback))) - (rx/reduce conj [])) + (rx/merge-map (fn [callback] (if (fn? callback) (callback) (rx/empty)))) + (rx/reduce conj []) + (rx/catch #(rx/empty))) (->> (rx/from (vals pending-full)) - (rx/mapcat (fn [callback] (callback))) - (rx/reduce conj []))) + (rx/mapcat (fn [callback] (if (fn? callback) (callback) (rx/empty)))) + (rx/reduce conj []) + (rx/catch #(rx/empty)))) (rx/subs! (fn [_] ;; Fonts are now loaded — recompute text layouts so Skia @@ -1134,7 +1136,7 @@ (update-text-layouts text-ids))) (request-render "images-loaded")) noop-fn - (fn [] (when on-complete (on-complete))))) + (fn [] (when (fn? on-complete) (on-complete))))) ;; No pending images — complete immediately. (when on-complete (on-complete))))) From 41bc8c9b9d6078c646e6cb1d167a9f25c6d4979c Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Wed, 15 Apr 2026 15:57:23 +0200 Subject: [PATCH 57/89] :bug: Fix masked shapes causing render cuts at tile boundaries --- render-wasm/src/render.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index aee90b6320..d9a5a5ba36 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -2454,6 +2454,7 @@ impl RenderState { let has_effects = transformed_element.has_effects_that_extend_bounds(); let is_visible = export + || mask || if is_container || has_effects { let element_extrect = extrect.get_or_insert_with(|| transformed_element.extrect(tree, scale)); From 1477758656b07408b0229671aeb36edfcfbd11b5 Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Wed, 15 Apr 2026 16:10:38 +0200 Subject: [PATCH 58/89] :bug: Fix pointer selection --- .../main/ui/workspace/viewport/actions.cljs | 18 ++---------------- render-wasm/src/state/text_editor.rs | 2 ++ render-wasm/src/wasm/text_editor.rs | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/viewport/actions.cljs b/frontend/src/app/main/ui/workspace/viewport/actions.cljs index f0711768b2..c3c26ab6a5 100644 --- a/frontend/src/app/main/ui/workspace/viewport/actions.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/actions.cljs @@ -23,7 +23,6 @@ [app.main.store :as st] [app.main.ui.workspace.sidebar.assets.components :as wsac] [app.main.ui.workspace.viewport.viewport-ref :as uwvv] - [app.render-wasm.api :as wasm.api] [app.util.dom :as dom] [app.util.dom.dnd :as dnd] [app.util.dom.normalize-wheel :as nw] @@ -280,7 +279,6 @@ (.releasePointerCapture target (.-pointerId event))) (let [native-event (dom/event->native-event event) - off-pt (dom/get-offset-position native-event) ctrl? (kbd/ctrl? native-event) shift? (kbd/shift? native-event) alt? (kbd/alt? native-event) @@ -290,10 +288,7 @@ middle-click? (= 2 (.-which native-event))] (when left-click? - (st/emit! (mse/->MouseEvent :up ctrl? shift? alt? meta?)) - - (when (wasm.api/text-editor-has-focus?) - (wasm.api/text-editor-pointer-up (.-x off-pt) (.-y off-pt)))) + (st/emit! (mse/->MouseEvent :up ctrl? shift? alt? meta?))) (when middle-click? (dom/prevent-default native-event) @@ -354,9 +349,7 @@ (let [last-position (mf/use-var nil)] (mf/use-fn (fn [event] - (let [native-event (unchecked-get event "nativeEvent") - off-pt (dom/get-offset-position native-event) - raw-pt (dom/get-client-position event) + (let [raw-pt (dom/get-client-position event) pt (uwvv/point->viewport raw-pt) ;; We calculate the delta because Safari's MouseEvent.movementX/Y drop @@ -365,13 +358,6 @@ (gpt/subtract raw-pt @last-position) (gpt/point 0 0))] - ;; IMPORTANT! This function, right now it's called on EVERY pointermove. I think - ;; in the future (when we handle the UI in the render) should be better to - ;; have a "wasm.api/pointer-move" function that works as an entry point for - ;; all the pointer-move events. - (when (wasm.api/text-editor-has-focus?) - (wasm.api/text-editor-pointer-move (.-x off-pt) (.-y off-pt))) - (rx/push! move-stream pt) (reset! last-position raw-pt) (st/emit! (mse/->PointerEvent :delta delta diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index 062e56864c..0f89a25d41 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -327,6 +327,7 @@ pub struct TextEditorState { // This property indicates that we've started // selecting something with the pointer. pub is_pointer_selection_active: bool, + pub is_click_event_skipped: bool, pub active_shape_id: Option, pub cursor_visible: bool, pub last_blink_time: f64, @@ -346,6 +347,7 @@ impl TextEditorState { composition: TextComposition::new(), has_focus: false, is_pointer_selection_active: false, + is_click_event_skipped: false, active_shape_id: None, cursor_visible: true, last_blink_time: 0.0, diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index dd145bab64..21c8566ec1 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -208,16 +208,20 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) { if !state.text_editor_state.has_focus { return; } + let point = Point::new(x, y); let Some(shape_id) = state.text_editor_state.active_shape_id else { return; }; + let Some(shape) = state.shapes.get(&shape_id) else { return; }; + if !state.text_editor_state.is_pointer_selection_active { return; } + let Type::Text(text_content) = &shape.shape_type else { return; }; @@ -226,6 +230,9 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) { state .text_editor_state .extend_selection_from_position(&position); + // We need this flag to prevent handling the click behavior + // just after a pointerup event. + state.text_editor_state.is_click_event_skipped = true; state.text_editor_state.update_styles(text_content); } }); @@ -263,6 +270,13 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) { #[no_mangle] pub extern "C" fn text_editor_set_cursor_from_offset(x: f32, y: f32) { with_state_mut!(state, { + // We need this flag to prevent handling the click behavior + // just after a pointerup event. + if state.text_editor_state.is_click_event_skipped { + state.text_editor_state.is_click_event_skipped = false; + return; + } + if !state.text_editor_state.has_focus { return; } @@ -271,12 +285,15 @@ pub extern "C" fn text_editor_set_cursor_from_offset(x: f32, y: f32) { let Some(shape_id) = state.text_editor_state.active_shape_id else { return; }; + let Some(shape) = state.shapes.get(&shape_id) else { return; }; + let Type::Text(text_content) = &shape.shape_type else { return; }; + if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { state.text_editor_state.set_caret_from_position(&position); } From f8f7a0828ecefc4db8d531226b02a63e9ae3dcda Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 15 Apr 2026 18:02:13 +0200 Subject: [PATCH 59/89] :sparkles: Add missing indexes on audit_log table --- backend/src/app/loggers/audit/archive_task.clj | 2 +- backend/src/app/migrations.clj | 3 +++ backend/src/app/migrations/sql/0145-mod-audit-log-table.sql | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 backend/src/app/migrations/sql/0145-mod-audit-log-table.sql diff --git a/backend/src/app/loggers/audit/archive_task.clj b/backend/src/app/loggers/audit/archive_task.clj index 62024e573b..1915652bbd 100644 --- a/backend/src/app/loggers/audit/archive_task.clj +++ b/backend/src/app/loggers/audit/archive_task.clj @@ -81,7 +81,7 @@ (def ^:private sql:get-audit-log-chunk "SELECT * FROM audit_log - WHERE archived_at is null + WHERE archived_at IS NULL ORDER BY created_at ASC LIMIT 128 FOR UPDATE diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index 4c9199a6f5..3464902128 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -465,6 +465,9 @@ {:name "0145-fix-plugins-uri-on-profile" :fn mg0145/migrate} + {:name "0145-mod-audit-log-table" + :fn (mg/resource "app/migrations/sql/0145-mod-audit-log-table.sql")} + {:name "0146-mod-access-token-table" :fn (mg/resource "app/migrations/sql/0146-mod-access-token-table.sql")}]) diff --git a/backend/src/app/migrations/sql/0145-mod-audit-log-table.sql b/backend/src/app/migrations/sql/0145-mod-audit-log-table.sql new file mode 100644 index 0000000000..6d95ecc6af --- /dev/null +++ b/backend/src/app/migrations/sql/0145-mod-audit-log-table.sql @@ -0,0 +1,2 @@ +CREATE INDEX audit_log__created_at__idx ON audit_log(created_at) WHERE archived_at IS NULL; +CREATE INDEX audit_log__archived_at__idx ON audit_log(archived_at) WHERE archived_at IS NOT NULL; From a7e362dbfe650aaed140613d4a9a128f65d13f4c Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 15 Apr 2026 18:03:11 +0200 Subject: [PATCH 60/89] :paperclip: Add commented helpers on backend _env for testing nexus --- backend/scripts/_env | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/scripts/_env b/backend/scripts/_env index f57c6121ec..a18cbc2896 100644 --- a/backend/scripts/_env +++ b/backend/scripts/_env @@ -45,6 +45,10 @@ export PENPOT_FLAGS="\ enable-redis-cache \ enable-subscriptions"; +# Uncomment for nexus integration testing +# export PENPOT_FLAGS="$PENPOT_FLAGS enable-audit-log-archive"; +# export PENPOT_AUDIT_LOG_ARCHIVE_URI="http://localhost:6070/api/audit"; + # Default deletion delay for devenv export PENPOT_DELETION_DELAY="24h" From f5271dabeed15990f9e6b9e68482c9c26ecf4496 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 15 Apr 2026 23:37:04 +0200 Subject: [PATCH 61/89] :bug: Fix error handling issues (#8962) * :ambulance: Fix RangeError from re-entrant error handling in errors.cljs Two complementary changes to prevent 'RangeError: Maximum call stack size exceeded' when an error fires while the potok store error pipeline is still on the call stack: 1. Re-entrancy guard on on-error: a volatile flag (handling-error?) is set true for the duration of each on-error invocation. Any nested call (e.g. from a notification emit that itself throws) is suppressed with a console.error instead of recursing indefinitely. 2. Async notification in flash: the st/emit!(ntf/show ...) call is now wrapped in ts/schedule (setTimeout 0) so the notification event is pushed to the store on the next event-loop tick, outside the error-handler call stack. This matches the pattern already used by the :worker-error, :svg-parser and :comment-error handlers. * :bug: Add unit tests for app.main.errors Test coverage for the error-handling module: - stale-asset-error?: 6 cases covering keyword-constant and protocol-dispatch mismatch signatures, plus negative cases - exception->error-data: plain JS Error, ex-info with/without :hint - on-error dispatch: map errors routed via ptk/handle-error, JS exceptions wrapped into error-data before dispatch - Re-entrancy guard: verifies that a second on-error call issued from within a handle-error method is suppressed (exactly one handler invocation) --------- Signed-off-by: Andrey Antukh --- frontend/src/app/main/errors.cljs | 48 +++++-- .../test/frontend_tests/main_errors_test.cljs | 136 ++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + 3 files changed, 175 insertions(+), 11 deletions(-) create mode 100644 frontend/test/frontend_tests/main_errors_test.cljs diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 223e916261..37177aec7d 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -33,6 +33,12 @@ ;; Will contain last uncaught exception (def last-exception nil) +;; Re-entrancy guard: prevents on-error from calling itself recursively. +;; If an error occurs while we are already handling an error (e.g. the +;; notification emit itself throws), we log it and bail out immediately +;; instead of recursing until the call-stack overflows. +(def ^:private handling-error? (volatile! false)) + ;; --- Stale-asset error detection and auto-reload ;; ;; When the browser loads JS modules from different builds (e.g. shared.js from @@ -80,12 +86,24 @@ (assoc ::trace (.-stack cause))))) (defn on-error - "A general purpose error handler." + "A general purpose error handler. + + Protected by a re-entrancy guard: if an error is raised while this + function is already on the call stack (e.g. the notification emit + itself fails), we print it to the console and return immediately + instead of recursing until the call-stack is exhausted." [error] - (if (map? error) - (ptk/handle-error error) - (let [data (exception->error-data error)] - (ptk/handle-error data)))) + (if @handling-error? + (.error js/console "[on-error] re-entrant call suppressed" error) + (do + (vreset! handling-error? true) + (try + (if (map? error) + (ptk/handle-error error) + (let [data (exception->error-data error)] + (ptk/handle-error data))) + (finally + (vreset! handling-error? false)))))) ;; Inject dependency to remove circular dependency (set! app.main.worker/on-error on-error) @@ -138,7 +156,14 @@ :report report})))) (defn flash - "Show error notification banner and emit error report" + "Show error notification banner and emit error report. + + The notification is scheduled asynchronously (via tm/schedule) to + avoid pushing a new event into the potok store while the store's own + error-handling pipeline is still on the call stack. Emitting + synchronously from inside an error handler creates a re-entrant + event-processing cycle that can exhaust the JS call stack + (RangeError: Maximum call stack size exceeded)." [& {:keys [type hint cause] :or {type :handled}}] (when (ex/exception? cause) (when-let [event-name (case type @@ -150,11 +175,12 @@ :report report :hint (ex/get-hint cause))))) - (st/emit! - (ntf/show {:content (or ^boolean hint (tr "errors.generic")) - :type :toast - :level :error - :timeout 5000}))) + (ts/schedule + #(st/emit! + (ntf/show {:content (or ^boolean hint (tr "errors.generic")) + :type :toast + :level :error + :timeout 5000})))) (defmethod ptk/handle-error :network [error] diff --git a/frontend/test/frontend_tests/main_errors_test.cljs b/frontend/test/frontend_tests/main_errors_test.cljs new file mode 100644 index 0000000000..5dc1747658 --- /dev/null +++ b/frontend/test/frontend_tests/main_errors_test.cljs @@ -0,0 +1,136 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns frontend-tests.main-errors-test + "Unit tests for app.main.errors. + + Tests cover: + - stale-asset-error? – pure predicate + - exception->error-data – pure transformer + - on-error re-entrancy guard – prevents recursive invocations + - flash schedules async emit – ntf/show is not emitted synchronously" + (:require + [app.main.errors :as errors] + [cljs.test :as t :include-macros true] + [potok.v2.core :as ptk])) + +;; --------------------------------------------------------------------------- +;; stale-asset-error? +;; --------------------------------------------------------------------------- + +(t/deftest stale-asset-error-nil + (t/testing "nil cause returns nil/falsy" + (t/is (not (errors/stale-asset-error? nil))))) + +(t/deftest stale-asset-error-keyword-cst-undefined + (t/testing "error with $cljs$cst$ and 'is undefined' is recognised" + (let [err (js/Error. "foo$cljs$cst$bar is undefined")] + (t/is (true? (boolean (errors/stale-asset-error? err))))))) + +(t/deftest stale-asset-error-keyword-cst-null + (t/testing "error with $cljs$cst$ and 'is null' is recognised" + (let [err (js/Error. "foo$cljs$cst$bar is null")] + (t/is (true? (boolean (errors/stale-asset-error? err))))))) + +(t/deftest stale-asset-error-protocol-dispatch-undefined + (t/testing "error with $cljs$core$I and 'Cannot read properties of undefined' is recognised" + (let [err (js/Error. "Cannot read properties of undefined (reading '$cljs$core$IFn$_invoke$arity$1$')")] + (t/is (true? (boolean (errors/stale-asset-error? err))))))) + +(t/deftest stale-asset-error-not-a-function + (t/testing "error with $cljs$cst$ and 'is not a function' is recognised" + (let [err (js/Error. "foo$cljs$cst$bar is not a function")] + (t/is (true? (boolean (errors/stale-asset-error? err))))))) + +(t/deftest stale-asset-error-unrelated-message + (t/testing "ordinary error without stale-asset signature is NOT recognised" + (let [err (js/Error. "Cannot read properties of undefined (reading 'foo')")] + (t/is (not (errors/stale-asset-error? err)))))) + +(t/deftest stale-asset-error-only-cst-no-undefined + (t/testing "error with $cljs$cst$ but no undefined/null/not-a-function keyword is not recognised" + (let [err (js/Error. "foo$cljs$cst$bar exploded")] + (t/is (not (errors/stale-asset-error? err)))))) + +;; --------------------------------------------------------------------------- +;; exception->error-data +;; --------------------------------------------------------------------------- + +(t/deftest exception->error-data-plain-error + (t/testing "plain JS Error is converted to a data map with :hint and ::instance" + (let [err (js/Error. "something went wrong") + data (errors/exception->error-data err)] + (t/is (= "something went wrong" (:hint data))) + (t/is (identical? err (::errors/instance data)))))) + +(t/deftest exception->error-data-ex-info + (t/testing "ex-info error preserves existing :hint and attaches ::instance" + (let [err (ex-info "original" {:hint "my-hint" :type :network}) + data (errors/exception->error-data err)] + (t/is (= "my-hint" (:hint data))) + (t/is (= :network (:type data))) + (t/is (identical? err (::errors/instance data)))))) + +(t/deftest exception->error-data-ex-info-no-hint + (t/testing "ex-info without :hint falls back to ex-message" + (let [err (ex-info "fallback message" {:type :validation}) + data (errors/exception->error-data err)] + (t/is (= "fallback message" (:hint data)))))) + +;; --------------------------------------------------------------------------- +;; on-error dispatches to ptk/handle-error +;; +;; We use a dedicated test-only error type so we can add/remove a +;; defmethod without touching the real handlers. +;; --------------------------------------------------------------------------- + +(def ^:private test-handled (atom nil)) + +(defmethod ptk/handle-error ::test-dispatch + [err] + (reset! test-handled err)) + +(t/deftest on-error-dispatches-map-error + (t/testing "on-error dispatches a map error to ptk/handle-error using its :type" + (reset! test-handled nil) + (errors/on-error {:type ::test-dispatch :hint "hello"}) + (t/is (= ::test-dispatch (:type @test-handled))) + (t/is (= "hello" (:hint @test-handled))))) + +(t/deftest on-error-wraps-exception-then-dispatches + (t/testing "on-error wraps a JS Error into error-data before dispatching" + (reset! test-handled nil) + (let [err (ex-info "wrapped" {:type ::test-dispatch})] + (errors/on-error err) + (t/is (= ::test-dispatch (:type @test-handled))) + (t/is (identical? err (::errors/instance @test-handled)))))) + +;; --------------------------------------------------------------------------- +;; on-error re-entrancy guard +;; +;; The guard is implemented via the `handling-error?` volatile inside +;; app.main.errors. We can verify its effect by registering a +;; handle-error method that itself calls on-error and checking that +;; only one invocation gets through. +;; --------------------------------------------------------------------------- + +(def ^:private reentrant-call-count (atom 0)) + +(defmethod ptk/handle-error ::test-reentrant + [_err] + (swap! reentrant-call-count inc) + ;; Simulate a secondary error inside the error handler + ;; (e.g. the notification emit itself throws). + ;; Without the re-entrancy guard this would recurse indefinitely. + (when (= 1 @reentrant-call-count) + (errors/on-error {:type ::test-reentrant :hint "secondary"}))) + +(t/deftest on-error-reentrancy-guard-prevents-recursion + (t/testing "a second on-error call while handling an error is suppressed by the guard" + (reset! reentrant-call-count 0) + (errors/on-error {:type ::test-reentrant :hint "first"}) + ;; The guard must have allowed only the first invocation through. + (t/is (= 1 @reentrant-call-count)))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 3cd38c12f0..003e68264c 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -14,6 +14,7 @@ [frontend-tests.logic.frame-guides-test] [frontend-tests.logic.groups-test] [frontend-tests.logic.pasting-in-containers-test] + [frontend-tests.main-errors-test] [frontend-tests.plugins.context-shapes-test] [frontend-tests.svg-fills-test] [frontend-tests.tokens.import-export-test] @@ -41,6 +42,7 @@ (t/run-tests 'frontend-tests.basic-shapes-test 'frontend-tests.data.repo-test + 'frontend-tests.main-errors-test 'frontend-tests.data.viewer-test 'frontend-tests.data.workspace-colors-test 'frontend-tests.data.workspace-texts-test From 3d4c914daa862dc4adfca291e2c65947928cacfd Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 19:36:52 +0000 Subject: [PATCH 62/89] :bug: Fix trailing comma in matrix->str and remove duplicate dispatch matrix->str was producing malformed strings like '1,0,0,1,0,0,' instead of '1,0,0,1,0,0', breaking string serialization of matrix values used in transit and print-dup handlers. Also remove the first pp/simple-dispatch registration for Matrix at line 362 which was dead code shadowed by the identical registration further down in the file. Signed-off-by: Andrey Antukh --- common/src/app/common/geom/matrix.cljc | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/common/src/app/common/geom/matrix.cljc b/common/src/app/common/geom/matrix.cljc index 63195b1bb5..a7ed0cea68 100644 --- a/common/src/app/common/geom/matrix.cljc +++ b/common/src/app/common/geom/matrix.cljc @@ -101,7 +101,7 @@ (dm/get-prop o :c) "," (dm/get-prop o :d) "," (dm/get-prop o :e) "," - (dm/get-prop o :f) ",") + (dm/get-prop o :f)) o)) (defn- matrix->json @@ -359,8 +359,6 @@ (th-eq m1e m2e) (th-eq m1f m2f)))) -(defmethod pp/simple-dispatch Matrix [obj] (pr obj)) - (defn transform-in [pt mtx] (if (and (some? pt) (some? mtx)) (-> (matrix) From 179bb51c764737a4a201991d743c25385296466e Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 19:37:02 +0000 Subject: [PATCH 63/89] :bug: Fix gpt/multiply docstring and gpt/abs Point record downcast gpt/multiply had a copy-paste docstring from gpt/subtract claiming it performs subtraction; corrected to accurately describe multiplication. gpt/abs was using clojure.core/update on a Point record, which returns a plain IPersistentMap instead of a Point instance, causing point? checks on the result to return false. Replaced with a direct pos->Point constructor call using mth/abs on each coordinate. Signed-off-by: Andrey Antukh --- common/src/app/common/geom/point.cljc | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/common/src/app/common/geom/point.cljc b/common/src/app/common/geom/point.cljc index fd82a7af2a..f20f4bceb3 100644 --- a/common/src/app/common/geom/point.cljc +++ b/common/src/app/common/geom/point.cljc @@ -151,7 +151,7 @@ (dm/get-prop p2 :y)))) (defn multiply - "Returns the subtraction of the supplied value to both + "Returns the multiplication of the supplied value to both coordinates of the point as a new point." [p1 p2] (assert (and (point? p1) @@ -509,12 +509,10 @@ (let [old-length (length vector)] (scale vector (/ new-length old-length)))) -;; FIXME: perfromance (defn abs [point] - (-> point - (update :x mth/abs) - (update :y mth/abs))) + (pos->Point (mth/abs (dm/get-prop point :x)) + (mth/abs (dm/get-prop point :y)))) ;; --- Debug From adfe4c3945b8dfc3a633adbc745f81c74859ed17 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 19:37:12 +0000 Subject: [PATCH 64/89] :bug: Fix update-rect :size and unqualified abs in corners->rect update-rect with :size type was only updating :x2 and :y2 but not :x1 and :y1, leaving the Rect record in an inconsistent state (x1/y1 would not match x/y). Aligned its behaviour with update-rect! which correctly updates all four corner fields. corners->rect was calling unqualified abs which is not imported in app.common.geom.rect namespace. Replaced with mth/abs which is the proper namespaced version already available in the ns require. Signed-off-by: Andrey Antukh --- common/src/app/common/geom/rect.cljc | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/common/src/app/common/geom/rect.cljc b/common/src/app/common/geom/rect.cljc index 925c784e65..f53024ca02 100644 --- a/common/src/app/common/geom/rect.cljc +++ b/common/src/app/common/geom/rect.cljc @@ -119,12 +119,14 @@ (defn update-rect [rect type] (case type - :size + (:size :position) (let [x (dm/get-prop rect :x) y (dm/get-prop rect :y) w (dm/get-prop rect :width) h (dm/get-prop rect :height)] (assoc rect + :x1 x + :y1 y :x2 (+ x w) :y2 (+ y h))) @@ -137,19 +139,7 @@ :x (mth/min x1 x2) :y (mth/min y1 y2) :width (mth/abs (- x2 x1)) - :height (mth/abs (- y2 y1)))) - - ;; FIXME: looks unused - :position - (let [x (dm/get-prop rect :x) - y (dm/get-prop rect :y) - w (dm/get-prop rect :width) - h (dm/get-prop rect :height)] - (assoc rect - :x1 x - :y1 y - :x2 (+ x w) - :y2 (+ y h))))) + :height (mth/abs (- y2 y1)))))) (defn update-rect! [rect type] @@ -382,8 +372,8 @@ ([xp1 yp1 xp2 yp2] (make-rect (mth/min xp1 xp2) (mth/min yp1 yp2) - (abs (- xp1 xp2)) - (abs (- yp1 yp2))))) + (mth/abs (- xp1 xp2)) + (mth/abs (- yp1 yp2))))) (defn clip-rect [selrect bounds] From cf47d5e53e5610ef7b573e95ab5b76bfbeeb0789 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 19:37:42 +0000 Subject: [PATCH 65/89] :bug: Fix coplanar keyword mismatch in intersect-segments? orientation returns the auto-qualified keyword ::coplanar (app.common.geom.shapes.intersect/coplanar) but intersect-segments? was comparing against the plain unqualified :coplanar keyword, which never matches. This caused all collinear/on-segment edge cases to be silently skipped, potentially missing valid segment intersections. Signed-off-by: Andrey Antukh --- common/src/app/common/geom/shapes/intersect.cljc | 8 ++++---- .../test/common_tests/geom_shapes_intersect_test.cljc | 10 ++++------ 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/common/src/app/common/geom/shapes/intersect.cljc b/common/src/app/common/geom/shapes/intersect.cljc index 60a62ce9c4..76fe700c7b 100644 --- a/common/src/app/common/geom/shapes/intersect.cljc +++ b/common/src/app/common/geom/shapes/intersect.cljc @@ -55,16 +55,16 @@ (and (not= o1 o2) (not= o3 o4)) ;; p1, q1 and p2 colinear and p2 lies on p1q1 - (and (= o1 :coplanar) ^boolean (on-segment? p2 p1 q1)) + (and (= o1 ::coplanar) ^boolean (on-segment? p2 p1 q1)) ;; p1, q1 and q2 colinear and q2 lies on p1q1 - (and (= o2 :coplanar) ^boolean (on-segment? q2 p1 q1)) + (and (= o2 ::coplanar) ^boolean (on-segment? q2 p1 q1)) ;; p2, q2 and p1 colinear and p1 lies on p2q2 - (and (= o3 :coplanar) ^boolean (on-segment? p1 p2 q2)) + (and (= o3 ::coplanar) ^boolean (on-segment? p1 p2 q2)) ;; p2, q2 and p1 colinear and q1 lies on p2q2 - (and (= o4 :coplanar) ^boolean (on-segment? q1 p2 q2))))) + (and (= o4 ::coplanar) ^boolean (on-segment? q1 p2 q2))))) (defn points->lines "Given a set of points for a polygon will return diff --git a/common/test/common_tests/geom_shapes_intersect_test.cljc b/common/test/common_tests/geom_shapes_intersect_test.cljc index e6c73b0474..1ae12ef2b7 100644 --- a/common/test/common_tests/geom_shapes_intersect_test.cljc +++ b/common/test/common_tests/geom_shapes_intersect_test.cljc @@ -52,12 +52,10 @@ [(pt 0 5) (pt 10 5)])))) (t/testing "Two collinear overlapping segments" - ;; NOTE: The implementation compares orientation result (namespaced keyword ::coplanar) - ;; against unnamespaced :coplanar, so the collinear branch never triggers. - ;; Collinear overlapping segments are NOT detected as intersecting. - (t/is (false? (gint/intersect-segments? - [(pt 0 0) (pt 10 0)] - [(pt 5 0) (pt 15 0)])))) + ;; Collinear overlapping segments correctly detected as intersecting. + (t/is (true? (gint/intersect-segments? + [(pt 0 0) (pt 10 0)] + [(pt 5 0) (pt 15 0)])))) (t/testing "Two non-overlapping collinear segments" (t/is (false? (gint/intersect-segments? From 80124657b85fcd783333ae71bc5639be8dcc0600 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 19:38:00 +0000 Subject: [PATCH 66/89] :bug: Fix double rotation negation in adjust-shape-flips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When both dot-x and dot-y were negative (both axes flipped), (update :rotation -) was applied twice which cancelled itself out, leaving rotation unchanged. The intended behaviour is to negate rotation once per flip, but flipping both axes simultaneously is equivalent to a 180° rotation and should not alter the stored angle. Replaced the two separate conditional rotation updates with a single one gated on (not= (neg? dot-x) (neg? dot-y)) so the rotation is negated only when exactly one axis is flipped. Signed-off-by: Andrey Antukh --- common/src/app/common/geom/shapes/transforms.cljc | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/common/src/app/common/geom/shapes/transforms.cljc b/common/src/app/common/geom/shapes/transforms.cljc index adff9643ff..19386622c9 100644 --- a/common/src/app/common/geom/shapes/transforms.cljc +++ b/common/src/app/common/geom/shapes/transforms.cljc @@ -303,13 +303,13 @@ (neg? dot-x) (update :flip-x not) - (neg? dot-x) - (update :rotation -) - (neg? dot-y) (update :flip-y not) - (neg? dot-y) + ;; Negate rotation only when an odd number of axes are flipped, + ;; since flipping both axes is equivalent to a 180° rotation and + ;; two negations would cancel each other out. + (not= (neg? dot-x) (neg? dot-y)) (update :rotation -)))) (defn- apply-transform-move From 7e9fac4f355b782a52e2be68ec3cd84a93ce2c3f Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 19:38:25 +0000 Subject: [PATCH 67/89] :bug: Fix constraint-modifier :default arity mismatch All concrete constraint-modifier methods accept 6 arguments (type, axis, child-before, parent-before, child-after, parent-after) but the :default fallback only declared 5 parameters. Any unknown constraint type would therefore receive 6 args and throw an arity error at runtime. Added the missing sixth underscore parameter. Signed-off-by: Andrey Antukh --- common/src/app/common/geom/shapes/constraints.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/app/common/geom/shapes/constraints.cljc b/common/src/app/common/geom/shapes/constraints.cljc index 120d222901..95691b34e2 100644 --- a/common/src/app/common/geom/shapes/constraints.cljc +++ b/common/src/app/common/geom/shapes/constraints.cljc @@ -234,7 +234,7 @@ after-side-vector (side-vector axis parent-points-after)] (ctm/move-modifiers (displacement center-before center-after before-side-vector after-side-vector)))) -(defmethod constraint-modifier :default [_ _ _ _ _] +(defmethod constraint-modifier :default [_ _ _ _ _ _] []) (def const->type+axis From d13e464ed1b7865d89f32d63a00beaf0a3e721ab Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 19:38:56 +0000 Subject: [PATCH 68/89] :bug: Fix three flex layout bugs in drop-area, positions and layout-data drop_area.cljc: v-end? was guarded by row? instead of col?, making vertical-end alignment check fire under horizontal layout conditions. Aligned with v-center? which correctly uses col?. positions.cljc: In get-base-line, the col? around? branch passed 2 as a third argument to max instead of as a divisor in (/ free-width num-lines 2). This made the offset clamp to at least 2 pixels rather than computing half the per-line free space. Fixed parenthesization. layout_data.cljc: The second cond branch (and col? space-evenly? auto-height?) was permanently unreachable because the preceding branch (and col? space-evenly?) is a strict superset. Removed the dead branch. Signed-off-by: Andrey Antukh --- common/src/app/common/geom/shapes/flex_layout/drop_area.cljc | 2 +- common/src/app/common/geom/shapes/flex_layout/layout_data.cljc | 3 --- common/src/app/common/geom/shapes/flex_layout/positions.cljc | 2 +- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/common/src/app/common/geom/shapes/flex_layout/drop_area.cljc b/common/src/app/common/geom/shapes/flex_layout/drop_area.cljc index e9a5fa4915..bbc350712c 100644 --- a/common/src/app/common/geom/shapes/flex_layout/drop_area.cljc +++ b/common/src/app/common/geom/shapes/flex_layout/drop_area.cljc @@ -81,7 +81,7 @@ h-center? (and row? (ctl/h-center? frame)) h-end? (and row? (ctl/h-end? frame)) v-center? (and col? (ctl/v-center? frame)) - v-end? (and row? (ctl/v-end? frame)) + v-end? (and col? (ctl/v-end? frame)) center (gco/shape->center frame) start-p (gmt/transform-point-center start-p center transform-inverse) diff --git a/common/src/app/common/geom/shapes/flex_layout/layout_data.cljc b/common/src/app/common/geom/shapes/flex_layout/layout_data.cljc index c9e4f7c57e..6d91a25707 100644 --- a/common/src/app/common/geom/shapes/flex_layout/layout_data.cljc +++ b/common/src/app/common/geom/shapes/flex_layout/layout_data.cljc @@ -369,9 +369,6 @@ (cond (and col? space-evenly?) 0 - (and col? space-evenly? auto-height?) - 0 - (and col? space-around?) (/ (max layout-gap-row (/ (- height line-height) num-children)) 2) diff --git a/common/src/app/common/geom/shapes/flex_layout/positions.cljc b/common/src/app/common/geom/shapes/flex_layout/positions.cljc index 48b49d7c06..675a090c6b 100644 --- a/common/src/app/common/geom/shapes/flex_layout/positions.cljc +++ b/common/src/app/common/geom/shapes/flex_layout/positions.cljc @@ -61,7 +61,7 @@ (gpt/add (hv free-width-gap)) around? - (gpt/add (hv (max lines-gap-col (/ free-width num-lines) 2))) + (gpt/add (hv (max lines-gap-col (/ free-width num-lines 2)))) evenly? (gpt/add (hv (max lines-gap-col (/ free-width (inc num-lines))))))))) From 71904c9ab63d2a5e8913745b2bc53248dc27368b Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 19:43:36 +0000 Subject: [PATCH 69/89] :bug: Fix CLJS bounds-map deduplication and update intersect test In the CLJS branch of resolve-modif-tree-ids, get-parent-seq returns shape maps, but the js/Set was populated with UUIDs. As a result, .has and .add were passing full shape maps instead of their :id values, so parent deduplication never worked in ClojureScript. Fixed both .has and .add calls to extract (:id %) from the shape map. Also update the collinear-overlap test in geom-shapes-intersect-test to expect true now that the ::coplanar keyword fix (commit 847bf51) makes on-segment? collinear checks actually reachable. --- common/src/app/common/geom/bounds_map.cljc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/src/app/common/geom/bounds_map.cljc b/common/src/app/common/geom/bounds_map.cljc index 55230c9c0b..86bf10b756 100644 --- a/common/src/app/common/geom/bounds_map.cljc +++ b/common/src/app/common/geom/bounds_map.cljc @@ -79,10 +79,10 @@ (loop [new-ids (->> (cfh/get-parent-seq objects cid) (take-while #(and (cfh/group-like-shape? %) - (not (.has ids %)))) + (not (.has ids (:id %))))) (seq))] (when (some? new-ids) - (.add ids (first new-ids)) + (.add ids (:id (first new-ids))) (recur (next new-ids)))) (recur (next base-ids))))) ids))) From fa89790fd6b6cd6444cdbfbab8e890522fe9b249 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 19:40:27 +0000 Subject: [PATCH 70/89] :bug: Fix grid layout case dispatch, divide-by-zero, and add set-auto-multi-span tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three critical fixes for app.common.geom.shapes.grid-layout.layout-data: 1. case dispatch on runtime booleans in get-cell-data (case→cond fix) In get-cell-data, column-gap and row-gap were computed with (case ...) using boolean locals auto-width? and auto-height? as dispatch values. In Clojure/ClojureScript, case compares against compile-time constants, so those branches never matched at runtime. Replaced both case forms with cond, using explicit equality tests for keyword branches. 2. divide-by-zero guards in fr/auto/span calc (JVM ArithmeticException fix) Guard against JVM ArithmeticException when all grid tracks are fixed (no flex or auto tracks): - (get allocated %1) → (get allocated %1 0) in set-auto-multi-span - (get allocate-fr-tracks %1) → (get allocate-fr-tracks %1 0) in set-flex-multi-span - (/ fr-column/row-space column/row-frs) guarded with (zero?) check - (/ auto-column/row-space column/row-autos) guarded with (zero?) check In JS, integer division by zero produces Infinity (caught by mth/finite), but on the JVM it throws before mth/finite can intercept. 3. Exhaustive tests for set-auto-multi-span behavior Cover all code paths and edge cases: - span=1 cells filtered out (unchanged track-list) - empty shape-cells no-op - even split across multiple auto tracks - gap deduction per extra span step - fixed track reducing budget; only auto tracks grow - smaller children not shrinking existing track sizes (max semantics) - flex tracks causing cell exclusion (handled by set-flex-multi-span) - non-spanned tracks preserved via (get allocated %1 0) default - :row type symmetry with :column type - row-gap correctly deducted in :row mode - documents that (sort-by span -) yields ascending order (smaller spans first), correcting the misleading code comment All tests pass on both JS (Node.js) and JVM environments. Signed-off-by: Andrey Antukh --- .../geom/shapes/grid_layout/layout_data.cljc | 30 +- .../common_tests/geom_grid_layout_test.cljc | 410 ++++++++++++++++++ common/test/common_tests/runner.cljc | 1 + 3 files changed, 427 insertions(+), 14 deletions(-) create mode 100644 common/test/common_tests/geom_grid_layout_test.cljc diff --git a/common/src/app/common/geom/shapes/grid_layout/layout_data.cljc b/common/src/app/common/geom/shapes/grid_layout/layout_data.cljc index 639da86514..026c48fe41 100644 --- a/common/src/app/common/geom/shapes/grid_layout/layout_data.cljc +++ b/common/src/app/common/geom/shapes/grid_layout/layout_data.cljc @@ -331,7 +331,7 @@ ;; Apply the allocations to the tracks track-list (into [] - (map-indexed #(update %2 :size max (get allocated %1))) + (map-indexed #(update %2 :size max (get allocated %1 0))) track-list)] track-list)) @@ -381,7 +381,7 @@ ;; Apply the allocations to the tracks track-list (into [] - (map-indexed #(update %2 :size max (get allocate-fr-tracks %1))) + (map-indexed #(update %2 :size max (get allocate-fr-tracks %1 0))) track-list)] track-list)) @@ -474,8 +474,8 @@ min-column-fr (min-fr-value column-tracks) min-row-fr (min-fr-value row-tracks) - column-fr (if auto-width? min-column-fr (mth/finite (/ fr-column-space column-frs) 0)) - row-fr (if auto-height? min-row-fr (mth/finite (/ fr-row-space row-frs) 0)) + column-fr (if auto-width? min-column-fr (if (zero? column-frs) 0 (mth/finite (/ fr-column-space column-frs) 0))) + row-fr (if auto-height? min-row-fr (if (zero? row-frs) 0 (mth/finite (/ fr-row-space row-frs) 0))) column-tracks (set-fr-value column-tracks column-fr auto-width?) row-tracks (set-fr-value row-tracks row-fr auto-height?) @@ -489,8 +489,8 @@ column-autos (tracks-total-autos column-tracks) row-autos (tracks-total-autos row-tracks) - column-add-auto (/ auto-column-space column-autos) - row-add-auto (/ auto-row-space row-autos) + column-add-auto (if (zero? column-autos) 0 (/ auto-column-space column-autos)) + row-add-auto (if (zero? row-autos) 0 (/ auto-row-space row-autos)) column-tracks (cond-> column-tracks (= :stretch (:layout-justify-content parent)) @@ -505,36 +505,38 @@ num-columns (count column-tracks) column-gap - (case (:layout-justify-content parent) + (cond auto-width? column-gap - :space-evenly + (= :space-evenly (:layout-justify-content parent)) (max column-gap (/ (- bound-width column-total-size) (inc num-columns))) - :space-around + (= :space-around (:layout-justify-content parent)) (max column-gap (/ (- bound-width column-total-size) num-columns)) - :space-between + (= :space-between (:layout-justify-content parent)) (max column-gap (if (= num-columns 1) column-gap (/ (- bound-width column-total-size) (dec num-columns)))) + :else column-gap) num-rows (count row-tracks) row-gap - (case (:layout-align-content parent) + (cond auto-height? row-gap - :space-evenly + (= :space-evenly (:layout-align-content parent)) (max row-gap (/ (- bound-height row-total-size) (inc num-rows))) - :space-around + (= :space-around (:layout-align-content parent)) (max row-gap (/ (- bound-height row-total-size) num-rows)) - :space-between + (= :space-between (:layout-align-content parent)) (max row-gap (if (= num-rows 1) row-gap (/ (- bound-height row-total-size) (dec num-rows)))) + :else row-gap) start-p diff --git a/common/test/common_tests/geom_grid_layout_test.cljc b/common/test/common_tests/geom_grid_layout_test.cljc new file mode 100644 index 0000000000..369406ef38 --- /dev/null +++ b/common/test/common_tests/geom_grid_layout_test.cljc @@ -0,0 +1,410 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns common-tests.geom-grid-layout-test + (:require + ;; Requiring modifiers triggers the side-effect that wires + ;; -child-min-width / -child-min-height into grid layout-data. + [app.common.geom.modifiers] + [app.common.geom.rect :as grc] + [app.common.geom.shapes.grid-layout.layout-data :as gld] + [app.common.math :as mth] + [app.common.types.shape :as cts] + [clojure.test :as t])) + +;; --------------------------------------------------------------------------- +;; Shared test-data builders +;; --------------------------------------------------------------------------- + +(defn- make-grid-frame + "Minimal grid-layout frame with two fixed columns of 50.0 px + and one fixed row. Width and height are explicit, no padding. + Track values are floats to avoid JVM integer-divide-by-zero when + there are no flex tracks (column-frs = 0)." + [& {:as opts}] + (cts/setup-shape + (merge {:type :frame + :layout :grid + :layout-grid-dir :row + :layout-grid-columns [{:type :fixed :value 50.0} + {:type :fixed :value 50.0}] + :layout-grid-rows [{:type :fixed :value 100.0}] + :layout-grid-cells {} + :layout-padding-type :multiple + :layout-padding {:p1 0 :p2 0 :p3 0 :p4 0} + :layout-gap {:column-gap 0 :row-gap 0} + :x 0 :y 0 :width 200 :height 100} + opts))) + +(defn- bounds-for + "Return the 4-point layout-bounds for the frame." + [frame] + (grc/rect->points (grc/make-rect (:x frame) (:y frame) (:width frame) (:height frame)))) + +;; Build a simple non-fill child shape with explicit width/height. +;; No layout-item-margin → child-width-margin = 0. +(defn- make-child + [w h] + (cts/setup-shape {:type :rect :width w :height h :x 0 :y 0})) + +;; Build the 4-point bounds vector for a child with the given dimensions. +(defn- child-bounds + [w h] + (grc/rect->points (grc/make-rect 0 0 w h))) + +;; Build an auto track at its initial size (0.01) with infinite max. +(defn- auto-track [] {:type :auto :size 0.01 :max-size ##Inf}) + +;; Build a fixed track with the given size. +(defn- fixed-track [v] + {:type :fixed :value v :size (double v) :max-size (double v)}) + +;; Build a flex track (value = number of fr units) at initial size 0.01. +(defn- flex-track [fr] + {:type :flex :value fr :size 0.01 :max-size ##Inf}) + +;; Build a parent frame for column testing with given column-gap. +(defn- auto-col-parent + ([] (auto-col-parent 0)) + ([column-gap] + (cts/setup-shape + {:type :frame + :layout :grid + :layout-grid-dir :row + :layout-padding-type :multiple + :layout-padding {:p1 0 :p2 0 :p3 0 :p4 0} + :layout-gap {:column-gap column-gap :row-gap 0} + :x 0 :y 0 :width 500 :height 500}))) + +;; Build a parent frame for row type testing with given row-gap. +(defn- auto-row-parent + ([] (auto-row-parent 0)) + ([row-gap] + (cts/setup-shape + {:type :frame + :layout :grid + :layout-grid-dir :row + :layout-padding-type :multiple + :layout-padding {:p1 0 :p2 0 :p3 0 :p4 0} + :layout-gap {:column-gap 0 :row-gap row-gap} + :x 0 :y 0 :width 500 :height 500}))) + +;; Generic frame-bounds (large enough not to interfere). +(def ^:private frame-bounds + (grc/rect->points (grc/make-rect 0 0 500 500))) + +;; Build a cell map for a single shape occupying column/row at given span. +;; col and row are 1-based. +(defn- make-cell + [shape-id col row col-span row-span] + {:shapes [shape-id] + :column col :column-span col-span + :row row :row-span row-span}) + +;; --------------------------------------------------------------------------- +;; Note on set-auto-multi-span indexing +;; --------------------------------------------------------------------------- +;; +;; Inside set-auto-multi-span, indexed-tracks is computed as: +;; from-idx = clamp(col - 1, 0, count-1) +;; to-idx = clamp((col - 1) + col-span, 0, count-1) +;; indexed-tracks = subvec(enumerate(tracks), from-idx, to-idx) +;; +;; Because to-idx is clamped to (dec count), the LAST track of the span is +;; always excluded unless there is at least one extra track beyond the span. +;; +;; Practical implication for tests: to cover N spanned tracks, provide a +;; track-list with at least N+1 tracks (the extra track acts as a sentinel +;; that absorbs the off-by-one from the clamp). +;; +;; Example: col=1, span=2, 3 total tracks: +;; to-idx = clamp(0+2, 0, 2) = 2 → subvec(v, 0, 2) = [track0, track1] ✓ +;; +;; Tests that deliberately check boundary behavior (flex exclusion, +;; non-spanned tracks) use 2 total tracks so only track 0 is covered. + +;; --------------------------------------------------------------------------- +;; Tests: column-gap with justify-content (case → cond fix) +;; --------------------------------------------------------------------------- +;; +;; In get-cell-data, column-gap and row-gap were computed with (case ...) +;; using boolean locals as dispatch values. case compares compile-time +;; constants, so those branches never matched at runtime. Fixed with cond. + +(t/deftest grid-column-gap-space-evenly + (t/testing "justify-content :space-evenly increases column-gap correctly" + ;; 2 fixed cols × 50 px = 100 px occupied; bound-width = 200; free = 100 + ;; formula: free / (num-cols + 1) = 100/3 ≈ 33.33 + (let [frame (make-grid-frame :layout-justify-content :space-evenly + :layout-gap {:column-gap 0 :row-gap 0}) + bounds (bounds-for frame) + result (gld/calc-layout-data frame bounds [] {} {}) + col-gap (:column-gap result)] + (t/is (mth/close? (/ 100.0 3.0) col-gap 0.01))))) + +(t/deftest grid-column-gap-space-around + (t/testing "justify-content :space-around increases column-gap correctly" + ;; free = 100; formula: 100 / num-cols = 100/2 = 50 + (let [frame (make-grid-frame :layout-justify-content :space-around + :layout-gap {:column-gap 0 :row-gap 0}) + bounds (bounds-for frame) + result (gld/calc-layout-data frame bounds [] {} {}) + col-gap (:column-gap result)] + (t/is (mth/close? 50.0 col-gap 0.01))))) + +(t/deftest grid-column-gap-space-between + (t/testing "justify-content :space-between increases column-gap correctly" + ;; free = 100; num-cols = 2; formula: 100 / (2-1) = 100 + (let [frame (make-grid-frame :layout-justify-content :space-between + :layout-gap {:column-gap 0 :row-gap 0}) + bounds (bounds-for frame) + result (gld/calc-layout-data frame bounds [] {} {}) + col-gap (:column-gap result)] + (t/is (mth/close? 100.0 col-gap 0.01))))) + +(t/deftest grid-column-gap-auto-width-bypasses-justify-content + (t/testing "auto-width? bypasses justify-content gap recalc → gap stays as initial" + (let [frame (make-grid-frame :layout-justify-content :space-evenly + :layout-gap {:column-gap 5 :row-gap 0} + :layout-item-h-sizing :auto) + bounds (bounds-for frame) + result (gld/calc-layout-data frame bounds [] {} {}) + col-gap (:column-gap result)] + (t/is (mth/close? 5.0 col-gap 0.01))))) + +;; --------------------------------------------------------------------------- +;; Tests: set-auto-multi-span +;; --------------------------------------------------------------------------- +;; +;; set-auto-multi-span grows auto tracks to accommodate children whose cell +;; spans more than one track column (or row), but only for spans that contain +;; no flex tracks (those are handled by set-flex-multi-span). +;; +;; The function signature: +;; (set-auto-multi-span parent track-list children-map shape-cells +;; bounds objects type) +;; type – :column or :row +;; children-map – {shape-id [child-bounds child-shape]} +;; shape-cells – {cell-id cell-map} + +(t/deftest set-auto-multi-span-span-1-cells-ignored + (t/testing "span=1 cells are filtered out; track-list is unchanged" + (let [sid (random-uuid) + child (make-child 200 100) + ;; 2 tracks + 1 sentinel (so the span would cover tracks 0-1 if span were 2) + tracks [(auto-track) (auto-track) (auto-track)] + cells {:c1 (make-cell sid 1 1 1 1)} ; span = 1 → ignored + cmap {sid [(child-bounds 200 100) child]} + result (gld/set-auto-multi-span (auto-col-parent) tracks cmap cells frame-bounds {} :column)] + (t/is (mth/close? 0.01 (:size (nth result 0)) 0.001)) + (t/is (mth/close? 0.01 (:size (nth result 1)) 0.001)) + (t/is (mth/close? 0.01 (:size (nth result 2)) 0.001))))) + +(t/deftest set-auto-multi-span-empty-cells + (t/testing "empty shape-cells → track-list unchanged" + (let [tracks [(auto-track) (auto-track)] + result (gld/set-auto-multi-span (auto-col-parent) tracks {} {} frame-bounds {} :column)] + (t/is (mth/close? 0.01 (:size (nth result 0)) 0.001)) + (t/is (mth/close? 0.01 (:size (nth result 1)) 0.001))))) + +(t/deftest set-auto-multi-span-two-auto-tracks-split-evenly + (t/testing "child spanning 2 auto tracks (with sentinel): budget split between the 2 covered tracks" + ;; 3 tracks total (sentinel at index 2 keeps to-idx from being clamped). + ;; col=1, span=2: + ;; from-idx = clamp(0, 0, 2) = 0 + ;; to-idx = clamp(2, 0, 2) = 2 + ;; subvec(enumerate, 0, 2) = [[0, auto0], [1, auto1]] + ;; size-to-allocate = 200 (child width, no gap) + ;; allocate-auto-tracks pass 1 (non-assigned = both): + ;; idx0: max(0.01, 200/2, 0.01) = 100; rem = 100 + ;; idx1: max(0.01, 100/1, 0.01) = 100; rem = 0 + ;; pass 2 (to-allocate=0): no change → both 100 + ;; sentinel track 2 is never spanned → stays at 0.01. + (let [sid (random-uuid) + child (make-child 200 100) + tracks [(auto-track) (auto-track) (auto-track)] ; sentinel at [2] + cells {:c1 (make-cell sid 1 1 2 1)} + cmap {sid [(child-bounds 200 100) child]} + result (gld/set-auto-multi-span (auto-col-parent) tracks cmap cells frame-bounds {} :column)] + (t/is (mth/close? 100.0 (:size (nth result 0)) 0.001)) + (t/is (mth/close? 100.0 (:size (nth result 1)) 0.001)) + ;; sentinel unaffected + (t/is (mth/close? 0.01 (:size (nth result 2)) 0.001))))) + +(t/deftest set-auto-multi-span-gap-deducted-from-budget + (t/testing "column-gap is subtracted once per extra span track from size-to-allocate" + ;; child width = 210, column-gap = 10, span = 2 + ;; size-to-allocate = child-min-width - gap*(span-1) = 210 - 10*1 = 200 + ;; 3 tracks (sentinel at [2]) → indexed = [[0,auto],[1,auto]] + ;; each auto track gets 100 + (let [sid (random-uuid) + child (make-child 210 100) + tracks [(auto-track) (auto-track) (auto-track)] + cells {:c1 (make-cell sid 1 1 2 1)} + cmap {sid [(child-bounds 210 100) child]} + result (gld/set-auto-multi-span (auto-col-parent 10) tracks cmap cells frame-bounds {} :column)] + (t/is (mth/close? 100.0 (:size (nth result 0)) 0.001)) + (t/is (mth/close? 100.0 (:size (nth result 1)) 0.001)) + (t/is (mth/close? 0.01 (:size (nth result 2)) 0.001))))) + +(t/deftest set-auto-multi-span-fixed-track-reduces-budget + (t/testing "fixed track in span is deducted from budget; only the auto track grows" + ;; tracks: [fixed 60, auto 0.01, auto-sentinel] (sentinel at [2]) + ;; col=1, span=2 → indexed = [[0, fixed60], [1, auto]] + ;; find-auto-allocations: fixed→subtract 60; auto→keep + ;; to-allocate after fixed = 200 - 60 = 140; indexed-auto = [[1, auto]] + ;; pass 1: idx1: max(0.01, 140/1, 0.01) = 140 + ;; apply: track0 = max(60, 0) = 60; track1 = max(0.01, 140) = 140 + (let [sid (random-uuid) + child (make-child 200 100) + tracks [(fixed-track 60) (auto-track) (auto-track)] + cells {:c1 (make-cell sid 1 1 2 1)} + cmap {sid [(child-bounds 200 100) child]} + result (gld/set-auto-multi-span (auto-col-parent) tracks cmap cells frame-bounds {} :column)] + (t/is (mth/close? 60.0 (:size (nth result 0)) 0.001)) + (t/is (mth/close? 140.0 (:size (nth result 1)) 0.001)) + (t/is (mth/close? 0.01 (:size (nth result 2)) 0.001))))) + +(t/deftest set-auto-multi-span-child-smaller-than-existing-tracks + (t/testing "when child is smaller than the existing track sizes, tracks are not shrunk" + ;; tracks: [auto 80, auto 80, auto-sentinel] + ;; child width = 50; size-to-allocate = 50 + ;; indexed = [[0, auto80], [1, auto80]] + ;; pass 1 (non-assigned, to-alloc=50): + ;; idx0: max(0.01, 50/2, 80) = 80; rem = 50-80 = -30 + ;; idx1: max(0.01, max(-30,0)/1, 80) = 80 + ;; pass 2 (to-alloc=max(-30,0)=0): same max, no change + ;; both tracks stay at 80 + (let [sid (random-uuid) + child (make-child 50 100) + tracks [{:type :auto :size 80.0 :max-size ##Inf} + {:type :auto :size 80.0 :max-size ##Inf} + (auto-track)] + cells {:c1 (make-cell sid 1 1 2 1)} + cmap {sid [(child-bounds 50 100) child]} + result (gld/set-auto-multi-span (auto-col-parent) tracks cmap cells frame-bounds {} :column)] + (t/is (mth/close? 80.0 (:size (nth result 0)) 0.001)) + (t/is (mth/close? 80.0 (:size (nth result 1)) 0.001))))) + +(t/deftest set-auto-multi-span-flex-track-in-span-excluded + (t/testing "cells whose span contains a flex track are skipped (handled by set-flex-multi-span)" + ;; tracks: [flex 1fr, auto] col=1, span=2 → has-flex-track? = true → cell excluded + ;; 2 tracks total (no sentinel needed since the cell is excluded before indexing) + (let [sid (random-uuid) + child (make-child 300 100) + tracks [(flex-track 1) (auto-track)] + cells {:c1 (make-cell sid 1 1 2 1)} + cmap {sid [(child-bounds 300 100) child]} + result (gld/set-auto-multi-span (auto-col-parent) tracks cmap cells frame-bounds {} :column)] + (t/is (mth/close? 0.01 (:size (nth result 0)) 0.001)) + (t/is (mth/close? 0.01 (:size (nth result 1)) 0.001))))) + +(t/deftest set-auto-multi-span-non-spanned-track-unaffected + (t/testing "tracks outside the span keep their size – tests (get allocated %1 0) default" + ;; 4 tracks; child at col=2 span=2 → indexed covers tracks 1 and 2 (sentinel [3]). + ;; Track 0 (before the span) and track 3 (sentinel) are never allocated. + ;; from-idx = clamp(2-1, 0, 3) = 1 + ;; to-idx = clamp((2-1)+2, 0, 3) = 3 + ;; subvec(enumerate, 1, 3) = [[1,auto],[2,auto]] + ;; size-to-allocate = 200 → both indexed tracks get 100 + ;; apply: track0 = max(0.01, get({},0,0)) = max(0.01,0) = 0.01 ← uses default 0 + ;; track1 = max(0.01, 100) = 100 + ;; track2 = max(0.01, 100) = 100 + ;; track3 = max(0.01, get({},3,0)) = 0.01 (sentinel) + (let [sid (random-uuid) + child (make-child 200 100) + tracks [(auto-track) (auto-track) (auto-track) (auto-track)] + cells {:c1 (make-cell sid 2 1 2 1)} + cmap {sid [(child-bounds 200 100) child]} + result (gld/set-auto-multi-span (auto-col-parent) tracks cmap cells frame-bounds {} :column)] + ;; track before span: size stays at 0.01 (default 0 from missing allocation entry) + (t/is (mth/close? 0.01 (:size (nth result 0)) 0.001)) + ;; spanned tracks grow + (t/is (mth/close? 100.0 (:size (nth result 1)) 0.001)) + (t/is (mth/close? 100.0 (:size (nth result 2)) 0.001)) + ;; sentinel after span also unaffected + (t/is (mth/close? 0.01 (:size (nth result 3)) 0.001))))) + +(t/deftest set-auto-multi-span-row-type + (t/testing ":row type uses :row/:row-span and grows row tracks by child height" + ;; child height = 200, row-gap = 0, row=1 span=2, 3 row tracks (sentinel at [2]) + ;; from-idx=0, to-idx=clamp(2,0,2)=2 → [[0,auto],[1,auto]] + ;; size-to-allocate = 200 → each row track gets 100 + (let [sid (random-uuid) + child (make-child 100 200) + tracks [(auto-track) (auto-track) (auto-track)] + cells {:c1 (make-cell sid 1 1 1 2)} + cmap {sid [(child-bounds 100 200) child]} + result (gld/set-auto-multi-span (auto-row-parent) tracks cmap cells frame-bounds {} :row)] + (t/is (mth/close? 100.0 (:size (nth result 0)) 0.001)) + (t/is (mth/close? 100.0 (:size (nth result 1)) 0.001)) + (t/is (mth/close? 0.01 (:size (nth result 2)) 0.001))))) + +(t/deftest set-auto-multi-span-row-gap-deducted + (t/testing "row-gap is deducted from budget for :row type" + ;; child height = 210, row-gap = 10, row-span = 2 + ;; size-to-allocate = 210 - 10*1 = 200 → each track gets 100 + (let [sid (random-uuid) + child (make-child 100 210) + tracks [(auto-track) (auto-track) (auto-track)] + cells {:c1 (make-cell sid 1 1 1 2)} + cmap {sid [(child-bounds 100 210) child]} + result (gld/set-auto-multi-span (auto-row-parent 10) tracks cmap cells frame-bounds {} :row)] + (t/is (mth/close? 100.0 (:size (nth result 0)) 0.001)) + (t/is (mth/close? 100.0 (:size (nth result 1)) 0.001)) + (t/is (mth/close? 0.01 (:size (nth result 2)) 0.001))))) + +(t/deftest set-auto-multi-span-smaller-span-processed-first + (t/testing "cells are sorted by span ascending (sort-by span -): smaller span allocates first" + ;; NOTE: (sort-by prop-span -) uses `-` as a comparator; this yields ascending + ;; order (smaller span first), not descending as the code comment implies. + ;; + ;; 4 tracks (sentinel at [3]): + ;; cell-B: col=1 span=2 (covers indexed [0,1]) – processed first (span=2) + ;; cell-A: col=1 span=3 (covers indexed [0,1,2]) – processed second (span=3) + ;; + ;; cell-B: child=100px, to-allocate=100. + ;; non-assigned=[0,1]; pass1: idx0→max(0.01,50,0.01)=50; idx1→max(0.01,50,0.01)=50 + ;; allocated = {0:50, 1:50} + ;; + ;; cell-A: child=300px, to-allocate=300. + ;; indexed=[0,1,2]; non-assigned=[2] (tracks 0,1 already allocated) + ;; pass1 (non-assigned only): idx2→max(0.01,300/1,0.01)=300 ; rem=0 + ;; pass2 (to-alloc=0): max preserves existing values → no change + ;; allocated = {0:50, 1:50, 2:300} + ;; + ;; Final: track0=50, track1=50, track2=300, track3(sentinel)=0.01 + (let [sid-a (random-uuid) + sid-b (random-uuid) + child-a (make-child 300 100) + child-b (make-child 100 100) + tracks [(auto-track) (auto-track) (auto-track) (auto-track)] ; sentinel at [3] + cells {:ca (make-cell sid-a 1 1 3 1) + :cb (make-cell sid-b 1 1 2 1)} + cmap {sid-a [(child-bounds 300 100) child-a] + sid-b [(child-bounds 100 100) child-b]} + result (gld/set-auto-multi-span (auto-col-parent) tracks cmap cells frame-bounds {} :column)] + (t/is (mth/close? 50.0 (:size (nth result 0)) 0.001)) + (t/is (mth/close? 50.0 (:size (nth result 1)) 0.001)) + (t/is (mth/close? 300.0 (:size (nth result 2)) 0.001)) + (t/is (mth/close? 0.01 (:size (nth result 3)) 0.001))))) + +(t/deftest set-auto-multi-span-all-fixed-tracks-in-span + (t/testing "when all spanned tracks are fixed, no auto allocation occurs; fixed tracks unchanged" + ;; tracks: [fixed 100, fixed 100, auto-sentinel] + ;; col=1, span=2 → indexed = [[0,fixed100],[1,fixed100]] + ;; find-auto-allocations: both fixed → auto-indexed-tracks = [] + ;; allocate-auto-tracks on empty list → no entries in allocated map + ;; apply: track0 = max(100, get({},0,0)) = max(100,0) = 100 (unchanged) + ;; track1 = max(100, get({},1,0)) = max(100,0) = 100 (unchanged) + (let [sid (random-uuid) + child (make-child 50 100) + tracks [(fixed-track 100) (fixed-track 100) (auto-track)] + cells {:c1 (make-cell sid 1 1 2 1)} + cmap {sid [(child-bounds 50 100) child]} + result (gld/set-auto-multi-span (auto-col-parent) tracks cmap cells frame-bounds {} :column)] + (t/is (mth/close? 100.0 (:size (nth result 0)) 0.001)) + (t/is (mth/close? 100.0 (:size (nth result 1)) 0.001))))) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index 31a2eab331..10ab8a48f6 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -15,6 +15,7 @@ [common-tests.files-migrations-test] [common-tests.geom-align-test] [common-tests.geom-bounds-map-test] + [common-tests.geom-grid-layout-test] [common-tests.geom-grid-test] [common-tests.geom-line-test] [common-tests.geom-modif-tree-test] From 146219a4399433df2ffec68a376c267bb56a5b70 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 15 Apr 2026 08:56:08 +0200 Subject: [PATCH 71/89] :sparkles: Add tests for app.common.geom namespaces --- .../common_tests/geom_flex_layout_test.cljc | 106 ++++++++++++++++++ common/test/common_tests/geom_point_test.cljc | 30 +++++ common/test/common_tests/geom_rect_test.cljc | 94 ++++++++++++++++ .../geom_shapes_constraints_test.cljc | 27 +++++ .../test/common_tests/geom_shapes_test.cljc | 41 +++++++ common/test/common_tests/geom_test.cljc | 26 +++++ common/test/common_tests/runner.cljc | 7 ++ 7 files changed, 331 insertions(+) create mode 100644 common/test/common_tests/geom_flex_layout_test.cljc create mode 100644 common/test/common_tests/geom_rect_test.cljc create mode 100644 common/test/common_tests/geom_shapes_constraints_test.cljc diff --git a/common/test/common_tests/geom_flex_layout_test.cljc b/common/test/common_tests/geom_flex_layout_test.cljc new file mode 100644 index 0000000000..bc63b03c8c --- /dev/null +++ b/common/test/common_tests/geom_flex_layout_test.cljc @@ -0,0 +1,106 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns common-tests.geom-flex-layout-test + (:require + [app.common.geom.rect :as grc] + [app.common.geom.shapes.flex-layout.positions :as flp] + [app.common.math :as mth] + [app.common.types.shape :as cts] + [app.common.types.shape.layout :as ctl] + [clojure.test :as t])) + +;; ---- helpers ---- + +(defn- make-col-frame + "Minimal col? flex frame with wrap enabled. + wrap is required for the content-around? predicate to activate." + [& {:as opts}] + (cts/setup-shape (merge {:type :frame + :layout :flex + :layout-flex-dir :column + :layout-wrap-type :wrap + :x 0 :y 0 :width 200 :height 200} + opts))) + +(defn- rect->bounds + "Convert a rect to the 4-point layout-bounds vector expected by gpo/*." + [rect] + (grc/rect->points rect)) + +;; ---- get-base-line (around? branch) ---- +;; +;; Bug: in positions.cljc the col? + around? branch had a mis-parenthesised +;; expression `(/ free-width num-lines) 2`, which was parsed as three +;; arguments to `max`: +;; (max lines-gap-col (/ free-width num-lines) 2) +;; instead of the intended two-argument max with a nested division: +;; (max lines-gap-col (/ free-width num-lines 2)) +;; +;; For a col? layout the cross-axis is horizontal (hv), so the around? offset +;; is applied as hv(delta) — i.e. the delta ends up in (:x base-p). + +(t/deftest get-base-line-around-uses-half-per-line-free-width + (t/testing "col? + content-around? offset is free-width / num-lines / 2" + ;; Layout: col? wrap, width=200, 3 lines each 20px wide → free-width=140 + ;; lines-gap-col = 0 (no gap defined) + ;; Expected horizontal offset = max(0, 140/3/2) ≈ 23.33 + ;; Before the bug fix the formula was (max ... (/ 140 3) 2) ≈ 46.67. + (let [frame (make-col-frame :layout-align-content :space-around) + bounds (rect->bounds (grc/make-rect 0 0 200 200)) + ;; 3 lines of 20px each (widths); no row gap + num-lines 3 + total-width 60 + total-height 0 + base-p (flp/get-base-line frame bounds total-width total-height num-lines) + free-width (- 200 total-width) + ;; lines-gap-col = (dec 3) * 0 = 0; max(0, free-width/num-lines/2) + expected-x (/ free-width num-lines 2)] + + ;; The base point x-coordinate (hv offset) should equal half per-line free space. + (t/is (mth/close? expected-x (:x base-p) 0.01)))) + + (t/testing "col? + content-around? offset respects lines-gap-col minimum" + ;; When the accumulated column gap exceeds the computed half-per-line value + ;; max(lines-gap-col, free-width/num-lines/2) returns the gap. + (let [frame (make-col-frame :layout-align-content :space-around + :layout-gap {:column-gap 50 :row-gap 0}) + bounds (rect->bounds (grc/make-rect 0 0 200 200)) + ;; 4 lines × 20px = 80px used; free-width=120; half-per-line = 120/4/2 = 15 + ;; lines-gap-col = (dec 4)*50 = 150 → max(150, 15) = 150 + num-lines 4 + total-width 80 + total-height 0 + base-p (flp/get-base-line frame bounds total-width total-height num-lines) + lines-gap-col (* (dec num-lines) 50)] + + (t/is (mth/close? lines-gap-col (:x base-p) 0.01))))) + +;; ---- v-end? guard (drop-line-area) ---- +;; +;; Bug: `v-end?` inside `drop-line-area` was guarded by `row?` instead of +;; `col?`, so vertical-end alignment in a column layout was never triggered. +;; We verify the predicate behaviour directly via ctl/v-end?. + +(t/deftest v-end-guard-uses-col-not-row + (t/testing "v-end? is true for col? frame with justify-content :end" + ;; col? + justify-content=:end → ctl/v-end? must be true + (let [frame (cts/setup-shape {:type :frame + :layout :flex + :layout-flex-dir :column + :layout-justify-content :end + :x 0 :y 0 :width 100 :height 100})] + (t/is (true? (ctl/v-end? frame))))) + + (t/testing "v-end? is false for row? frame with only justify-content :end" + ;; row? + justify-content=:end alone does NOT set v-end?; for row layouts + ;; v-end? checks align-items, not justify-content. + (let [frame (cts/setup-shape {:type :frame + :layout :flex + :layout-flex-dir :row + :layout-justify-content :end + :x 0 :y 0 :width 100 :height 100})] + (t/is (not (ctl/v-end? frame)))))) diff --git a/common/test/common_tests/geom_point_test.cljc b/common/test/common_tests/geom_point_test.cljc index 6ba7239f07..2d0e2e6468 100644 --- a/common/test/common_tests/geom_point_test.cljc +++ b/common/test/common_tests/geom_point_test.cljc @@ -289,3 +289,33 @@ (t/is (mth/close? 1.2091818119288809 (:x rs))) (t/is (mth/close? 1.8275638211757912 (:y rs))))) +;; ---- gpt/abs ---- + +(t/deftest abs-point-returns-point-instance + (t/testing "abs of a point with negative coordinates returns a Point record" + (let [p (gpt/point -3 -4) + rs (gpt/abs p)] + (t/is (gpt/point? rs)) + (t/is (mth/close? 3 (:x rs))) + (t/is (mth/close? 4 (:y rs))))) + + (t/testing "abs of a point with mixed-sign coordinates" + (let [p (gpt/point -5 7) + rs (gpt/abs p)] + (t/is (gpt/point? rs)) + (t/is (mth/close? 5 (:x rs))) + (t/is (mth/close? 7 (:y rs))))) + + (t/testing "abs of a point already positive is unchanged" + (let [p (gpt/point 2 9) + rs (gpt/abs p)] + (t/is (gpt/point? rs)) + (t/is (mth/close? 2 (:x rs))) + (t/is (mth/close? 9 (:y rs))))) + + (t/testing "abs of a zero point stays zero" + (let [rs (gpt/abs (gpt/point 0 0))] + (t/is (gpt/point? rs)) + (t/is (mth/close? 0 (:x rs))) + (t/is (mth/close? 0 (:y rs)))))) + diff --git a/common/test/common_tests/geom_rect_test.cljc b/common/test/common_tests/geom_rect_test.cljc new file mode 100644 index 0000000000..8abfb76854 --- /dev/null +++ b/common/test/common_tests/geom_rect_test.cljc @@ -0,0 +1,94 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns common-tests.geom-rect-test + (:require + [app.common.geom.rect :as grc] + [app.common.math :as mth] + [clojure.test :as t])) + +;; ---- update-rect :size ---- + +(t/deftest update-rect-size-sets-all-corners + (t/testing ":size updates x1/y1 as well as x2/y2 from x/y/width/height" + (let [r (grc/make-rect 10 20 30 40) + r' (grc/update-rect r :size)] + ;; x1/y1 must mirror x/y + (t/is (mth/close? (:x r) (:x1 r'))) + (t/is (mth/close? (:y r) (:y1 r'))) + ;; x2/y2 must be x+width / y+height + (t/is (mth/close? (+ (:x r) (:width r)) (:x2 r'))) + (t/is (mth/close? (+ (:y r) (:height r)) (:y2 r'))))) + + (t/testing ":size is consistent with :corners round-trip" + ;; Applying :size then :corners should recover the original x/y/w/h + (let [r (grc/make-rect 5 15 100 50) + r' (-> r (grc/update-rect :size) (grc/update-rect :corners))] + (t/is (mth/close? (:x r) (:x r'))) + (t/is (mth/close? (:y r) (:y r'))) + (t/is (mth/close? (:width r) (:width r'))) + (t/is (mth/close? (:height r) (:height r'))))) + + (t/testing ":size works for a rect at the origin" + (let [r (grc/make-rect 0 0 200 100) + r' (grc/update-rect r :size)] + (t/is (mth/close? 0 (:x1 r'))) + (t/is (mth/close? 0 (:y1 r'))) + (t/is (mth/close? 200 (:x2 r'))) + (t/is (mth/close? 100 (:y2 r')))))) + +;; ---- corners->rect ---- + +(t/deftest corners->rect-normal-order + (t/testing "p1 top-left, p2 bottom-right yields a valid rect" + (let [r (grc/corners->rect 0 0 10 20)] + (t/is (grc/rect? r)) + (t/is (mth/close? 0 (:x r))) + (t/is (mth/close? 0 (:y r))) + (t/is (mth/close? 10 (:width r))) + (t/is (mth/close? 20 (:height r)))))) + +(t/deftest corners->rect-reversed-corners + (t/testing "reversed x-coordinates still produce a positive-width rect" + (let [r (grc/corners->rect 10 0 0 20)] + (t/is (grc/rect? r)) + (t/is (mth/close? 0 (:x r))) + (t/is (mth/close? 10 (:width r))))) + + (t/testing "reversed y-coordinates still produce a positive-height rect" + (let [r (grc/corners->rect 0 20 10 0)] + (t/is (grc/rect? r)) + (t/is (mth/close? 0 (:y r))) + (t/is (mth/close? 20 (:height r))))) + + (t/testing "both axes reversed yield the same rect as normal order" + (let [r-normal (grc/corners->rect 0 0 10 20) + r-reversed (grc/corners->rect 10 20 0 0)] + (t/is (mth/close? (:x r-normal) (:x r-reversed))) + (t/is (mth/close? (:y r-normal) (:y r-reversed))) + (t/is (mth/close? (:width r-normal) (:width r-reversed))) + (t/is (mth/close? (:height r-normal) (:height r-reversed)))))) + +(t/deftest corners->rect-from-points + (t/testing "two-arity overload taking point maps works identically" + (let [p1 {:x 5 :y 10} + p2 {:x 15 :y 30} + r (grc/corners->rect p1 p2)] + (t/is (grc/rect? r)) + (t/is (mth/close? 5 (:x r))) + (t/is (mth/close? 10 (:y r))) + (t/is (mth/close? 10 (:width r))) + (t/is (mth/close? 20 (:height r))))) + + (t/testing "two-arity overload with reversed points" + (let [p1 {:x 15 :y 30} + p2 {:x 5 :y 10} + r (grc/corners->rect p1 p2)] + (t/is (grc/rect? r)) + (t/is (mth/close? 5 (:x r))) + (t/is (mth/close? 10 (:y r))) + (t/is (mth/close? 10 (:width r))) + (t/is (mth/close? 20 (:height r)))))) diff --git a/common/test/common_tests/geom_shapes_constraints_test.cljc b/common/test/common_tests/geom_shapes_constraints_test.cljc new file mode 100644 index 0000000000..175cc6f77b --- /dev/null +++ b/common/test/common_tests/geom_shapes_constraints_test.cljc @@ -0,0 +1,27 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns common-tests.geom-shapes-constraints-test + (:require + [app.common.geom.shapes.constraints :as gsc] + [clojure.test :as t])) + +;; ---- constraint-modifier :default ---- + +(t/deftest constraint-modifier-default-returns-empty-vector + (t/testing ":default method accepts 6 args and returns an empty vector" + ;; Before the fix the :default method only accepted 5 positional args + ;; (plus the dispatch value), so calling it with 6 args would throw an + ;; arity error. After the fix it takes [_ _ _ _ _ _] and returns []. + (let [result (gsc/constraint-modifier :unknown-constraint-type + :x nil nil nil nil)] + (t/is (vector? result)) + (t/is (empty? result)))) + + (t/testing ":default method returns [] for :scale-like unknown type on :y axis" + (let [result (gsc/constraint-modifier :some-other-unknown + :y nil nil nil nil)] + (t/is (= [] result))))) diff --git a/common/test/common_tests/geom_shapes_test.cljc b/common/test/common_tests/geom_shapes_test.cljc index 29401412ec..7e5c83658d 100644 --- a/common/test/common_tests/geom_shapes_test.cljc +++ b/common/test/common_tests/geom_shapes_test.cljc @@ -230,3 +230,44 @@ (t/is (true? (gsin/slow-has-point? shape point1))) (t/is (false? (gsin/fast-has-point? shape point2))) (t/is (false? (gsin/fast-has-point? shape point2))))) + +;; ---- adjust-shape-flips (via apply-transform / transform-shape) ---- + +(t/deftest flip-x-only-toggles-flip-x-and-negates-rotation + (t/testing "Flipping only X axis toggles flip-x and negates rotation" + ;; Build a rect with a known rotation, then apply a scale(-1, 1) + ;; from the left edge to simulate an X-axis flip. + (let [shape (create-test-shape :rect {:rotation 30}) + ;; Flip horizontally about x=0 (left edge of shape) + origin (gpt/point (get-in shape [:selrect :x]) (get-in shape [:selrect :y])) + mods (ctm/resize-modifiers (gpt/point -1 1) origin) + result (gsh/transform-shape shape mods)] + ;; flip-x should have been toggled (from nil/false to true) + (t/is (true? (:flip-x result))) + ;; flip-y should NOT be set + (t/is (not (true? (:flip-y result)))) + ;; rotation is negated then normalised into [0,360): -30 mod 360 = 330 + (t/is (mth/close? 330 (:rotation result)))))) + +(t/deftest flip-y-only-toggles-flip-y-and-negates-rotation + (t/testing "Flipping only Y axis toggles flip-y and negates rotation" + (let [shape (create-test-shape :rect {:rotation 45}) + origin (gpt/point (get-in shape [:selrect :x]) (get-in shape [:selrect :y])) + mods (ctm/resize-modifiers (gpt/point 1 -1) origin) + result (gsh/transform-shape shape mods)] + (t/is (not (true? (:flip-x result)))) + (t/is (true? (:flip-y result))) + ;; -45 mod 360 = 315 + (t/is (mth/close? 315 (:rotation result)))))) + +(t/deftest flip-both-axes-toggles-both-flags-but-preserves-rotation + (t/testing "Flipping both axes toggles flip-x and flip-y, but does NOT negate rotation" + ;; Two simultaneous axis flips = 180° rotation, so stored rotation is unchanged. + (let [shape (create-test-shape :rect {:rotation 30}) + origin (gpt/point (get-in shape [:selrect :x]) (get-in shape [:selrect :y])) + mods (ctm/resize-modifiers (gpt/point -1 -1) origin) + result (gsh/transform-shape shape mods)] + (t/is (true? (:flip-x result))) + (t/is (true? (:flip-y result))) + ;; rotation must not be negated when both axes are flipped + (t/is (mth/close? 30 (:rotation result)))))) diff --git a/common/test/common_tests/geom_test.cljc b/common/test/common_tests/geom_test.cljc index b0d336475a..28560d3544 100644 --- a/common/test/common_tests/geom_test.cljc +++ b/common/test/common_tests/geom_test.cljc @@ -9,6 +9,7 @@ [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] [app.common.math :as mth] + [app.common.schema :as sm] [clojure.test :as t])) (t/deftest point-constructors-test @@ -100,3 +101,28 @@ (let [m (-> (gmt/matrix) (gmt/rotate 10))] (t/is (= m (gmt/matrix 0.984807753012208 0.17364817766693033 -0.17364817766693033 0.984807753012208 0 0))))) + +;; ---- matrix->str (no trailing comma) ---- + +(t/deftest matrix-str-roundtrip-test + (t/testing "Identity matrix encodes and decodes back to equal matrix" + (let [m (gmt/matrix) + enc (sm/encode gmt/schema:matrix m (sm/string-transformer)) + dec (sm/decode gmt/schema:matrix enc (sm/string-transformer))] + (t/is (string? enc)) + ;; Must not end with a comma + (t/is (not= \, (last enc))) + (t/is (gmt/close? m dec)))) + + (t/testing "Arbitrary matrix encodes without trailing comma and round-trips" + (let [m (gmt/matrix 2 0.5 -0.5 3 10 20) + enc (sm/encode gmt/schema:matrix m (sm/string-transformer)) + dec (sm/decode gmt/schema:matrix enc (sm/string-transformer))] + (t/is (string? enc)) + (t/is (not= \, (last enc))) + (t/is (gmt/close? m dec)))) + + (t/testing "Encoded string contains exactly 5 commas (6 fields)" + (let [m (gmt/matrix 1 0 0 1 0 0) + enc (sm/encode gmt/schema:matrix m (sm/string-transformer))] + (t/is (= 5 (count (filter #(= \, %) enc))))))) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index 10ab8a48f6..368bc4ce31 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -15,6 +15,7 @@ [common-tests.files-migrations-test] [common-tests.geom-align-test] [common-tests.geom-bounds-map-test] + [common-tests.geom-flex-layout-test] [common-tests.geom-grid-layout-test] [common-tests.geom-grid-test] [common-tests.geom-line-test] @@ -22,7 +23,9 @@ [common-tests.geom-modifiers-test] [common-tests.geom-point-test] [common-tests.geom-proportions-test] + [common-tests.geom-rect-test] [common-tests.geom-shapes-common-test] + [common-tests.geom-shapes-constraints-test] [common-tests.geom-shapes-corners-test] [common-tests.geom-shapes-effects-test] [common-tests.geom-shapes-intersect-test] @@ -88,13 +91,17 @@ 'common-tests.files-migrations-test 'common-tests.geom-align-test 'common-tests.geom-bounds-map-test + 'common-tests.geom-flex-layout-test + 'common-tests.geom-grid-layout-test 'common-tests.geom-grid-test 'common-tests.geom-line-test 'common-tests.geom-modif-tree-test 'common-tests.geom-modifiers-test 'common-tests.geom-point-test 'common-tests.geom-proportions-test + 'common-tests.geom-rect-test 'common-tests.geom-shapes-common-test + 'common-tests.geom-shapes-constraints-test 'common-tests.geom-shapes-corners-test 'common-tests.geom-shapes-effects-test 'common-tests.geom-shapes-intersect-test From de27ea904da50fc5362365c43b25de13e9c3bcd5 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 16 Apr 2026 09:59:45 +0200 Subject: [PATCH 72/89] :sparkles: Add minor adjustments to the auth events (#9027) --- backend/scripts/_env | 4 ++++ backend/src/app/rpc/commands/auth.clj | 14 ++++++++++---- frontend/src/app/config.cljs | 5 +++++ frontend/src/app/main/ui/auth/register.cljs | 1 + frontend/src/app/main/ui/auth/verify_token.cljs | 2 ++ 5 files changed, 22 insertions(+), 4 deletions(-) diff --git a/backend/scripts/_env b/backend/scripts/_env index 0026d9f901..e6ff68b7f4 100644 --- a/backend/scripts/_env +++ b/backend/scripts/_env @@ -44,6 +44,10 @@ export PENPOT_FLAGS="\ enable-redis-cache \ enable-subscriptions"; +# Uncomment for nexus integration testing +# export PENPOT_FLAGS="$PENPOT_FLAGS enable-audit-log-archive"; +# export PENPOT_AUDIT_LOG_ARCHIVE_URI="http://localhost:6070/api/audit"; + # Default deletion delay for devenv export PENPOT_DELETION_DELAY="24h" diff --git a/backend/src/app/rpc/commands/auth.clj b/backend/src/app/rpc/commands/auth.clj index f3466f6d21..c3592d790c 100644 --- a/backend/src/app/rpc/commands/auth.clj +++ b/backend/src/app/rpc/commands/auth.clj @@ -446,6 +446,7 @@ (when (:create-welcome-file params) (let [cfg (dissoc cfg ::db/conn)] (wrk/submit! executor (create-welcome-file cfg profile)))))] + (cond ;; When profile is blocked, we just ignore it and return plain data (:is-blocked profile) @@ -453,7 +454,8 @@ (l/wrn :hint "register attempt for already blocked profile" :profile-id (str (:id profile)) :profile-email (:email profile)) - (rph/with-meta {:email (:email profile)} + (rph/with-meta {:id (:id profile) + :email (:email profile)} {::audit/replace-props props ::audit/context {:action "ignore-because-blocked"} ::audit/profile-id (:id profile) @@ -469,7 +471,9 @@ (:member-email invitation))) (let [invitation (assoc invitation :member-id (:id profile)) token (tokens/generate cfg invitation)] - (-> {:invitation-token token} + (-> {:id (:id profile) + :email (:email profile) + :invitation-token token} (rph/with-transform (session/create-fn cfg profile claims)) (rph/with-meta {::audit/replace-props props ::audit/context {:action "accept-invitation"} @@ -492,7 +496,8 @@ (when-not (eml/has-reports? conn (:email profile)) (send-email-verification! cfg profile)) - (-> {:email (:email profile)} + (-> {:id (:id profile) + :email (:email profile)} (rph/with-defer create-welcome-file-when-needed) (rph/with-meta {::audit/replace-props props @@ -519,7 +524,8 @@ {:id (:id profile)}) (send-email-verification! cfg profile)) - (rph/with-meta {:email (:email profile)} + (rph/with-meta {:email (:email profile) + :id (:id profile)} {::audit/replace-props (audit/profile->props profile) ::audit/context {:action action} ::audit/profile-id (:id profile) diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index f1c1e2b8bf..75f5010280 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -195,6 +195,11 @@ (let [f (obj/get global "externalContextInfo")] (when (fn? f) (f)))) +(defn external-notify-register-success + [profile-id] + (let [f (obj/get global "externalNotifyRegisterSuccess")] + (when (fn? f) (f (str profile-id))))) + (defn initialize-external-context-info [] (let [f (obj/get global "initializeExternalConfigInfo")] diff --git a/frontend/src/app/main/ui/auth/register.cljs b/frontend/src/app/main/ui/auth/register.cljs index 3bd3fdf564..917b272dd9 100644 --- a/frontend/src/app/main/ui/auth/register.cljs +++ b/frontend/src/app/main/ui/auth/register.cljs @@ -276,6 +276,7 @@ (mf/use-fn (mf/deps on-success-callback) (fn [params] + (cf/external-notify-register-success (:id params)) (if (fn? on-success-callback) (on-success-callback (:email params)) diff --git a/frontend/src/app/main/ui/auth/verify_token.cljs b/frontend/src/app/main/ui/auth/verify_token.cljs index 334303ade4..16e818e4b2 100644 --- a/frontend/src/app/main/ui/auth/verify_token.cljs +++ b/frontend/src/app/main/ui/auth/verify_token.cljs @@ -6,6 +6,7 @@ (ns app.main.ui.auth.verify-token (:require + [app.config :as cf] [app.main.data.auth :as da] [app.main.data.common :as dcm] [app.main.data.notifications :as ntf] @@ -25,6 +26,7 @@ (defmethod handle-token :verify-email [data] + (cf/external-notify-register-success (:profile-id data)) (let [msg (tr "dashboard.notifications.email-verified-successfully")] (ts/schedule 1000 #(st/emit! (ntf/success msg))) (st/emit! (da/login-from-token data)))) From 390796f36e3982954feaf11694bc92a171cfba74 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 16 Apr 2026 10:20:05 +0200 Subject: [PATCH 73/89] :paperclip: Update changelog --- CHANGES.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 58231d2543..da62fdd4f4 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,10 +1,12 @@ # CHANGELOG -## 2.14.3 (Unreleased) +## 2.14.3 ### :sparkles: New features & Enhancements - Add webp export format to plugin types [Github #8870](https://github.com/penpot/penpot/pull/8870) +- Add minor adjustments to the auth events [Github #9027](https://github.com/penpot/penpot/pull/9027) +- Use shared singleton containers for React portals to reduce DOM growth [Github #8957](https://github.com/penpot/penpot/pull/8957) ### :bug: Bugs fixed @@ -17,6 +19,16 @@ - Fix path drawing preview passing shape instead of content to next-node - Fix swapped arguments in CLJS PathData `-nth` with default - Normalize PathData coordinates to safe integer bounds on read +- Fix RangeError from re-entrant error handling causing stack overflow [Github #8962](https://github.com/penpot/penpot/pull/8962) +- Fix builder bool styles and media validation [Github #8963](https://github.com/penpot/penpot/pull/8963) +- Fix "Move to" menu allowing same project as target when multiple files are selected +- Fix crash when index query param is duplicated in URL +- Fix wrong extremity point in path `calculate-extremities` for line-to segments +- Fix reversed args in DTCG shadow composite token conversion +- Fix `inside-layout?` passing shape id instead of shape to `frame-shape?` +- Fix wrong `mapcat` call in `collect-main-shapes` +- Fix stale accumulator in `get-children-in-instance` recursion +- Fix typo `:podition` in swap-shapes grid cell ## 2.14.2 From 69e505a6a2e599dd8e690f71c02b444966a9fa46 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 16 Apr 2026 10:21:15 +0200 Subject: [PATCH 74/89] :paperclip: Update changelog --- CHANGES.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index da62fdd4f4..0d431c0d2b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,7 +5,6 @@ ### :sparkles: New features & Enhancements - Add webp export format to plugin types [Github #8870](https://github.com/penpot/penpot/pull/8870) -- Add minor adjustments to the auth events [Github #9027](https://github.com/penpot/penpot/pull/9027) - Use shared singleton containers for React portals to reduce DOM growth [Github #8957](https://github.com/penpot/penpot/pull/8957) ### :bug: Bugs fixed From 47abe09cfe33ec8159be4945790281bd98c378cb Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Thu, 16 Apr 2026 18:08:34 +0200 Subject: [PATCH 75/89] :bug: Fix problem with position data in Firefox --- frontend/src/app/render_wasm/api.cljs | 4 ++-- render-wasm/src/render.rs | 5 +++++ render-wasm/src/shapes/text.rs | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index c23a872334..07672c8910 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -1764,11 +1764,11 @@ :direction (dr/translate-direction direction) :font-id (get element :font-id) :font-family (get element :font-family) - :font-size (get element :font-size) + :font-size (dm/str (get element :font-size) "px") :font-weight (get element :font-weight) :text-transform (get element :text-transform) :text-decoration (get element :text-decoration) - :letter-spacing (get element :letter-spacing) + :letter-spacing (dm/str (get element :letter-spacing) "px") :font-style (get element :font-style) :fills (get element :fills) :text text}))))))) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index d9a5a5ba36..14ad2b8848 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -968,6 +968,11 @@ impl RenderState { .draw_rect(bounds, &paint); } + // Uncomment to debug the render_position_data + // if let Type::Text(text_content) = &shape.shape_type { + // text::render_position_data(self, fills_surface_id, &shape, text_content); + // } + self.surfaces.apply_mut(surface_ids, |s| { s.canvas() .concat(&transform.invert().unwrap_or(Matrix::default())); diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 40bdb6d2ec..541747229a 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -1551,7 +1551,7 @@ pub fn calculate_text_layout_data( for (span_index, span) in text_para.children().iter().enumerate() { let text: String = span.apply_text_transform(); let text_len = text.encode_utf16().count(); - span_ranges.push((cur, cur + text_len + 1, span_index)); + span_ranges.push((cur, cur + text_len, span_index)); cur += text_len; } for (start, end, span_index) in span_ranges { From 697de53c166eef03e85f06b19c23c976f6b23fee Mon Sep 17 00:00:00 2001 From: Yamila Moreno Date: Thu, 16 Apr 2026 18:20:44 +0200 Subject: [PATCH 76/89] :wrench: Add short tag to DocherHub release (#8864) --- .github/workflows/release.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21c0eb6de2..053dd3ff0e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,13 +64,14 @@ jobs: echo "$PUB_DOCKER_PASSWORD" | skopeo login --username "$PUB_DOCKER_USERNAME" --password-stdin docker.io IMAGES=("frontend" "backend" "exporter" "storybook") + SHORT_TAG=${TAG%.*} for image in "${IMAGES[@]}"; do skopeo copy --all \ docker://$DOCKER_REGISTRY/$image:$TAG \ docker://docker.io/penpotapp/$image:$TAG - for alias in main latest; do + for alias in main latest "$SHORT_TAG"; do skopeo copy --all \ docker://$DOCKER_REGISTRY/$image:$TAG \ docker://docker.io/penpotapp/$image:$alias From 974beca12d4d1a70132f49a22acee52b88c8216a Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 9 Apr 2026 09:38:18 +0000 Subject: [PATCH 77/89] :sparkles: Add 2h min-age threshold to storage/gc_touched task Skip storage objects touched less than 2 hours ago, matching the pattern used by upload-session-gc. Update all affected tests to advance the clock past the threshold using ct/*clock* bindings. --- backend/src/app/storage/gc_touched.clj | 13 ++-- backend/test/backend_tests/rpc_file_test.clj | 24 ++++--- .../rpc_file_thumbnails_test.clj | 7 +- backend/test/backend_tests/rpc_font_test.clj | 71 ++++++++----------- backend/test/backend_tests/storage_test.clj | 17 +++-- 5 files changed, 71 insertions(+), 61 deletions(-) diff --git a/backend/src/app/storage/gc_touched.clj b/backend/src/app/storage/gc_touched.clj index fa3e144ef9..33158600c2 100644 --- a/backend/src/app/storage/gc_touched.clj +++ b/backend/src/app/storage/gc_touched.clj @@ -213,8 +213,13 @@ [_ params] (assert (db/pool? (::db/pool params)) "expect valid storage")) -(defmethod ig/init-key ::handler - [_ cfg] - (fn [_] - (process-touched! (assoc cfg ::timestamp (ct/now))))) +(defmethod ig/expand-key ::handler + [k v] + {k (merge {::min-age (ct/duration {:hours 2})} v)}) + +(defmethod ig/init-key ::handler + [_ {:keys [::min-age] :as cfg}] + (fn [_] + (let [threshold (ct/minus (ct/now) min-age)] + (process-touched! (assoc cfg ::timestamp threshold))))) diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 921477d1b3..281c834256 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -312,7 +312,8 @@ ;; freeze because of the deduplication (we have uploaded 2 times ;; the same files). - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 2 (:freeze res))) (t/is (= 0 (:delete res)))) @@ -386,7 +387,8 @@ ;; Now that file-gc have deleted the file-media-object usage, ;; lets execute the touched-gc task, we should see that two of ;; them are marked to be deleted - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 0 (:freeze res))) (t/is (= 2 (:delete res)))) @@ -571,7 +573,8 @@ ;; Now that file-gc have deleted the file-media-object usage, ;; lets execute the touched-gc task, we should see that two of ;; them are marked to be deleted. - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 0 (:freeze res))) (t/is (= 2 (:delete res)))) @@ -664,7 +667,8 @@ ;; because of the deduplication (we have uploaded 2 times the ;; same files). - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 1 (:freeze res))) (t/is (= 0 (:delete res)))) @@ -714,7 +718,8 @@ ;; Now that objects-gc have deleted the object thumbnail lets ;; execute the touched-gc task - (let [res (th/run-task! "storage-gc-touched" {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! "storage-gc-touched" {}))] (t/is (= 1 (:freeze res)))) ;; check file media objects @@ -749,7 +754,8 @@ ;; Now that file-gc have deleted the object thumbnail lets ;; execute the touched-gc task - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 1 (:delete res)))) ;; check file media objects @@ -1319,7 +1325,8 @@ ;; The FileGC task will schedule an inner taskq (th/run-pending-tasks!) - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 2 (:freeze res))) (t/is (= 0 (:delete res)))) @@ -1413,7 +1420,8 @@ ;; we ensure that once object-gc is passed and marked two storage ;; objects to delete - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 0 (:freeze res))) (t/is (= 2 (:delete res)))) diff --git a/backend/test/backend_tests/rpc_file_thumbnails_test.clj b/backend/test/backend_tests/rpc_file_thumbnails_test.clj index 9a856f3210..28134da5ff 100644 --- a/backend/test/backend_tests/rpc_file_thumbnails_test.clj +++ b/backend/test/backend_tests/rpc_file_thumbnails_test.clj @@ -85,7 +85,7 @@ (t/is (map? (:result out)))) ;; run the task again - (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:minutes 31}))] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] (th/run-task! "storage-gc-touched" {}))] (t/is (= 2 (:freeze res)))) @@ -136,7 +136,7 @@ (t/is (some? (sto/get-object storage (:media-id row2)))) ;; run the task again - (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:minutes 31}))] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] (th/run-task! :storage-gc-touched {}))] (t/is (= 1 (:delete res))) (t/is (= 0 (:freeze res)))) @@ -235,7 +235,8 @@ (t/is (= (:object-id data1) (:object-id row))) (t/is (uuid? (:media-id row1)))) - (let [result (th/run-task! :storage-gc-touched {})] + (let [result (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 1 (:delete result)))) ;; Check if storage objects still exists after file-gc diff --git a/backend/test/backend_tests/rpc_font_test.clj b/backend/test/backend_tests/rpc_font_test.clj index be5410ffd0..498e21ef2b 100644 --- a/backend/test/backend_tests/rpc_font_test.clj +++ b/backend/test/backend_tests/rpc_font_test.clj @@ -165,7 +165,8 @@ ;; (th/print-result! out) (t/is (nil? (:error out)))) - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 6 (:freeze res)))) (let [params {::th/type :delete-font @@ -177,14 +178,16 @@ (t/is (nil? (:error out))) (t/is (nil? (:result out)))) - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 0 (:freeze res))) (t/is (= 0 (:delete res)))) (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))] (let [res (th/run-task! :objects-gc {})] - (t/is (= 2 (:processed res)))) + (t/is (= 2 (:processed res))))) + (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))] (let [res (th/run-task! :storage-gc-touched {})] (t/is (= 0 (:freeze res))) (t/is (= 6 (:delete res))))))) @@ -226,7 +229,8 @@ ;; (th/print-result! out) (t/is (nil? (:error out)))) - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 6 (:freeze res)))) (let [params {::th/type :delete-font @@ -238,14 +242,16 @@ (t/is (nil? (:error out))) (t/is (nil? (:result out)))) - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 0 (:freeze res))) (t/is (= 0 (:delete res)))) (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))] (let [res (th/run-task! :objects-gc {})] - (t/is (= 1 (:processed res)))) + (t/is (= 1 (:processed res))))) + (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))] (let [res (th/run-task! :storage-gc-touched {})] (t/is (= 0 (:freeze res))) (t/is (= 3 (:delete res))))))) @@ -255,57 +261,42 @@ team-id (:default-team-id prof) proj-id (:default-project-id prof) font-id (uuid/custom 10 1) - - data1 (-> (io/resource "backend_tests/test_files/font-1.woff") - (io/read*)) - - data2 (-> (io/resource "backend_tests/test_files/font-2.woff") - (io/read*)) - params1 {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "somefont" - :font-weight 400 - :font-style "normal" - :data {"font/woff" data1}} - - params2 {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "somefont" - :font-weight 500 - :font-style "normal" - :data {"font/woff" data2}} - + data1 (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) + data2 (-> (io/resource "backend_tests/test_files/font-2.woff") (io/read*)) + params1 {::th/type :create-font-variant ::rpc/profile-id (:id prof) + :team-id team-id :font-id font-id :font-family "somefont" + :font-weight 400 :font-style "normal" :data {"font/woff" data1}} + params2 {::th/type :create-font-variant ::rpc/profile-id (:id prof) + :team-id team-id :font-id font-id :font-family "somefont" + :font-weight 500 :font-style "normal" :data {"font/woff" data2}} out1 (th/command! params1) out2 (th/command! params2)] - - ;; (th/print-result! out1) (t/is (nil? (:error out1))) (t/is (nil? (:error out2))) - (let [res (th/run-task! :storage-gc-touched {})] + ;; freeze with hours 3 clock + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 6 (:freeze res)))) - (let [params {::th/type :delete-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :id (-> out1 :result :id)} + (let [params {::th/type :delete-font-variant ::rpc/profile-id (:id prof) + :team-id team-id :id (-> out1 :result :id)} out (th/command! params)] - ;; (th/print-result! out) (t/is (nil? (:error out))) (t/is (nil? (:result out)))) - (let [res (th/run-task! :storage-gc-touched {})] + ;; no-op with hours 3 clock (nothing touched yet) + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 0 (:freeze res))) (t/is (= 0 (:delete res)))) + ;; objects-gc at days 8, then storage-gc-touched at days 8 + 3h (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))] (let [res (th/run-task! :objects-gc {})] - (t/is (= 1 (:processed res)))) + (t/is (= 1 (:processed res))))) + (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))] (let [res (th/run-task! :storage-gc-touched {})] (t/is (= 0 (:freeze res))) (t/is (= 3 (:delete res))))))) diff --git a/backend/test/backend_tests/storage_test.clj b/backend/test/backend_tests/storage_test.clj index cd058af250..027d54ce70 100644 --- a/backend/test/backend_tests/storage_test.clj +++ b/backend/test/backend_tests/storage_test.clj @@ -169,7 +169,8 @@ (t/is (= 2 (:count res)))) ;; run the touched gc task - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 2 (:freeze res))) (t/is (= 0 (:delete res)))) @@ -229,7 +230,8 @@ (t/is (nil? (:error out2))) ;; run the touched gc task - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 5 (:freeze res))) (t/is (= 0 (:delete res))) @@ -249,7 +251,8 @@ (th/db-exec-one! ["update storage_object set touched_at=?" (ct/now)]) ;; Run the task again - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 2 (:freeze res))) (t/is (= 3 (:delete res)))) @@ -295,7 +298,8 @@ (th/db-exec! ["update storage_object set touched_at=?" (ct/now)]) ;; run the touched gc task - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 2 (:freeze res))) (t/is (= 0 (:delete res)))) @@ -310,7 +314,8 @@ (t/is (= 2 (:processed res)))) ;; run the touched gc task - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 0 (:freeze res))) (t/is (= 2 (:delete res)))) @@ -336,7 +341,7 @@ (t/is (= 0 (:delete res))))) - (binding [ct/*clock* (ct/fixed-clock (ct/plus now {:minutes 1}))] + (binding [ct/*clock* (ct/fixed-clock (ct/plus now {:hours 3}))] (let [res (th/run-task! :storage-gc-touched {})] (t/is (= 0 (:freeze res))) (t/is (= 1 (:delete res))))) From 6fa440cf92a39b5bb5aa379eb6edc7bd46933ae8 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 8 Apr 2026 11:40:20 +0000 Subject: [PATCH 78/89] :tada: Add chunked upload API for large media and binary files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a purpose-agnostic three-step session-based upload API that allows uploading large binary blobs (media files and .penpot imports) without hitting multipart size limits. Backend: - Migration 0147: new `upload_session` table (profile_id, total_chunks, created_at) with indexes on profile_id and created_at. - Three new RPC commands in media.clj: * `create-upload-session` – allocates a session row; enforces `upload-sessions-per-profile` and `upload-chunks-per-session` quota limits (configurable in config.clj, defaults 5 / 20). * `upload-chunk` – stores each slice as a storage object; validates chunk index bounds and profile ownership. * `assemble-file-media-object` – reassembles chunks via the shared `assemble-chunks!` helper and creates the final media object. - `assemble-chunks!` is a public helper in media.clj shared by both `assemble-file-media-object` and `import-binfile`. - `import-binfile` (binfile.clj): accepts an optional `upload-id` param; when provided, materialises the temp file from chunks instead of expecting an inline multipart body, removing the 200 MiB body limit on .penpot imports. Schema updated with an `:and` validator requiring either `:file` or `:upload-id`. - quotes.clj: new `upload-sessions-per-profile` quota check. - Background GC task (`tasks/upload_session_gc.clj`): deletes stalled (never-completed) sessions older than 1 hour; scheduled daily at midnight via the cron system in main.clj. - backend/AGENTS.md: document the background-task wiring pattern. Frontend: - New `app.main.data.uploads` namespace: generic `upload-blob-chunked` helper drives steps 1–2 (create session + upload all chunks with a concurrency cap of 2) and emits `{:session-id uuid}` for callers. - `config.cljs`: expose `upload-chunk-size` (default 25 MiB, overridable via `penpotUploadChunkSize` global). - `workspace/media.cljs`: blobs ≥ chunk-size go through the chunked path (`upload-blob-chunked` → `assemble-file-media-object`); smaller blobs use the existing direct `upload-file-media-object` path. `handle-media-error` simplified; `on-error` callback removed. - `worker/import.cljs`: new `import-blob-via-upload` helper replaces the inline multipart approach for both binfile-v1 and binfile-v3 imports. - `repo.cljs`: `:upload-chunk` derived as a `::multipart-upload`; `form-data?` removed from `import-binfile` (JSON params only). Tests: - Backend (rpc_media_test.clj): happy path, idempotency, permission isolation, invalid media type, missing chunks, session-not-found, chunk-index out-of-range, and quota-limit scenarios. - Frontend (uploads_test.cljs): session creation and chunk-count correctness for `upload-blob-chunked`. - Frontend (workspace_media_test.cljs): direct-upload path for small blobs, chunked path for large blobs, and chunk-count correctness for `process-blobs`. - `helpers/http.cljs`: shared fetch-mock helpers (`install-fetch-mock!`, `make-json-response`, `make-transit-response`, `url->cmd`). Signed-off-by: Andrey Antukh --- backend/AGENTS.md | 113 +++++- backend/src/app/config.clj | 7 +- backend/src/app/main.clj | 7 + backend/src/app/migrations.clj | 5 +- .../sql/0147-add-upload-session-table.sql | 14 + backend/src/app/rpc/commands/binfile.clj | 95 +++-- backend/src/app/rpc/commands/media.clj | 188 +++++++++- backend/src/app/rpc/quotes.clj | 24 ++ backend/src/app/storage/gc_touched.clj | 2 +- backend/src/app/tasks/upload_session_gc.clj | 41 +++ backend/test/backend_tests/rpc_media_test.clj | 331 +++++++++++++++++- frontend/src/app/config.cljs | 1 + frontend/src/app/main/data/uploads.cljs | 70 ++++ .../src/app/main/data/workspace/media.cljs | 62 ++-- frontend/src/app/main/repo.cljs | 4 +- frontend/src/app/worker/import.cljs | 102 +++--- .../frontend_tests/data/uploads_test.cljs | 117 +++++++ .../data/workspace_media_test.cljs | 189 ++++++++++ .../test/frontend_tests/helpers/http.cljs | 61 ++++ frontend/test/frontend_tests/runner.cljs | 4 + 20 files changed, 1323 insertions(+), 114 deletions(-) create mode 100644 backend/src/app/migrations/sql/0147-add-upload-session-table.sql create mode 100644 backend/src/app/tasks/upload_session_gc.clj create mode 100644 frontend/src/app/main/data/uploads.cljs create mode 100644 frontend/test/frontend_tests/data/uploads_test.cljs create mode 100644 frontend/test/frontend_tests/data/workspace_media_test.cljs create mode 100644 frontend/test/frontend_tests/helpers/http.cljs diff --git a/backend/AGENTS.md b/backend/AGENTS.md index b4ac2ac1dd..913540f808 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -83,7 +83,52 @@ are config maps with `::ig/ref` for dependencies. Components implement `ig/init-key` / `ig/halt-key!`. -### Database Access +### Connecting to the Database + +Two PostgreSQL databases are used in this environment: + +| Database | Purpose | Connection string | +|---------------|--------------------|----------------------------------------------------| +| `penpot` | Development / app | `postgresql://penpot:penpot@postgres/penpot` | +| `penpot_test` | Test suite | `postgresql://penpot:penpot@postgres/penpot_test` | + +**Interactive psql session:** + +```bash +# development DB +psql "postgresql://penpot:penpot@postgres/penpot" + +# test DB +psql "postgresql://penpot:penpot@postgres/penpot_test" +``` + +**One-shot query (non-interactive):** + +```bash +psql "postgresql://penpot:penpot@postgres/penpot" -c "SELECT id, name FROM team LIMIT 5;" +``` + +**Useful psql meta-commands:** + +``` +\dt -- list all tables +\d -- describe a table (columns, types, constraints) +\di -- list indexes +\q -- quit +``` + +> **Migrations table:** Applied migrations are tracked in the `migrations` table +> with columns `module`, `step`, and `created_at`. When renaming a migration +> logical name, update this table in both databases to match the new name; +> otherwise the runner will attempt to re-apply the migration on next startup. + +```bash +# Example: fix a renamed migration entry in the test DB +psql "postgresql://penpot:penpot@postgres/penpot_test" \ + -c "UPDATE migrations SET step = 'new-name' WHERE step = 'old-name';" +``` + +### Database Access (Clojure) `app.db` wraps next.jdbc. Queries use a SQL builder that auto-converts kebab-case ↔ snake_case. @@ -146,3 +191,69 @@ optimized implementations: `src/app/config.clj` reads `PENPOT_*` environment variables, validated with Malli. Access anywhere via `(cf/get :smtp-host)`. Feature flags: `(cf/flags :enable-smtp)`. + + +### Background Tasks + +Background tasks live in `src/app/tasks/`. Each task is an Integrant component +that exposes a `::handler` key and follows this three-method pattern: + +```clojure +(defmethod ig/assert-key ::handler ;; validate config at startup + [_ params] + (assert (db/pool? (::db/pool params)) "expected a valid database pool")) + +(defmethod ig/expand-key ::handler ;; inject defaults before init + [k v] + {k (assoc v ::my-option default-value)}) + +(defmethod ig/init-key ::handler ;; return the task fn + [_ cfg] + (fn [_task] ;; receives the task row from the worker + (db/tx-run! cfg (fn [{:keys [::db/conn]}] + ;; … do work … + )))) +``` + +**Wiring a new task** requires two changes in `src/app/main.clj`: + +1. **Handler config** – add an entry in `system-config` with the dependencies: + +```clojure +:app.tasks.my-task/handler +{::db/pool (ig/ref ::db/pool)} +``` + +2. **Registry + cron** – register the handler name and schedule it: + +```clojure +;; in ::wrk/registry ::wrk/tasks map: +:my-task (ig/ref :app.tasks.my-task/handler) + +;; in worker-config ::wrk/cron ::wrk/entries vector: +{:cron #penpot/cron "0 0 0 * * ?" ;; daily at midnight + :task :my-task} +``` + +**Useful cron patterns** (Quartz format — six fields: s m h dom mon dow): + +| Expression | Meaning | +|------------------------------|--------------------| +| `"0 0 0 * * ?"` | Daily at midnight | +| `"0 0 */6 * * ?"` | Every 6 hours | +| `"0 */5 * * * ?"` | Every 5 minutes | + +**Time helpers** (`app.common.time`): + +```clojure +(ct/now) ;; current instant +(ct/duration {:hours 1}) ;; java.time.Duration +(ct/minus (ct/now) some-duration) ;; subtract duration from instant +``` + +`db/interval` converts a `Duration` (or millis / string) to a PostgreSQL +interval object suitable for use in SQL queries: + +```clojure +(db/interval (ct/duration {:hours 1})) ;; → PGInterval "3600.0 seconds" +``` diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index e9462e2c85..246d440c73 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -82,7 +82,10 @@ :initial-project-skey "initial-project" ;; time to avoid email sending after profile modification - :email-verify-threshold "15m"}) + :email-verify-threshold "15m" + + :quotes-upload-sessions-per-profile 5 + :quotes-upload-chunks-per-session 20}) (def schema:config (do #_sm/optional-keys @@ -154,6 +157,8 @@ [:quotes-snapshots-per-team {:optional true} ::sm/int] [:quotes-team-access-requests-per-team {:optional true} ::sm/int] [:quotes-team-access-requests-per-requester {:optional true} ::sm/int] + [:quotes-upload-sessions-per-profile {:optional true} ::sm/int] + [:quotes-upload-chunks-per-session {:optional true} ::sm/int] [:auth-token-cookie-name {:optional true} :string] [:auth-token-cookie-max-age {:optional true} ::ct/duration] diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 383578531e..a1501e3ca0 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -388,6 +388,7 @@ :offload-file-data (ig/ref :app.tasks.offload-file-data/handler) :tasks-gc (ig/ref :app.tasks.tasks-gc/handler) :telemetry (ig/ref :app.tasks.telemetry/handler) + :upload-session-gc (ig/ref :app.tasks.upload-session-gc/handler) :storage-gc-deleted (ig/ref ::sto.gc-deleted/handler) :storage-gc-touched (ig/ref ::sto.gc-touched/handler) :session-gc (ig/ref ::session.tasks/gc) @@ -423,6 +424,9 @@ :app.tasks.tasks-gc/handler {::db/pool (ig/ref ::db/pool)} + :app.tasks.upload-session-gc/handler + {::db/pool (ig/ref ::db/pool)} + :app.tasks.objects-gc/handler {::db/pool (ig/ref ::db/pool) ::sto/storage (ig/ref ::sto/storage)} @@ -544,6 +548,9 @@ {:cron #penpot/cron "0 0 0 * * ?" ;; daily :task :tasks-gc} + {:cron #penpot/cron "0 0 0 * * ?" ;; daily + :task :upload-session-gc} + {:cron #penpot/cron "0 0 2 * * ?" ;; daily :task :file-gc-scheduler} diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index 3464902128..be9dc4bace 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -469,7 +469,10 @@ :fn (mg/resource "app/migrations/sql/0145-mod-audit-log-table.sql")} {:name "0146-mod-access-token-table" - :fn (mg/resource "app/migrations/sql/0146-mod-access-token-table.sql")}]) + :fn (mg/resource "app/migrations/sql/0146-mod-access-token-table.sql")} + + {:name "0147-add-upload-session-table" + :fn (mg/resource "app/migrations/sql/0147-add-upload-session-table.sql")}]) (defn apply-migrations! [pool name migrations] diff --git a/backend/src/app/migrations/sql/0147-add-upload-session-table.sql b/backend/src/app/migrations/sql/0147-add-upload-session-table.sql new file mode 100644 index 0000000000..eda1964785 --- /dev/null +++ b/backend/src/app/migrations/sql/0147-add-upload-session-table.sql @@ -0,0 +1,14 @@ +CREATE TABLE upload_session ( + id uuid PRIMARY KEY, + + created_at timestamptz NOT NULL DEFAULT now(), + + profile_id uuid NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + total_chunks integer NOT NULL +); + +CREATE INDEX upload_session__profile_id__idx + ON upload_session(profile_id); + +CREATE INDEX upload_session__created_at__idx + ON upload_session(created_at); diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index 743303c6a2..adb942bec8 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -22,6 +22,7 @@ [app.media :as media] [app.rpc :as-alias rpc] [app.rpc.commands.files :as files] + [app.rpc.commands.media :as media-cmd] [app.rpc.commands.projects :as projects] [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] @@ -80,20 +81,33 @@ ;; --- Command: import-binfile (defn- import-binfile - [{:keys [::db/pool] :as cfg} {:keys [profile-id project-id version name file]}] - (let [team (teams/get-team pool - :profile-id profile-id - :project-id project-id) - cfg (-> cfg - (assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team)) - (assoc ::bfc/project-id project-id) - (assoc ::bfc/profile-id profile-id) - (assoc ::bfc/name name) - (assoc ::bfc/input (:path file))) + [{:keys [::db/pool] :as cfg} {:keys [profile-id project-id version name file upload-id]}] + (let [team + (teams/get-team pool + :profile-id profile-id + :project-id project-id) - result (case (int version) - 1 (bf.v1/import-files! cfg) - 3 (bf.v3/import-files! cfg))] + cfg + (-> cfg + (assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team)) + (assoc ::bfc/project-id project-id) + (assoc ::bfc/profile-id profile-id) + (assoc ::bfc/name name)) + + input-path (:path file) + owned? (some? upload-id) + + cfg + (assoc cfg ::bfc/input input-path) + + result + (try + (case (int version) + 1 (bf.v1/import-files! cfg) + 3 (bf.v3/import-files! cfg)) + (finally + (when owned? + (fs/delete input-path))))] (db/update! pool :project {:modified-at (ct/now)} @@ -103,13 +117,18 @@ result)) (def ^:private schema:import-binfile - [:map {:title "import-binfile"} - [:name [:or [:string {:max 250}] - [:map-of ::sm/uuid [:string {:max 250}]]]] - [:project-id ::sm/uuid] - [:file-id {:optional true} ::sm/uuid] - [:version {:optional true} ::sm/int] - [:file media/schema:upload]]) + [:and + [:map {:title "import-binfile"} + [:name [:or [:string {:max 250}] + [:map-of ::sm/uuid [:string {:max 250}]]]] + [:project-id ::sm/uuid] + [:file-id {:optional true} ::sm/uuid] + [:version {:optional true} ::sm/int] + [:file {:optional true} media/schema:upload] + [:upload-id {:optional true} ::sm/uuid]] + [:fn {:error/message "one of :file or :upload-id is required"} + (fn [{:keys [file upload-id]}] + (or (some? file) (some? upload-id)))]]) (sv/defmethod ::import-binfile "Import a penpot file in a binary format. If `file-id` is provided, @@ -117,28 +136,40 @@ The in-place imports are only supported for binfile-v3 and when a .penpot file only contains one penpot file. + + The file content may be provided either as a multipart `file` upload + or as an `upload-id` referencing a completed chunked-upload session, + which allows importing files larger than the multipart size limit. " {::doc/added "1.15" ::doc/changes ["1.20" "Add file-id param for in-place import" - "1.20" "Set default version to 3"] + "1.20" "Set default version to 3" + "2.15" "Add upload-id param for chunked upload support"] ::webhooks/event? true ::sse/stream? true ::sm/params schema:import-binfile} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version file-id file] :as params}] + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version file-id upload-id] :as params}] (projects/check-edition-permissions! pool profile-id project-id) - (let [version (or version 3) - params (-> params - (assoc :profile-id profile-id) - (assoc :version version)) + (let [version (or version 3) + params (-> params + (assoc :profile-id profile-id) + (assoc :version version)) - cfg (cond-> cfg - (uuid? file-id) - (assoc ::bfc/file-id file-id)) + cfg (cond-> cfg + (uuid? file-id) + (assoc ::bfc/file-id file-id)) - manifest (case (int version) - 1 nil - 3 (bf.v3/get-manifest (:path file)))] + params + (if (some? upload-id) + (let [file (db/tx-run! cfg media-cmd/assemble-chunks upload-id)] + (assoc params :file file)) + params) + + manifest + (case (int version) + 1 nil + 3 (bf.v3/get-manifest (-> params :file :path)))] (with-meta (sse/response (partial import-binfile cfg params)) diff --git a/backend/src/app/rpc/commands/media.clj b/backend/src/app/rpc/commands/media.clj index 80e49b6366..5bea17d379 100644 --- a/backend/src/app/rpc/commands/media.clj +++ b/backend/src/app/rpc/commands/media.clj @@ -7,9 +7,11 @@ (ns app.rpc.commands.media (:require [app.common.data :as d] + [app.common.exceptions :as ex] [app.common.schema :as sm] [app.common.time :as ct] [app.common.uuid :as uuid] + [app.config :as cf] [app.db :as db] [app.loggers.audit :as-alias audit] [app.media :as media] @@ -17,8 +19,13 @@ [app.rpc.climit :as climit] [app.rpc.commands.files :as files] [app.rpc.doc :as-alias doc] + [app.rpc.quotes :as quotes] [app.storage :as sto] - [app.util.services :as sv])) + [app.storage.tmp :as tmp] + [app.util.services :as sv] + [datoteka.io :as io]) + (:import + java.io.OutputStream)) (def thumbnail-options {:width 100 @@ -236,3 +243,182 @@ :width (:width mobj) :height (:height mobj) :mtype (:mtype mobj)}))) + +;; --- Chunked Upload: Create an upload session + +(def ^:private schema:create-upload-session + [:map {:title "create-upload-session"} + [:total-chunks ::sm/int]]) + +(def ^:private schema:create-upload-session-result + [:map {:title "create-upload-session-result"} + [:session-id ::sm/uuid]]) + +(sv/defmethod ::create-upload-session + {::doc/added "2.16" + ::sm/params schema:create-upload-session + ::sm/result schema:create-upload-session-result} + [{:keys [::db/pool] :as cfg} + {:keys [::rpc/profile-id total-chunks]}] + + (let [max-chunks (cf/get :quotes-upload-chunks-per-session)] + (when (> total-chunks max-chunks) + (ex/raise :type :restriction + :code :max-quote-reached + :target "upload-chunks-per-session" + :quote max-chunks + :count total-chunks))) + + (quotes/check! cfg {::quotes/id ::quotes/upload-sessions-per-profile + ::quotes/profile-id profile-id}) + + (let [session-id (uuid/next)] + (db/insert! pool :upload-session + {:id session-id + :profile-id profile-id + :total-chunks total-chunks}) + {:session-id session-id})) + +;; --- Chunked Upload: Upload a single chunk + +(def ^:private schema:upload-chunk + [:map {:title "upload-chunk"} + [:session-id ::sm/uuid] + [:index ::sm/int] + [:content media/schema:upload]]) + +(def ^:private schema:upload-chunk-result + [:map {:title "upload-chunk-result"} + [:session-id ::sm/uuid] + [:index ::sm/int]]) + +(sv/defmethod ::upload-chunk + {::doc/added "2.16" + ::sm/params schema:upload-chunk + ::sm/result schema:upload-chunk-result} + [{:keys [::db/pool] :as cfg} + {:keys [::rpc/profile-id session-id index content] :as _params}] + (let [session (db/get pool :upload-session {:id session-id :profile-id profile-id})] + (when (or (neg? index) (>= index (:total-chunks session))) + (ex/raise :type :validation + :code :invalid-chunk-index + :hint "chunk index is out of range for this session" + :session-id session-id + :total-chunks (:total-chunks session) + :index index))) + + (let [storage (sto/resolve cfg) + data (sto/content (:path content))] + (sto/put-object! storage + {::sto/content data + ::sto/deduplicate? false + ::sto/touch true + :content-type (:mtype content) + :bucket "tempfile" + :upload-id (str session-id) + :chunk-index index})) + + {:session-id session-id + :index index}) + +;; --- Chunked Upload: shared helpers + +(def ^:private sql:get-upload-chunks + "SELECT id, size, (metadata->>'~:chunk-index')::integer AS chunk_index + FROM storage_object + WHERE (metadata->>'~:upload-id') = ?::text + AND deleted_at IS NULL + ORDER BY (metadata->>'~:chunk-index')::integer ASC") + +(defn- get-upload-chunks + [conn session-id] + (db/exec! conn [sql:get-upload-chunks (str session-id)])) + +(defn- concat-chunks + "Reads all chunk storage objects in order and writes them to a single + temporary file on the local filesystem. Returns a path to that file." + [storage chunks] + (let [tmp (tmp/tempfile :prefix "penpot.chunked-upload.")] + (with-open [^OutputStream out (io/output-stream tmp)] + (doseq [{:keys [id]} chunks] + (let [sobj (sto/get-object storage id) + bytes (sto/get-object-bytes storage sobj)] + (.write out ^bytes bytes)))) + tmp)) + +(defn assemble-chunks + "Validates that all expected chunks are present for `session-id` and + concatenates them into a single temporary file. Returns a map + conforming to `media/schema:upload` with `:filename`, `:path` and + `:size`. + + Raises a :validation/:missing-chunks error when the number of stored + chunks does not match `:total-chunks` recorded in the session row. + Deletes the session row from `upload_session` on success." + [{:keys [::db/conn] :as cfg} session-id] + (let [session (db/get conn :upload-session {:id session-id}) + chunks (get-upload-chunks conn session-id)] + + (when (not= (count chunks) (:total-chunks session)) + (ex/raise :type :validation + :code :missing-chunks + :hint "number of stored chunks does not match expected total" + :session-id session-id + :expected (:total-chunks session) + :found (count chunks))) + + (let [storage (sto/resolve cfg ::db/reuse-conn true) + path (concat-chunks storage chunks) + size (reduce #(+ %1 (:size %2)) 0 chunks)] + + (db/delete! conn :upload-session {:id session-id}) + + {:filename "upload" + :path path + :size size}))) + +;; --- Chunked Upload: Assemble all chunks into a final media object + +(def ^:private schema:assemble-file-media-object + [:map {:title "assemble-file-media-object"} + [:session-id ::sm/uuid] + [:file-id ::sm/uuid] + [:is-local ::sm/boolean] + [:name [:string {:max 250}]] + [:mtype :string] + [:id {:optional true} ::sm/uuid]]) + +(sv/defmethod ::assemble-file-media-object + {::doc/added "2.16" + ::sm/params schema:assemble-file-media-object + ::climit/id [[:process-image/by-profile ::rpc/profile-id] + [:process-image/global]]} + [{:keys [::db/pool] :as cfg} + {:keys [::rpc/profile-id session-id file-id is-local name mtype id] :as params}] + (files/check-edition-permissions! pool profile-id file-id) + + (db/tx-run! cfg + (fn [{:keys [::db/conn] :as cfg}] + (let [{:keys [path size]} (assemble-chunks cfg session-id) + content {:filename "upload" + :size size + :path path + :mtype mtype} + _ (media/validate-media-type! content) + mobj (create-file-media-object cfg (assoc params + :id (or id (uuid/next)) + :content content))] + + (db/update! conn :file + {:modified-at (ct/now) + :has-media-trimmed false} + {:id file-id} + {::db/return-keys false}) + + (with-meta mobj + {::audit/replace-props + {:name name + :file-id file-id + :is-local is-local + :mtype mtype}}))))) + diff --git a/backend/src/app/rpc/quotes.clj b/backend/src/app/rpc/quotes.clj index d5903744b3..1fe00e62d9 100644 --- a/backend/src/app/rpc/quotes.clj +++ b/backend/src/app/rpc/quotes.clj @@ -522,6 +522,30 @@ (assoc ::count-sql [sql:get-team-access-requests-per-requester profile-id]) (generic-check!))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; QUOTE: UPLOAD-SESSIONS-PER-PROFILE +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def ^:private schema:upload-sessions-per-profile + [:map [::profile-id ::sm/uuid]]) + +(def ^:private valid-upload-sessions-per-profile-quote? + (sm/lazy-validator schema:upload-sessions-per-profile)) + +(def ^:private sql:get-upload-sessions-per-profile + "SELECT count(*) AS total + FROM upload_session + WHERE profile_id = ?") + +(defmethod check-quote ::upload-sessions-per-profile + [{:keys [::profile-id ::target] :as quote}] + (assert (valid-upload-sessions-per-profile-quote? quote) "invalid quote parameters") + (-> quote + (assoc ::default (cf/get :quotes-upload-sessions-per-profile Integer/MAX_VALUE)) + (assoc ::quote-sql [sql:get-quotes-1 target profile-id]) + (assoc ::count-sql [sql:get-upload-sessions-per-profile profile-id]) + (generic-check!))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; QUOTE: DEFAULT ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/backend/src/app/storage/gc_touched.clj b/backend/src/app/storage/gc_touched.clj index 33158600c2..f00140d04e 100644 --- a/backend/src/app/storage/gc_touched.clj +++ b/backend/src/app/storage/gc_touched.clj @@ -149,7 +149,7 @@ :status "delete" :bucket bucket) (recur to-freeze (conj to-delete id) (rest objects)))) - (let [deletion-delay (if (= bucket "tempfile") + (let [deletion-delay (if (= "tempfile" bucket) (ct/duration {:hours 2}) (cf/get-deletion-delay))] (some->> (seq to-freeze) (mark-freeze-in-bulk! conn)) diff --git a/backend/src/app/tasks/upload_session_gc.clj b/backend/src/app/tasks/upload_session_gc.clj new file mode 100644 index 0000000000..c733bbd64e --- /dev/null +++ b/backend/src/app/tasks/upload_session_gc.clj @@ -0,0 +1,41 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.tasks.upload-session-gc + "A maintenance task that deletes stalled (incomplete) upload sessions. + + An upload session is considered stalled when it was created more than + `max-age` ago without being completed (i.e. the session row still + exists because `assemble-chunks` was never called to clean it up). + The default max-age is 1 hour." + (:require + [app.common.logging :as l] + [app.common.time :as ct] + [app.db :as db] + [integrant.core :as ig])) + +(def ^:private sql:delete-stalled-sessions + "DELETE FROM upload_session + WHERE created_at < ?::timestamptz") + +(defmethod ig/assert-key ::handler + [_ params] + (assert (db/pool? (::db/pool params)) "expected a valid database pool")) + +(defmethod ig/expand-key ::handler + [k v] + {k (merge {::max-age (ct/duration {:hours 1})} v)}) + +(defmethod ig/init-key ::handler + [_ {:keys [::max-age] :as cfg}] + (fn [_] + (db/tx-run! cfg + (fn [{:keys [::db/conn]}] + (let [threshold (ct/minus (ct/now) max-age) + result (-> (db/exec-one! conn [sql:delete-stalled-sessions threshold]) + (db/get-update-count))] + (l/debug :hint "task finished" :deleted result) + {:deleted result}))))) diff --git a/backend/test/backend_tests/rpc_media_test.clj b/backend/test/backend_tests/rpc_media_test.clj index 79df6d38b4..070a105a1b 100644 --- a/backend/test/backend_tests/rpc_media_test.clj +++ b/backend/test/backend_tests/rpc_media_test.clj @@ -6,9 +6,7 @@ (ns backend-tests.rpc-media-test (:require - [app.common.time :as ct] [app.common.uuid :as uuid] - [app.db :as db] [app.http.client :as http] [app.media :as media] [app.rpc :as-alias rpc] @@ -16,7 +14,10 @@ [backend-tests.helpers :as th] [clojure.test :as t] [datoteka.fs :as fs] - [mockery.core :refer [with-mocks]])) + [datoteka.io :as io] + [mockery.core :refer [with-mocks]]) + (:import + java.io.RandomAccessFile)) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -260,7 +261,7 @@ :is-shared false}) _ (th/db-update! :file - {:deleted-at (ct/now)} + {:deleted-at (app.common.time/now)} {:id (:id file)}) mfile {:filename "sample.jpg" @@ -378,3 +379,325 @@ (t/is (some? err)) (t/is (= :validation (:type (ex-data err)))) (t/is (= :unable-to-download-image (:code (ex-data err)))))))) + +;; -------------------------------------------------------------------- +;; Helpers for chunked-upload tests +;; -------------------------------------------------------------------- + +(defn- split-file-into-chunks + "Splits the file at `path` into byte-array chunks of at most + `chunk-size` bytes. Returns a vector of byte arrays." + [path chunk-size] + (let [file (RandomAccessFile. (str path) "r") + length (.length file)] + (try + (loop [offset 0 chunks []] + (if (>= offset length) + chunks + (let [remaining (- length offset) + size (min chunk-size remaining) + buf (byte-array size)] + (.seek file offset) + (.readFully file buf) + (recur (+ offset size) (conj chunks buf))))) + (finally + (.close file))))) + +(defn- make-chunk-mfile + "Writes `data` (byte array) to a tempfile and returns a map + compatible with `media/schema:upload`." + [data mtype] + (let [tmp (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-chunk-")] + (io/write* tmp data) + {:filename "chunk" + :path tmp + :mtype mtype + :size (alength data)})) + +;; -------------------------------------------------------------------- +;; Chunked-upload tests +;; -------------------------------------------------------------------- + +(defn- create-session! + "Creates an upload session for `prof` with `total-chunks`. Returns the session-id UUID." + [prof total-chunks] + (let [out (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks total-chunks})] + (t/is (nil? (:error out))) + (:session-id (:result out)))) + +(t/deftest chunked-upload-happy-path + (let [prof (th/create-profile* 1) + _ (th/create-project* 1 {:profile-id (:id prof) + :team-id (:default-team-id prof)}) + file (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + source-path (th/tempfile "backend_tests/test_files/sample.jpg") + chunks (split-file-into-chunks source-path 110000) ; ~107 KB each + mtype "image/jpeg" + total-size (reduce + (map alength chunks)) + session-id (create-session! prof (count chunks))] + + (t/is (= 3 (count chunks))) + + ;; --- 1. Upload chunks --- + (doseq [[idx chunk-data] (map-indexed vector chunks)] + (let [mfile (make-chunk-mfile chunk-data mtype) + out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index idx + :content mfile})] + (t/is (nil? (:error out))) + (t/is (= session-id (:session-id (:result out)))) + (t/is (= idx (:index (:result out)))))) + + ;; --- 2. Assemble --- + (let [assemble-out (th/command! {::th/type :assemble-file-media-object + ::rpc/profile-id (:id prof) + :session-id session-id + :file-id (:id file) + :is-local true + :name "assembled-image" + :mtype mtype})] + + (t/is (nil? (:error assemble-out))) + (let [{:keys [media-id thumbnail-id] :as result} (:result assemble-out)] + (t/is (= (:id file) (:file-id result))) + (t/is (= 800 (:width result))) + (t/is (= 800 (:height result))) + (t/is (= mtype (:mtype result))) + (t/is (uuid? media-id)) + (t/is (uuid? thumbnail-id)) + + (let [storage (:app.storage/storage th/*system*) + mobj1 (sto/get-object storage media-id) + mobj2 (sto/get-object storage thumbnail-id)] + (t/is (sto/object? mobj1)) + (t/is (sto/object? mobj2)) + (t/is (= total-size (:size mobj1)))))))) + +(t/deftest chunked-upload-idempotency + (let [prof (th/create-profile* 1) + _ (th/create-project* 1 {:profile-id (:id prof) + :team-id (:default-team-id prof)}) + file (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + media-id (uuid/next) + source-path (th/tempfile "backend_tests/test_files/sample.jpg") + chunks (split-file-into-chunks source-path 312043) ; single chunk = whole file + mtype "image/jpeg" + mfile (make-chunk-mfile (first chunks) mtype) + session-id (create-session! prof 1)] + + (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile}) + + ;; First assemble succeeds; session row is deleted afterwards + (let [out1 (th/command! {::th/type :assemble-file-media-object + ::rpc/profile-id (:id prof) + :session-id session-id + :file-id (:id file) + :is-local true + :name "sample" + :mtype mtype + :id media-id})] + (t/is (nil? (:error out1))) + (t/is (= media-id (:id (:result out1))))) + + ;; Second assemble with the same session-id must fail because the + ;; session row has been deleted after the first assembly + (let [out2 (th/command! {::th/type :assemble-file-media-object + ::rpc/profile-id (:id prof) + :session-id session-id + :file-id (:id file) + :is-local true + :name "sample" + :mtype mtype + :id media-id})] + (t/is (some? (:error out2))) + (t/is (= :not-found (-> out2 :error ex-data :type))) + (t/is (= :object-not-found (-> out2 :error ex-data :code)))))) + +(t/deftest chunked-upload-no-permission + ;; A second profile must not be able to upload chunks into a session + ;; that belongs to another profile: the DB lookup includes profile-id, + ;; so the session will not be found. + (let [prof1 (th/create-profile* 1) + prof2 (th/create-profile* 2) + session-id (create-session! prof1 1) + source-path (th/tempfile "backend_tests/test_files/sample.jpg") + mfile {:filename "sample.jpg" + :path source-path + :mtype "image/jpeg" + :size 312043} + + ;; prof2 tries to upload a chunk into prof1's session + out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof2) + :session-id session-id + :index 0 + :content mfile})] + + (t/is (some? (:error out))) + (t/is (= :not-found (-> out :error ex-data :type))))) + +(t/deftest chunked-upload-invalid-media-type + (let [prof (th/create-profile* 1) + _ (th/create-project* 1 {:profile-id (:id prof) + :team-id (:default-team-id prof)}) + file (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + session-id (create-session! prof 1) + source-path (th/tempfile "backend_tests/test_files/sample.jpg") + mfile {:filename "sample.jpg" + :path source-path + :mtype "image/jpeg" + :size 312043}] + + (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile}) + + ;; Assemble with a wrong mtype should fail validation + (let [out (th/command! {::th/type :assemble-file-media-object + ::rpc/profile-id (:id prof) + :session-id session-id + :file-id (:id file) + :is-local true + :name "bad-type" + :mtype "application/octet-stream"})] + (t/is (some? (:error out))) + (t/is (= :validation (-> out :error ex-data :type)))))) + +(t/deftest chunked-upload-missing-chunks + (let [prof (th/create-profile* 1) + _ (th/create-project* 1 {:profile-id (:id prof) + :team-id (:default-team-id prof)}) + file (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + ;; Session expects 3 chunks + session-id (create-session! prof 3) + source-path (th/tempfile "backend_tests/test_files/sample.jpg") + mfile {:filename "sample.jpg" + :path source-path + :mtype "image/jpeg" + :size 312043}] + + ;; Upload only 1 chunk + (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile}) + + ;; Assemble: session says 3 expected, only 1 stored → :missing-chunks + (let [out (th/command! {::th/type :assemble-file-media-object + ::rpc/profile-id (:id prof) + :session-id session-id + :file-id (:id file) + :is-local true + :name "incomplete" + :mtype "image/jpeg"})] + (t/is (some? (:error out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (= :missing-chunks (-> out :error ex-data :code)))))) + +(t/deftest chunked-upload-session-not-found + (let [prof (th/create-profile* 1) + _ (th/create-project* 1 {:profile-id (:id prof) + :team-id (:default-team-id prof)}) + file (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + bogus-id (uuid/next)] + + ;; Assemble with a session-id that was never created + (let [out (th/command! {::th/type :assemble-file-media-object + ::rpc/profile-id (:id prof) + :session-id bogus-id + :file-id (:id file) + :is-local true + :name "ghost" + :mtype "image/jpeg"})] + (t/is (some? (:error out))) + (t/is (= :not-found (-> out :error ex-data :type))) + (t/is (= :object-not-found (-> out :error ex-data :code)))))) + +(t/deftest chunked-upload-over-chunk-limit + ;; Verify that requesting more chunks than the configured maximum + ;; (quotes-upload-chunks-per-session) raises a :restriction error. + (with-mocks [mock {:target 'app.config/get + :return (th/config-get-mock + {:quotes-upload-chunks-per-session 3})}] + (let [prof (th/create-profile* 1) + out (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks 4})] + + (t/is (some? (:error out))) + (t/is (= :restriction (-> out :error ex-data :type))) + (t/is (= :max-quote-reached (-> out :error ex-data :code))) + (t/is (= "upload-chunks-per-session" (-> out :error ex-data :target)))))) + +(t/deftest chunked-upload-invalid-chunk-index + ;; Both a negative index and an index >= total-chunks must be + ;; rejected with a :validation / :invalid-chunk-index error. + (let [prof (th/create-profile* 1) + session-id (create-session! prof 2) + source-path (th/tempfile "backend_tests/test_files/sample.jpg") + mfile {:filename "sample.jpg" + :path source-path + :mtype "image/jpeg" + :size 312043}] + + ;; index == total-chunks (out of range) + (let [out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 2 + :content mfile})] + (t/is (some? (:error out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (= :invalid-chunk-index (-> out :error ex-data :code)))) + + ;; negative index + (let [out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index -1 + :content mfile})] + (t/is (some? (:error out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (= :invalid-chunk-index (-> out :error ex-data :code)))))) + +(t/deftest chunked-upload-sessions-per-profile-quota + ;; With the session limit set to 2, creating a third session for the + ;; same profile must fail with :restriction / :max-quote-reached. + ;; The :quotes flag is already enabled by the test fixture. + (with-mocks [mock {:target 'app.config/get + :return (th/config-get-mock + {:quotes-upload-sessions-per-profile 2})}] + (let [prof (th/create-profile* 1)] + + ;; First two sessions succeed + (create-session! prof 1) + (create-session! prof 1) + + ;; Third session must be rejected + (let [out (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks 1})] + (t/is (some? (:error out))) + (t/is (= :restriction (-> out :error ex-data :type))) + (t/is (= :max-quote-reached (-> out :error ex-data :code))))))) diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index 5059820ede..efda9a9356 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -160,6 +160,7 @@ (def plugins-list-uri (obj/get global "penpotPluginsListUri" "https://penpot.app/penpothub/plugins")) (def plugins-whitelist (into #{} (obj/get global "penpotPluginsWhitelist" []))) (def templates-uri (obj/get global "penpotTemplatesUri" "https://penpot.github.io/penpot-files/")) +(def upload-chunk-size (obj/get global "penpotUploadChunkSize" (* 1024 1024 25))) ;; 25 MiB ;; We set the current parsed flags under common for make ;; it available for common code without the need to pass diff --git a/frontend/src/app/main/data/uploads.cljs b/frontend/src/app/main/data/uploads.cljs new file mode 100644 index 0000000000..06e87f02d9 --- /dev/null +++ b/frontend/src/app/main/data/uploads.cljs @@ -0,0 +1,70 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.data.uploads + "Generic chunked-upload helpers. + + Provides a purpose-agnostic three-step session API that can be used + by any feature that needs to upload large binary blobs: + + 1. create-upload-session – obtain a session-id + 2. upload-chunk – upload each slice (max-parallel-chunk-uploads in-flight) + 3. caller-specific step – e.g. assemble-file-media-object or import-binfile + + `upload-blob-chunked` drives steps 1 and 2 and emits the completed + `{:session-id …}` map so that the caller can proceed with its own + step 3." + (:require + [app.common.data.macros :as dm] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.main.repo :as rp] + [beicon.v2.core :as rx])) + +;; Size of each upload chunk in bytes. Reads the penpotUploadChunkSize global +;; variable at startup; defaults to 25 MiB (overridden in production). +(def ^:private chunk-size cf/upload-chunk-size) + +(def ^:private max-parallel-chunk-uploads + "Maximum number of chunk upload requests that may be in-flight at the + same time within a single chunked upload session." + 2) + +(defn upload-blob-chunked + "Uploads `blob` via the three-step chunked session API. + + Steps performed: + 1. Creates an upload session (`create-upload-session`). + 2. Slices `blob` and uploads every chunk (`upload-chunk`), + with at most `max-parallel-chunk-uploads` concurrent requests. + + Returns an observable that emits exactly one map: + `{:session-id }` + + The caller is responsible for the final step (assemble / import)." + [blob] + (let [total-size (.-size blob) + total-chunks (js/Math.ceil (/ total-size chunk-size))] + (->> (rp/cmd! :create-upload-session + {:total-chunks total-chunks}) + (rx/mapcat + (fn [{raw-session-id :session-id}] + (let [session-id (cond-> raw-session-id + (string? raw-session-id) uuid/uuid) + chunk-uploads + (->> (range total-chunks) + (map (fn [idx] + (let [start (* idx chunk-size) + end (min (+ start chunk-size) total-size) + chunk (.slice blob start end)] + (rp/cmd! :upload-chunk + {:session-id session-id + :index idx + :content (list chunk (dm/str "chunk-" idx))})))))] + (->> (rx/from chunk-uploads) + (rx/merge-all max-parallel-chunk-uploads) + (rx/last) + (rx/map (fn [_] {:session-id session-id}))))))))) diff --git a/frontend/src/app/main/data/workspace/media.cljs b/frontend/src/app/main/data/workspace/media.cljs index 0d1e1c6e32..bcffef8378 100644 --- a/frontend/src/app/main/data/workspace/media.cljs +++ b/frontend/src/app/main/data/workspace/media.cljs @@ -24,6 +24,7 @@ [app.main.data.helpers :as dsh] [app.main.data.media :as dmm] [app.main.data.notifications :as ntf] + [app.main.data.uploads :as uploads] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.svg-upload :as svg] [app.main.repo :as rp] @@ -103,6 +104,26 @@ :url url :is-local true})) +;; Size of each upload chunk in bytes — read from config directly, +;; same source used by the uploads namespace. +(def ^:private chunk-size cf/upload-chunk-size) + +(defn- upload-blob-chunked + "Uploads `blob` to `file-id` as a chunked media object using the + three-step session API. Returns an observable that emits the + assembled file-media-object map." + [{:keys [file-id name is-local blob]}] + (let [mtype (.-type blob)] + (->> (uploads/upload-blob-chunked blob) + (rx/mapcat + (fn [{:keys [session-id]}] + (rp/cmd! :assemble-file-media-object + {:session-id session-id + :file-id file-id + :is-local is-local + :name name + :mtype mtype})))))) + (defn process-uris [{:keys [file-id local? name uris mtype on-image on-svg]}] (letfn [(svg-url? [url] @@ -143,12 +164,18 @@ (and (not force-media) (= (.-type blob) "image/svg+xml"))) - (prepare-blob [blob] - (let [name (or name (if (dmm/file? blob) (media/strip-image-extension (.-name blob)) "blob"))] - {:file-id file-id - :name name - :is-local local? - :content blob})) + (upload-blob [blob] + (let [params {:file-id file-id + :name (or name (if (dmm/file? blob) (media/strip-image-extension (.-name blob)) "blob")) + :is-local local? + :blob blob}] + (if (>= (.-size blob) chunk-size) + (upload-blob-chunked params) + (rp/cmd! :upload-file-media-object + {:file-id file-id + :name (:name params) + :is-local local? + :content blob})))) (extract-content [blob] (let [name (or name (.-name blob))] @@ -159,8 +186,7 @@ (->> (rx/from blobs) (rx/map dmm/validate-file) (rx/filter (comp not svg-blob?)) - (rx/map prepare-blob) - (rx/mapcat #(rp/cmd! :upload-file-media-object %)) + (rx/mapcat upload-blob) (rx/tap on-image)) (->> (rx/from blobs) @@ -170,9 +196,10 @@ (rx/merge-map svg->clj) (rx/tap on-svg))))) -(defn handle-media-error [error on-error] - (if (ex/ex-info? error) - (handle-media-error (ex-data error) on-error) +(defn handle-media-error + [cause] + (ex/print-throwable cause) + (let [error (ex-data cause)] (cond (= (:code error) :invalid-svg-file) (rx/of (ntf/error (tr "errors.media-type-not-allowed"))) @@ -195,13 +222,8 @@ (= (:code error) :unable-to-optimize) (rx/of (ntf/error (:hint error))) - (fn? on-error) - (on-error error) - :else - (do - (.error js/console "ERROR" error) - (rx/of (ntf/error (tr "errors.cannot-upload"))))))) + (rx/of (ntf/error (tr "errors.cannot-upload")))))) (def ^:private @@ -215,7 +237,7 @@ [:mtype {:optional true} :string]]) (defn- process-media-objects - [{:keys [uris on-error] :as params}] + [{:keys [uris] :as params}] (dm/assert! (and (sm/check schema:process-media-objects params) (or (contains? params :blobs) @@ -238,7 +260,7 @@ ;; Every stream has its own sideeffect. We need to ignore the result (rx/ignore) - (rx/catch #(handle-media-error % on-error)) + (rx/catch handle-media-error) (rx/finalize #(st/emit! (ntf/hide :tag :media-loading)))))))) (defn upload-media-workspace @@ -278,8 +300,6 @@ (rx/tap on-upload-success) (rx/catch handle-media-error)))))) -;; --- Upload File Media objects - (defn create-shapes-svg "Convert svg elements into penpot shapes." [file-id objects pos svg-data] diff --git a/frontend/src/app/main/repo.cljs b/frontend/src/app/main/repo.cljs index 6f264e5d02..1e6dd417a6 100644 --- a/frontend/src/app/main/repo.cljs +++ b/frontend/src/app/main/repo.cljs @@ -139,8 +139,7 @@ {:stream? true} ::sse/import-binfile - {:stream? true - :form-data? true} + {:stream? true} ::sse/permanently-delete-team-files {:stream? true} @@ -273,6 +272,7 @@ (send-export (merge default params)))) (derive :upload-file-media-object ::multipart-upload) +(derive :upload-chunk ::multipart-upload) (derive :update-profile-photo ::multipart-upload) (derive :update-team-photo ::multipart-upload) diff --git a/frontend/src/app/worker/import.cljs b/frontend/src/app/worker/import.cljs index a191b9466f..20c314f012 100644 --- a/frontend/src/app/worker/import.cljs +++ b/frontend/src/app/worker/import.cljs @@ -11,6 +11,7 @@ [app.common.logging :as log] [app.common.schema :as sm] [app.common.uuid :as uuid] + [app.main.data.uploads :as uploads] [app.main.repo :as rp] [app.util.http :as http] [app.util.i18n :as i18n :refer [tr]] @@ -129,6 +130,23 @@ (->> (rx/from files) (rx/merge-map analyze-file))) +(defn- import-blob-via-upload + "Fetches `uri` as a Blob, uploads it using the generic chunked-upload + session API and calls `import-binfile` with the resulting upload-id. + Returns an observable of SSE events from the import stream." + [uri {:keys [name version project-id]}] + (->> (slurp-uri uri :blob) + (rx/mapcat + (fn [blob] + (->> (uploads/upload-blob-chunked blob) + (rx/mapcat + (fn [{:keys [session-id]}] + (rp/cmd! ::sse/import-binfile + {:name name + :upload-id session-id + :version version + :project-id project-id})))))))) + (defmethod impl/handler :import-files [{:keys [project-id files]}] (let [binfile-v1 (filter #(= :binfile-v1 (:type %)) files) @@ -138,31 +156,22 @@ (->> (rx/from binfile-v1) (rx/merge-map (fn [data] - (->> (http/send! - {:uri (:uri data) - :response-type :blob - :method :get}) - (rx/map :body) - (rx/mapcat - (fn [file] - (->> (rp/cmd! ::sse/import-binfile - {:name (str/replace (:name data) #".penpot$" "") - :file file - :version 1 - :project-id project-id}) - (rx/tap (fn [event] - (let [payload (sse/get-payload event) - type (sse/get-type event)] - (if (= type "progress") - (log/dbg :hint "import-binfile: progress" - :section (:section payload) - :name (:name payload)) - (log/dbg :hint "import-binfile: end"))))) - (rx/filter sse/end-of-stream?) - (rx/map (fn [_] - {:status :finish - :file-id (:file-id data)}))))) - + (->> (import-blob-via-upload (:uri data) + {:name (str/replace (:name data) #".penpot$" "") + :version 1 + :project-id project-id}) + (rx/tap (fn [event] + (let [payload (sse/get-payload event) + type (sse/get-type event)] + (if (= type "progress") + (log/dbg :hint "import-binfile: progress" + :section (:section payload) + :name (:name payload)) + (log/dbg :hint "import-binfile: end"))))) + (rx/filter sse/end-of-stream?) + (rx/map (fn [_] + {:status :finish + :file-id (:file-id data)})) (rx/catch (fn [cause] (log/error :hint "unexpected error on import process" @@ -179,29 +188,24 @@ (rx/mapcat identity) (rx/merge-map (fn [[uri entries]] - (->> (slurp-uri uri :blob) - (rx/mapcat (fn [content] - ;; FIXME: implement the naming and filtering - (->> (rp/cmd! ::sse/import-binfile - {:name (-> entries first :name) - :file content - :version 3 - :project-id project-id}) - (rx/tap (fn [event] - (let [payload (sse/get-payload event) - type (sse/get-type event)] - (if (= type "progress") - (log/dbg :hint "import-binfile: progress" - :section (:section payload) - :name (:name payload)) - (log/dbg :hint "import-binfile: end"))))) - (rx/filter sse/end-of-stream?) - (rx/mapcat (fn [_] - (->> (rx/from entries) - (rx/map (fn [entry] - {:status :finish - :file-id (:file-id entry)})))))))) - + (->> (import-blob-via-upload uri + {:name (-> entries first :name) + :version 3 + :project-id project-id}) + (rx/tap (fn [event] + (let [payload (sse/get-payload event) + type (sse/get-type event)] + (if (= type "progress") + (log/dbg :hint "import-binfile: progress" + :section (:section payload) + :name (:name payload)) + (log/dbg :hint "import-binfile: end"))))) + (rx/filter sse/end-of-stream?) + (rx/mapcat (fn [_] + (->> (rx/from entries) + (rx/map (fn [entry] + {:status :finish + :file-id (:file-id entry)}))))) (rx/catch (fn [cause] (log/error :hint "unexpected error on import process" @@ -213,5 +217,3 @@ {:status :error :error (ex-message cause) :file-id (:file-id entry)})))))))))))) - - diff --git a/frontend/test/frontend_tests/data/uploads_test.cljs b/frontend/test/frontend_tests/data/uploads_test.cljs new file mode 100644 index 0000000000..1512fcb90b --- /dev/null +++ b/frontend/test/frontend_tests/data/uploads_test.cljs @@ -0,0 +1,117 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns frontend-tests.data.uploads-test + "Integration tests for the generic chunked-upload logic in + app.main.data.uploads." + (:require + [app.common.uuid :as uuid] + [app.config :as cf] + [app.main.data.uploads :as uploads] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.http :as http])) + +;; --------------------------------------------------------------------------- +;; Local helpers +;; --------------------------------------------------------------------------- + +(defn- make-blob + "Creates a JS Blob of exactly `size` bytes." + [size] + (let [buf (js/Uint8Array. size)] + (js/Blob. #js [buf] #js {:type "application/octet-stream"}))) + +;; --------------------------------------------------------------------------- +;; upload-blob-chunked tests +;; --------------------------------------------------------------------------- + +(t/deftest upload-blob-chunked-creates-session-and-uploads-chunks + (t/testing "upload-blob-chunked calls create-upload-session then upload-chunk for each slice" + (t/async done + (let [session-id (uuid/next) + chunk-size cf/upload-chunk-size + ;; Exactly two full chunks + blob-size (* 2 chunk-size) + blob (make-blob blob-size) + calls (atom []) + + fetch-mock + (fn [url _opts] + (let [cmd (http/url->cmd url)] + (swap! calls conj cmd) + (js/Promise.resolve + (case cmd + :create-upload-session + (http/make-transit-response + {:session-id session-id}) + + :upload-chunk + (http/make-transit-response + {:session-id session-id :index 0}) + + (http/make-json-response + {:error (str "unexpected cmd: " cmd)}))))) + + orig (http/install-fetch-mock! fetch-mock)] + + (->> (uploads/upload-blob-chunked blob) + (rx/subs! + (fn [{:keys [session-id]}] + (t/is (uuid? session-id))) + (fn [err] + (t/is false (str "unexpected error: " (ex-message err))) + (done)) + (fn [] + (http/restore-fetch! orig) + (let [cmd-seq @calls] + ;; First call must create the session + (t/is (= :create-upload-session (first cmd-seq))) + ;; Two chunk uploads + (t/is (= 2 (count (filter #(= :upload-chunk %) cmd-seq)))) + ;; No assemble call here — that's the caller's responsibility + (t/is (not (some #(= :assemble-file-media-object %) cmd-seq)))) + (done)))))))) + +(t/deftest upload-blob-chunked-chunk-count-matches-blob + (t/testing "number of upload-chunk calls equals ceil(blob-size / chunk-size)" + (t/async done + (let [session-id (uuid/next) + chunk-size cf/upload-chunk-size + ;; Three chunks: 2 full + 1 partial + blob-size (+ (* 2 chunk-size) 1) + blob (make-blob blob-size) + chunk-calls (atom 0) + + fetch-mock + (fn [url _opts] + (let [cmd (http/url->cmd url)] + (js/Promise.resolve + (case cmd + :create-upload-session + (http/make-transit-response + {:session-id session-id}) + + :upload-chunk + (do (swap! chunk-calls inc) + (http/make-transit-response + {:session-id session-id :index 0})) + + (http/make-json-response + {:error (str "unexpected cmd: " cmd)}))))) + + orig (http/install-fetch-mock! fetch-mock)] + + (->> (uploads/upload-blob-chunked blob) + (rx/subs! + (fn [_] nil) + (fn [err] + (t/is false (str "unexpected error: " (ex-message err))) + (done)) + (fn [] + (http/restore-fetch! orig) + (t/is (= 3 @chunk-calls)) + (done)))))))) diff --git a/frontend/test/frontend_tests/data/workspace_media_test.cljs b/frontend/test/frontend_tests/data/workspace_media_test.cljs new file mode 100644 index 0000000000..915adb203b --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_media_test.cljs @@ -0,0 +1,189 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns frontend-tests.data.workspace-media-test + "Integration tests for the chunked-upload logic in + app.main.data.workspace.media." + (:require + [app.common.uuid :as uuid] + [app.config :as cf] + [app.main.data.workspace.media :as media] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.http :as http])) + +;; --------------------------------------------------------------------------- +;; Local helpers +;; --------------------------------------------------------------------------- + +(defn- make-blob + "Creates a JS Blob of exactly `size` bytes with the given `mtype`." + [size mtype] + (let [buf (js/Uint8Array. size)] + (js/Blob. #js [buf] #js {:type mtype}))) + +;; --------------------------------------------------------------------------- +;; Small-file path: direct upload (no chunking) +;; --------------------------------------------------------------------------- + +(t/deftest small-file-uses-direct-upload + (t/testing "blobs below chunk-size use :upload-file-media-object directly" + (t/async done + (let [file-id (uuid/next) + ;; One byte below the threshold so the blob takes the direct path + blob-size (dec cf/upload-chunk-size) + blob (make-blob blob-size "image/jpeg") + calls (atom []) + + fetch-mock + (fn [url _opts] + (let [cmd (http/url->cmd url)] + (swap! calls conj cmd) + (js/Promise.resolve + (http/make-json-response + {:id (str (uuid/next)) + :name "img" + :width 100 + :height 100 + :mtype "image/jpeg" + :file-id (str file-id)})))) + + orig (http/install-fetch-mock! fetch-mock)] + + (->> (media/process-blobs + {:file-id file-id + :local? true + :blobs [blob] + :on-image (fn [_] nil) + :on-svg (fn [_] nil)}) + (rx/subs! + (fn [_] nil) + (fn [err] + (t/is false (str "unexpected error: " (ex-message err))) + (done)) + (fn [] + (http/restore-fetch! orig) + ;; Should call :upload-file-media-object, NOT the chunked API + (t/is (= 1 (count @calls))) + (t/is (= :upload-file-media-object (first @calls))) + (done)))))))) + +;; --------------------------------------------------------------------------- +;; Large-file path: chunked upload via uploads namespace +;; --------------------------------------------------------------------------- + +(t/deftest large-file-uses-chunked-upload + (t/testing "blobs at or above chunk-size use the three-step session API" + (t/async done + (let [file-id (uuid/next) + session-id (uuid/next) + chunk-size cf/upload-chunk-size + ;; Exactly two full chunks + blob-size (* 2 chunk-size) + blob (make-blob blob-size "image/jpeg") + calls (atom []) + + fetch-mock + (fn [url _opts] + (let [cmd (http/url->cmd url)] + (swap! calls conj cmd) + (js/Promise.resolve + (http/make-json-response + (case cmd + :create-upload-session + {:session-id (str session-id)} + + :upload-chunk + {:session-id (str session-id) :index 0} + + :assemble-file-media-object + {:id (str (uuid/next)) + :name "img" + :width 100 + :height 100 + :mtype "image/jpeg" + :file-id (str file-id)} + + ;; Default: return an error response + {:error (str "unexpected cmd: " cmd)}))))) + + orig (http/install-fetch-mock! fetch-mock)] + + (->> (media/process-blobs + {:file-id file-id + :local? true + :blobs [blob] + :on-image (fn [_] nil) + :on-svg (fn [_] nil)}) + (rx/subs! + (fn [_] nil) + (fn [err] + (t/is false (str "unexpected error: " (ex-message err))) + (done)) + (fn [] + (http/restore-fetch! orig) + (let [cmd-seq @calls] + ;; First call must create the session + (t/is (= :create-upload-session (first cmd-seq))) + ;; Two chunk uploads + (t/is (= 2 (count (filter #(= :upload-chunk %) cmd-seq)))) + ;; Last call must assemble + (t/is (= :assemble-file-media-object (last cmd-seq))) + ;; Direct upload must NOT be called + (t/is (not (some #(= :upload-file-media-object %) cmd-seq)))) + (done)))))))) + +(t/deftest chunked-upload-chunk-count-matches-blob + (t/testing "number of chunk upload calls equals ceil(blob-size / chunk-size)" + (t/async done + (let [file-id (uuid/next) + session-id (uuid/next) + chunk-size cf/upload-chunk-size + ;; Three chunks: 2 full + 1 partial + blob-size (+ (* 2 chunk-size) 1) + blob (make-blob blob-size "image/jpeg") + chunk-calls (atom 0) + + fetch-mock + (fn [url _opts] + (let [cmd (http/url->cmd url)] + (js/Promise.resolve + (http/make-json-response + (case cmd + :create-upload-session + {:session-id (str session-id)} + + :upload-chunk + (do (swap! chunk-calls inc) + {:session-id (str session-id) :index 0}) + + :assemble-file-media-object + {:id (str (uuid/next)) + :name "img" + :width 100 + :height 100 + :mtype "image/jpeg" + :file-id (str file-id)} + + {:error (str "unexpected cmd: " cmd)}))))) + + orig (http/install-fetch-mock! fetch-mock)] + + (->> (media/process-blobs + {:file-id file-id + :local? true + :blobs [blob] + :on-image (fn [_] nil) + :on-svg (fn [_] nil)}) + (rx/subs! + (fn [_] nil) + (fn [err] + (t/is false (str "unexpected error: " (ex-message err))) + (done)) + (fn [] + (http/restore-fetch! orig) + (t/is (= 3 @chunk-calls)) + (done)))))))) diff --git a/frontend/test/frontend_tests/helpers/http.cljs b/frontend/test/frontend_tests/helpers/http.cljs new file mode 100644 index 0000000000..28895f4049 --- /dev/null +++ b/frontend/test/frontend_tests/helpers/http.cljs @@ -0,0 +1,61 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns frontend-tests.helpers.http + "Helpers for intercepting and mocking the global `fetch` function in + ClojureScript tests. The underlying HTTP layer (`app.util.http`) calls + `(js/fetch url params)` directly, so replacing `globalThis.fetch` is the + correct interception point." + (:require + [app.common.transit :as t] + [clojure.string :as str])) + +(defn install-fetch-mock! + "Replaces the global `js/fetch` with `handler-fn`. + + `handler-fn` is called with `[url opts]` where `url` is a plain string + such as `\"http://localhost/api/main/methods/some-cmd\"`. It must return + a JS Promise that resolves to a fetch Response object. + + Returns the previous `globalThis.fetch` value so callers can restore it + with [[restore-fetch!]]." + [handler-fn] + (let [prev (.-fetch js/globalThis)] + (set! (.-fetch js/globalThis) handler-fn) + prev)) + +(defn restore-fetch! + "Restores `globalThis.fetch` to `orig` (the value returned by + [[install-fetch-mock!]])." + [orig] + (set! (.-fetch js/globalThis) orig)) + +(defn make-json-response + "Creates a minimal fetch `Response` that returns `body-clj` serialised as + plain JSON with HTTP status 200." + [body-clj] + (let [json-str (.stringify js/JSON (clj->js body-clj)) + headers (js/Headers. #js {"content-type" "application/json"})] + (js/Response. json-str #js {:status 200 :headers headers}))) + +(defn make-transit-response + "Creates a minimal fetch `Response` that returns `body-clj` serialised as + Transit+JSON with HTTP status 200. Use this helper when the code under + test inspects typed values (UUIDs, keywords, etc.) from the response body, + since the HTTP layer only decodes transit+json content automatically." + [body-clj] + (let [transit-str (t/encode-str body-clj {:type :json-verbose}) + headers (js/Headers. #js {"content-type" "application/transit+json"})] + (js/Response. transit-str #js {:status 200 :headers headers}))) + +(defn url->cmd + "Extracts the RPC command keyword from a URL string. + + Example: `\"http://…/api/main/methods/create-upload-session\"` + → `:create-upload-session`." + [url] + (when (string? url) + (keyword (last (str/split url #"/"))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 003e68264c..ff7a1f0699 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -3,8 +3,10 @@ [cljs.test :as t] [frontend-tests.basic-shapes-test] [frontend-tests.data.repo-test] + [frontend-tests.data.uploads-test] [frontend-tests.data.viewer-test] [frontend-tests.data.workspace-colors-test] + [frontend-tests.data.workspace-media-test] [frontend-tests.data.workspace-texts-test] [frontend-tests.data.workspace-thumbnails-test] [frontend-tests.helpers-shapes-test] @@ -43,8 +45,10 @@ 'frontend-tests.basic-shapes-test 'frontend-tests.data.repo-test 'frontend-tests.main-errors-test + 'frontend-tests.data.uploads-test 'frontend-tests.data.viewer-test 'frontend-tests.data.workspace-colors-test + 'frontend-tests.data.workspace-media-test 'frontend-tests.data.workspace-texts-test 'frontend-tests.data.workspace-thumbnails-test 'frontend-tests.helpers-shapes-test From c08c3bd16073837efc0e33f4460ec37f1b5df90c Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 13 Apr 2026 17:02:56 +0200 Subject: [PATCH 79/89] :tada: Tiles atlas support --- frontend/src/app/render_wasm/api.cljs | 2 + frontend/src/app/render_wasm/api/webgl.cljs | 9 + frontend/src/debug.cljs | 22 ++ render-wasm/src/main.rs | 12 ++ render-wasm/src/render.rs | 91 +++++++- render-wasm/src/render/debug.rs | 39 ++++ render-wasm/src/render/surfaces.rs | 219 ++++++++++++++++++++ 7 files changed, 386 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index c23a872334..0d69dd5bc2 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -1539,6 +1539,8 @@ (h/call wasm/internal-module "_set_render_options" flags dpr) (when-let [t (wasm-aa-threshold-from-route-params)] (h/call wasm/internal-module "_set_antialias_threshold" t)) + (when-let [max-tex (webgl/max-texture-size context)] + (h/call wasm/internal-module "_set_max_atlas_texture_size" max-tex)) ;; Set browser and canvas size only after initialization (h/call wasm/internal-module "_set_browser" browser) diff --git a/frontend/src/app/render_wasm/api/webgl.cljs b/frontend/src/app/render_wasm/api/webgl.cljs index c6741944a2..eac0c42df7 100644 --- a/frontend/src/app/render_wasm/api/webgl.cljs +++ b/frontend/src/app/render_wasm/api/webgl.cljs @@ -12,6 +12,15 @@ [app.util.dom :as dom] [promesa.core :as p])) +(defn max-texture-size + "Returns `gl.MAX_TEXTURE_SIZE` (max dimension of a 2D texture), or nil if + unavailable." + [gl] + (when gl + (let [n (.getParameter ^js gl (.-MAX_TEXTURE_SIZE ^js gl))] + (when (and (number? n) (pos? n) (js/isFinite n)) + (js/Math.floor n))))) + (defn get-webgl-context "Gets the WebGL context from the WASM module" [] diff --git a/frontend/src/debug.cljs b/frontend/src/debug.cljs index ee3f58b74c..65ac296895 100644 --- a/frontend/src/debug.cljs +++ b/frontend/src/debug.cljs @@ -135,6 +135,28 @@ (wasm.mem/free) text))) +(defn ^:export wasmAtlasConsole + "Logs the current render-wasm atlas as an image in the JS console (if present)." + [] + (let [module wasm/internal-module + f (when module (unchecked-get module "_debug_atlas_console"))] + (if (fn? f) + (wasm.h/call module "_debug_atlas_console") + (js/console.warn "[debug] render-wasm module not ready or missing _debug_atlas_console")))) + +(defn ^:export wasmAtlasBase64 + "Returns the atlas PNG base64 (empty string if missing/empty)." + [] + (let [module wasm/internal-module + f (when module (unchecked-get module "_debug_atlas_base64"))] + (if (fn? f) + (let [ptr (wasm.h/call module "_debug_atlas_base64") + s (or (wasm-read-len-prefixed-utf8 ptr) "")] + s) + (do + (js/console.warn "[debug] render-wasm module not ready or missing _debug_atlas_base64") + "")))) + (defn ^:export wasmCacheConsole "Logs the current render-wasm cache surface as an image in the JS console." [] diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 747b6018f8..740fae8104 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -154,6 +154,18 @@ pub extern "C" fn set_antialias_threshold(threshold: f32) -> Result<()> { Ok(()) } +#[no_mangle] +#[wasm_error] +pub extern "C" fn set_max_atlas_texture_size(max_px: i32) -> Result<()> { + with_state_mut!(state, { + state + .render_state_mut() + .surfaces + .set_max_atlas_texture_size(max_px); + }); + Ok(()) +} + #[no_mangle] #[wasm_error] pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> { diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index d9a5a5ba36..e2c91a250a 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -715,12 +715,14 @@ impl RenderState { // In fast mode the viewport is moving (pan/zoom) so Cache surface // positions would be wrong — only save to the tile HashMap. self.surfaces.cache_current_tile_texture( + &mut self.gpu_state, &self.tile_viewbox, &self .current_tile .ok_or(Error::CriticalError("Current tile not found".to_string()))?, &tile_rect, fast_mode, + self.render_area, ); self.surfaces.draw_cached_tile_surface( @@ -1459,6 +1461,28 @@ impl RenderState { performance::begin_measure!("render_from_cache"); let cached_scale = self.get_cached_scale(); + let bg_color = self.background_color; + + // During fast mode (pan/zoom), if a previous full-quality render still has pending tiles, + // always prefer the persistent atlas. The atlas is incrementally updated as tiles finish, + // and drawing from it avoids mixing a partially-updated Cache surface with missing tiles. + if self.options.is_fast_mode() && self.render_in_progress && self.surfaces.has_atlas() { + self.surfaces + .draw_atlas_to_target(self.viewbox, self.options.dpr(), bg_color); + + if self.options.is_debug_visible() { + debug::render(self); + } + + ui::render(self, shapes); + debug::render_wasm_label(self); + + self.flush_and_submit(); + performance::end_measure!("render_from_cache"); + performance::end_timed_log!("render_from_cache", _start); + return; + } + // Check if we have a valid cached viewbox (non-zero dimensions indicate valid cache) if self.cached_viewbox.area.width() > 0.0 { // Scale and translate the target according to the cached data @@ -1475,7 +1499,62 @@ impl RenderState { let offset_y = self.viewbox.area.top * self.cached_viewbox.zoom * self.options.dpr(); let translate_x = (start_tile_x as f32 * tiles::TILE_SIZE) - offset_x; let translate_y = (start_tile_y as f32 * tiles::TILE_SIZE) - offset_y; - let bg_color = self.background_color; + + // For zoom-out, prefer cache only if it fully covers the viewport. + // Otherwise, atlas will provide a more correct full-viewport preview. + let zooming_out = self.viewbox.zoom < self.cached_viewbox.zoom; + if zooming_out { + let cache_dim = self.surfaces.cache_dimensions(); + let cache_w = cache_dim.width as f32; + let cache_h = cache_dim.height as f32; + + // Viewport in target pixels. + let vw = (self.viewbox.width * self.options.dpr()).max(1.0); + let vh = (self.viewbox.height * self.options.dpr()).max(1.0); + + // Inverse-map viewport corners into cache coordinates. + // target = (cache * navigate_zoom) translated by (translate_x, translate_y) (in cache coords). + // => cache = (target / navigate_zoom) - translate + let inv = if navigate_zoom.abs() > f32::EPSILON { + 1.0 / navigate_zoom + } else { + 0.0 + }; + + let cx0 = (0.0 * inv) - translate_x; + let cy0 = (0.0 * inv) - translate_y; + let cx1 = (vw * inv) - translate_x; + let cy1 = (vh * inv) - translate_y; + + let min_x = cx0.min(cx1); + let min_y = cy0.min(cy1); + let max_x = cx0.max(cx1); + let max_y = cy0.max(cy1); + + let cache_covers = + min_x >= 0.0 && min_y >= 0.0 && max_x <= cache_w && max_y <= cache_h; + if !cache_covers { + // Early return only if atlas exists; otherwise keep cache path. + if self.surfaces.has_atlas() { + self.surfaces.draw_atlas_to_target( + self.viewbox, + self.options.dpr(), + bg_color, + ); + + if self.options.is_debug_visible() { + debug::render(self); + } + + ui::render(self, shapes); + debug::render_wasm_label(self); + self.flush_and_submit(); + performance::end_measure!("render_from_cache"); + performance::end_timed_log!("render_from_cache", _start); + return; + } + } + } // Setup canvas transform { @@ -1531,6 +1610,7 @@ impl RenderState { self.flush_and_submit(); } + performance::end_measure!("render_from_cache"); performance::end_timed_log!("render_from_cache", _start); } @@ -2700,13 +2780,8 @@ impl RenderState { } } else { performance::begin_measure!("render_shape_tree::uncached"); - // Only allow stopping (yielding) if the current tile is NOT visible. - // This ensures all visible tiles render synchronously before showing, - // eliminating empty squares during zoom. Interest-area tiles can still yield. - let tile_is_visible = self.tile_viewbox.is_visible(¤t_tile); - let can_stop = allow_stop && !tile_is_visible; - let (is_empty, early_return) = - self.render_shape_tree_partial_uncached(tree, timestamp, can_stop, false)?; + let (is_empty, early_return) = self + .render_shape_tree_partial_uncached(tree, timestamp, allow_stop, false)?; if early_return { return Ok(()); diff --git a/render-wasm/src/render/debug.rs b/render-wasm/src/render/debug.rs index 47b739b484..f374e32af3 100644 --- a/render-wasm/src/render/debug.rs +++ b/render-wasm/src/render/debug.rs @@ -194,6 +194,15 @@ pub fn console_debug_surface(render_state: &mut RenderState, id: SurfaceId) { run_script!(format!("console.log('%c ', 'font-size: 1px; background: url(data:image/png;base64,{base64_image}) no-repeat; padding: 100px; background-size: contain;')")); } +pub fn console_debug_surface_base64(render_state: &mut RenderState, id: SurfaceId) { + let base64_image = render_state + .surfaces + .base64_snapshot(id) + .expect("Failed to get base64 image"); + + println!("{}", base64_image); +} + #[allow(dead_code)] #[cfg(target_arch = "wasm32")] pub fn console_debug_surface_rect(render_state: &mut RenderState, id: SurfaceId, rect: skia::Rect) { @@ -223,3 +232,33 @@ pub extern "C" fn debug_cache_console() -> Result<()> { }); Ok(()) } + +#[no_mangle] +#[wasm_error] +#[cfg(target_arch = "wasm32")] +pub extern "C" fn debug_cache_base64() -> Result<()> { + with_state_mut!(state, { + console_debug_surface_base64(state.render_state_mut(), SurfaceId::Cache); + }); + Ok(()) +} + +#[no_mangle] +#[wasm_error] +#[cfg(target_arch = "wasm32")] +pub extern "C" fn debug_atlas_console() -> Result<()> { + with_state_mut!(state, { + console_debug_surface(state.render_state_mut(), SurfaceId::Atlas); + }); + Ok(()) +} + +#[no_mangle] +#[wasm_error] +#[cfg(target_arch = "wasm32")] +pub extern "C" fn debug_atlas_base64() -> Result<()> { + with_state_mut!(state, { + console_debug_surface_base64(state.render_state_mut(), SurfaceId::Atlas); + }); + Ok(()) +} diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 1c5a77c72c..3a1d20d900 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -1,6 +1,7 @@ use crate::error::{Error, Result}; use crate::performance; use crate::shapes::Shape; +use crate::view::Viewbox; use skia_safe::{self as skia, IRect, Paint, RRect}; @@ -15,6 +16,16 @@ const TEXTURES_BATCH_DELETE: usize = 256; // If it's too big it could affect performance. const TILE_SIZE_MULTIPLIER: i32 = 2; +/// Atlas texture size limits (px per side). +/// +/// - `DEFAULT_MAX_ATLAS_TEXTURE_SIZE` is the startup fallback used until the +/// frontend reads the real `gl.MAX_TEXTURE_SIZE` and sends it via +/// [`Surfaces::set_max_atlas_texture_size`]. +/// - `MAX_ATLAS_TEXTURE_SIZE` is a hard upper bound to clamp the runtime value +/// (defensive cap to avoid accidentally creating oversized GPU textures). +const MAX_ATLAS_TEXTURE_SIZE: i32 = 4096; +const DEFAULT_MAX_ATLAS_TEXTURE_SIZE: i32 = 1024; + #[repr(u32)] #[derive(Debug, PartialEq, Clone, Copy)] pub enum SurfaceId { @@ -30,6 +41,7 @@ pub enum SurfaceId { Export = 0b010_0000_0000, UI = 0b100_0000_0000, Debug = 0b100_0000_0001, + Atlas = 0b100_0000_0010, } pub struct Surfaces { @@ -57,6 +69,18 @@ pub struct Surfaces { export: skia::Surface, tiles: TileTextureCache, + // Persistent 1:1 document-space atlas that gets incrementally updated as tiles render. + // It grows dynamically to include any rendered document rect. + atlas: skia::Surface, + atlas_origin: skia::Point, + atlas_size: skia::ISize, + /// Atlas pixel density relative to document pixels (1.0 == 1:1 doc px). + /// When the atlas would exceed `max_atlas_texture_size`, this value is + /// reduced so the atlas stays within the fixed texture cap. + atlas_scale: f32, + /// Max width/height in pixels for the atlas surface (typically browser + /// `MAX_TEXTURE_SIZE`). Set from ClojureScript after WebGL context creation. + max_atlas_texture_size: i32, sampling_options: skia::SamplingOptions, pub margins: skia::ISize, // Tracks which surfaces have content (dirty flag bitmask) @@ -99,6 +123,10 @@ impl Surfaces { let ui = gpu_state.create_surface_with_dimensions("ui".to_string(), width, height)?; let debug = gpu_state.create_surface_with_dimensions("debug".to_string(), width, height)?; + // Keep atlas as a regular surface like the rest. Start with a tiny + // transparent surface and grow it on demand. + let mut atlas = gpu_state.create_surface_with_dimensions("atlas".to_string(), 1, 1)?; + atlas.canvas().clear(skia::Color::TRANSPARENT); let tiles = TileTextureCache::new(); Ok(Surfaces { @@ -115,6 +143,11 @@ impl Surfaces { debug, export, tiles, + atlas, + atlas_origin: skia::Point::new(0.0, 0.0), + atlas_size: skia::ISize::new(0, 0), + atlas_scale: 1.0, + max_atlas_texture_size: DEFAULT_MAX_ATLAS_TEXTURE_SIZE, sampling_options, margins, dirty_surfaces: 0, @@ -122,10 +155,185 @@ impl Surfaces { }) } + /// Sets the maximum atlas texture dimension (one side). Should match the + /// WebGL `MAX_TEXTURE_SIZE` reported by the browser. Values are clamped to + /// a small minimum so the atlas logic stays well-defined. + pub fn set_max_atlas_texture_size(&mut self, max_px: i32) { + self.max_atlas_texture_size = max_px.clamp(TILE_SIZE as i32, MAX_ATLAS_TEXTURE_SIZE); + } + + fn ensure_atlas_contains( + &mut self, + gpu_state: &mut GpuState, + doc_rect: skia::Rect, + ) -> Result<()> { + if doc_rect.is_empty() { + return Ok(()); + } + + // Current atlas bounds in document space (1 unit == 1 px). + let current_left = self.atlas_origin.x; + let current_top = self.atlas_origin.y; + let atlas_scale = self.atlas_scale.max(0.01); + let current_right = current_left + (self.atlas_size.width as f32) / atlas_scale; + let current_bottom = current_top + (self.atlas_size.height as f32) / atlas_scale; + + let mut new_left = current_left; + let mut new_top = current_top; + let mut new_right = current_right; + let mut new_bottom = current_bottom; + + // If atlas is empty/uninitialized, seed to rect (expanded to tile boundaries for fewer reallocs). + let needs_init = self.atlas_size.width <= 0 || self.atlas_size.height <= 0; + if needs_init { + new_left = doc_rect.left.floor(); + new_top = doc_rect.top.floor(); + new_right = doc_rect.right.ceil(); + new_bottom = doc_rect.bottom.ceil(); + } else { + new_left = new_left.min(doc_rect.left.floor()); + new_top = new_top.min(doc_rect.top.floor()); + new_right = new_right.max(doc_rect.right.ceil()); + new_bottom = new_bottom.max(doc_rect.bottom.ceil()); + } + + // Add padding to reduce realloc frequency. + let pad = TILE_SIZE; + new_left -= pad; + new_top -= pad; + new_right += pad; + new_bottom += pad; + + let doc_w = (new_right - new_left).max(1.0); + let doc_h = (new_bottom - new_top).max(1.0); + + // Compute atlas scale needed to fit within the fixed texture cap. + // Keep the highest possible scale (closest to 1.0) that still fits. + let cap = self.max_atlas_texture_size.max(TILE_SIZE as i32) as f32; + let required_scale = (cap / doc_w).min(cap / doc_h).clamp(0.01, 1.0); + + // Never upscale the atlas (it would add blur and churn). + let new_scale = self.atlas_scale.min(required_scale).max(0.01); + + let new_w = (doc_w * new_scale).ceil().clamp(1.0, cap) as i32; + let new_h = (doc_h * new_scale).ceil().clamp(1.0, cap) as i32; + + // Fast path: existing atlas already contains the rect. + if !needs_init + && doc_rect.left >= current_left + && doc_rect.top >= current_top + && doc_rect.right <= current_right + && doc_rect.bottom <= current_bottom + { + return Ok(()); + } + + let mut new_atlas = + gpu_state.create_surface_with_dimensions("atlas".to_string(), new_w, new_h)?; + new_atlas.canvas().clear(skia::Color::TRANSPARENT); + + // Copy old atlas into the new one with offset. + if !needs_init { + let old_scale = self.atlas_scale.max(0.01); + let scale_ratio = new_scale / old_scale; + let dx = (current_left - new_left) * new_scale; + let dy = (current_top - new_top) * new_scale; + + let image = self.atlas.image_snapshot(); + let src = skia::Rect::from_xywh( + 0.0, + 0.0, + self.atlas_size.width as f32, + self.atlas_size.height as f32, + ); + let dst = skia::Rect::from_xywh( + dx, + dy, + (self.atlas_size.width as f32) * scale_ratio, + (self.atlas_size.height as f32) * scale_ratio, + ); + new_atlas.canvas().draw_image_rect( + &image, + Some((&src, skia::canvas::SrcRectConstraint::Fast)), + dst, + &skia::Paint::default(), + ); + } + + self.atlas_origin = skia::Point::new(new_left, new_top); + self.atlas_size = skia::ISize::new(new_w, new_h); + self.atlas_scale = new_scale; + self.atlas = new_atlas; + Ok(()) + } + + fn blit_tile_image_into_atlas( + &mut self, + gpu_state: &mut GpuState, + tile_image: &skia::Image, + doc_rect: skia::Rect, + ) -> Result<()> { + self.ensure_atlas_contains(gpu_state, doc_rect)?; + + // Destination is document-space rect mapped into atlas pixel coords. + let dst = skia::Rect::from_xywh( + (doc_rect.left - self.atlas_origin.x) * self.atlas_scale, + (doc_rect.top - self.atlas_origin.y) * self.atlas_scale, + doc_rect.width() * self.atlas_scale, + doc_rect.height() * self.atlas_scale, + ); + + self.atlas + .canvas() + .draw_image_rect(tile_image, None, dst, &skia::Paint::default()); + Ok(()) + } + pub fn clear_tiles(&mut self) { self.tiles.clear(); } + pub fn has_atlas(&self) -> bool { + self.atlas_size.width > 0 && self.atlas_size.height > 0 + } + + /// Draw the persistent atlas onto the target using the current viewbox transform. + /// Intended for fast pan/zoom-out previews (avoids per-tile composition). + pub fn draw_atlas_to_target(&mut self, viewbox: Viewbox, dpr: f32, background: skia::Color) { + if !self.has_atlas() { + return; + }; + + let canvas = self.target.canvas(); + canvas.save(); + canvas.reset_matrix(); + let size = canvas.base_layer_size(); + canvas.clip_rect( + skia::Rect::from_xywh(0.0, 0.0, size.width as f32, size.height as f32), + None, + true, + ); + + let s = viewbox.zoom * dpr; + let atlas_scale = self.atlas_scale.max(0.01); + + canvas.clear(background); + canvas.translate(( + (self.atlas_origin.x + viewbox.pan_x) * s, + (self.atlas_origin.y + viewbox.pan_y) * s, + )); + canvas.scale((s / atlas_scale, s / atlas_scale)); + + self.atlas.draw( + canvas, + (0.0, 0.0), + self.sampling_options, + Some(&skia::Paint::default()), + ); + + canvas.restore(); + } + pub fn margins(&self) -> skia::ISize { self.margins } @@ -255,6 +463,10 @@ impl Surfaces { ); } + pub fn cache_dimensions(&self) -> skia::ISize { + skia::ISize::new(self.cache.width(), self.cache.height()) + } + pub fn apply_mut(&mut self, ids: u32, mut f: impl FnMut(&mut skia::Surface)) { performance::begin_measure!("apply_mut::flags"); if ids & SurfaceId::Target as u32 != 0 { @@ -352,6 +564,7 @@ impl Surfaces { SurfaceId::Debug => &mut self.debug, SurfaceId::UI => &mut self.ui, SurfaceId::Export => &mut self.export, + SurfaceId::Atlas => &mut self.atlas, } } @@ -369,6 +582,7 @@ impl Surfaces { SurfaceId::Debug => &self.debug, SurfaceId::UI => &self.ui, SurfaceId::Export => &self.export, + SurfaceId::Atlas => &self.atlas, } } @@ -546,10 +760,12 @@ impl Surfaces { pub fn cache_current_tile_texture( &mut self, + gpu_state: &mut GpuState, tile_viewbox: &TileViewbox, tile: &Tile, tile_rect: &skia::Rect, skip_cache_surface: bool, + tile_doc_rect: skia::Rect, ) { let rect = IRect::from_xywh( self.margins.width, @@ -571,6 +787,9 @@ impl Surfaces { ); } + // Incrementally update persistent 1:1 atlas in document space. + // `tile_doc_rect` is in world/document coordinates (1 unit == 1 px at 100%). + let _ = self.blit_tile_image_into_atlas(gpu_state, &tile_image, tile_doc_rect); self.tiles.add(tile_viewbox, tile, tile_image); } } From 8775e234f3238925319a54cc1db21d6e4c71c29d Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 15 Apr 2026 07:09:38 +0200 Subject: [PATCH 80/89] :tada: Waiting for tiles complete in a non blocking way --- .../main/ui/workspace/sidebar/sitemap.cljs | 22 +-- .../app/main/ui/workspace/viewport_wasm.cljs | 120 +++++++++------ frontend/src/app/render_wasm/api.cljs | 145 ++++++++++++++++-- frontend/src/app/render_wasm/api/webgl.cljs | 52 +++---- frontend/src/app/render_wasm/wasm.cljs | 5 +- render-wasm/src/render.rs | 9 ++ 6 files changed, 250 insertions(+), 103 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs index a58e1512ba..71bdf0b7d4 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs @@ -30,6 +30,7 @@ [app.util.timers :as timers] [cuerdas.core :as str] [okulary.core :as l] + [promesa.core :as p] [rumext.v2 :as mf])) ;; FIXME: can we unify this two refs in one? @@ -72,18 +73,21 @@ (mf/use-fn (mf/deps id current-page-id) (fn [] - ;; For the wasm renderer, apply a blur effect to the viewport canvas - ;; when we navigate to a different page. + ;; WASM page transitions: + ;; - Capture the current page (A) once + ;; - Show a blurred snapshot while the target page (B/C/...) renders + ;; - If the user clicks again during the transition, keep showing the original (A) snapshot (if (and (features/active-feature? @st/state "render-wasm/v1") (not= id current-page-id)) (do - (wasm.api/capture-canvas-pixels) - (wasm.api/apply-canvas-blur) - ;; NOTE: it seems we need two RAF so the blur is actually applied and visible - ;; in the canvas :( - (timers/raf - (fn [] - (timers/raf navigate-fn)))) + (-> (wasm.api/apply-canvas-blur) + (p/finally + (fn [] + ;; NOTE: it seems we need two RAF so the blur is actually applied and visible + ;; in the canvas :( + (timers/raf + (fn [] + (timers/raf navigate-fn))))))) (navigate-fn)))) on-delete diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 724c165c21..e90a584baa 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -132,21 +132,21 @@ (apply-modifiers-to-objects base-objects wasm-modifiers)) ;; STATE - alt? (mf/use-state false) - shift? (mf/use-state false) - mod? (mf/use-state false) - space? (mf/use-state false) - z? (mf/use-state false) - cursor (mf/use-state (utils/get-cursor :pointer-inner)) - hover-ids (mf/use-state nil) - hover (mf/use-state nil) - measure-hover (mf/use-state nil) - hover-disabled? (mf/use-state false) - hover-top-frame-id (mf/use-state nil) - frame-hover (mf/use-state nil) - active-frames (mf/use-state #{}) - canvas-init? (mf/use-state false) - initialized? (mf/use-state false) + alt? (mf/use-state false) + shift? (mf/use-state false) + mod? (mf/use-state false) + space? (mf/use-state false) + z? (mf/use-state false) + cursor (mf/use-state (utils/get-cursor :pointer-inner)) + hover-ids (mf/use-state nil) + hover (mf/use-state nil) + measure-hover (mf/use-state nil) + hover-disabled? (mf/use-state false) + hover-top-frame-id (mf/use-state nil) + frame-hover (mf/use-state nil) + active-frames (mf/use-state #{}) + canvas-init? (mf/use-state false) + initialized? (mf/use-state false) ;; REFS [viewport-ref @@ -205,6 +205,9 @@ mode-inspect? (= options-mode :inspect) + ;; True when we are opening a new file or switching to a new page + page-transition? (mf/deref wasm.api/page-transition?) + on-click (actions/on-click hover selected edition path-drawing? drawing-tool space? selrect z?) on-context-menu (actions/on-context-menu hover hover-ids read-only?) on-double-click (actions/on-double-click hover hover-ids hover-top-frame-id path-drawing? base-objects edition drawing-tool z? read-only?) @@ -234,41 +237,46 @@ show-cursor-tooltip? tooltip show-draw-area? drawing-obj show-gradient-handlers? (= (count selected) 1) - show-grids? (contains? layout :display-guides) + show-grids? (and (contains? layout :display-guides) (not page-transition?)) - show-frame-outline? (and (= transform :move) (not panning)) + show-frame-outline? (and (= transform :move) (not panning) (not page-transition?)) show-outlines? (and (nil? transform) (not panning) (not edition) (not drawing-obj) - (not (#{:comments :path :curve} drawing-tool))) + (not (#{:comments :path :curve} drawing-tool)) + (not page-transition?)) show-pixel-grid? (and (contains? layout :show-pixel-grid) - (>= zoom 8)) - show-text-editor? (and editing-shape (= :text (:type editing-shape))) + (>= zoom 8) + (not page-transition?)) + show-text-editor? (and editing-shape (= :text (:type editing-shape)) (not page-transition?)) hover-grid? (and (some? @hover-top-frame-id) - (ctl/grid-layout? objects @hover-top-frame-id)) + (ctl/grid-layout? objects @hover-top-frame-id) + (not page-transition?)) - show-grid-editor? (and editing-shape (ctl/grid-layout? editing-shape)) - show-presence? page-id - show-prototypes? (= options-mode :prototype) - show-selection-handlers? (and (seq selected) (not show-text-editor?)) + show-grid-editor? (and editing-shape (ctl/grid-layout? editing-shape) (not page-transition?)) + show-presence? (and page-id (not page-transition?)) + show-prototypes? (and (= options-mode :prototype) (not page-transition?)) + show-selection-handlers? (and (seq selected) (not show-text-editor?) (not page-transition?)) show-snap-distance? (and (contains? layout :dynamic-alignment) (= transform :move) - (seq selected)) + (seq selected) + (not page-transition?)) show-snap-points? (and (or (contains? layout :dynamic-alignment) (contains? layout :snap-guides)) - (or drawing-obj transform)) - show-selrect? (and selrect (empty? drawing) (not text-editing?)) + (or drawing-obj transform) + (not page-transition?)) + show-selrect? (and selrect (empty? drawing) (not text-editing?) (not page-transition?)) show-measures? (and (not transform) (not path-editing?) - (or show-distances? mode-inspect?)) - show-artboard-names? (contains? layout :display-artboard-names) + (or show-distances? mode-inspect?) + (not page-transition?)) + show-artboard-names? (and (contains? layout :display-artboard-names) (not page-transition?)) hide-ui? (contains? layout :hide-ui) show-rulers? (and (contains? layout :rulers) (not hide-ui?)) - disabled-guides? (or drawing-tool transform path-drawing? path-editing?) single-select? (= (count selected-shapes) 1) @@ -279,6 +287,8 @@ (or (ctk/is-variant-container? first-shape) (ctk/is-variant? first-shape))) + show-scrollbar? (not page-transition?) + add-variant (mf/use-fn (mf/deps first-shape) @@ -311,7 +321,8 @@ rule-area-size (/ rulers/ruler-area-size zoom) preview-blend (-> refs/workspace-preview-blend (mf/deref)) - shapes-loading? (mf/deref wasm.api/shapes-loading?)] + shapes-loading? (mf/deref wasm.api/shapes-loading?) + transition-image-url (mf/deref wasm.api/transition-image-url*)] ;; NOTE: We need this page-id dependency to react to it and reset the ;; canvas, even though we are not using `page-id` inside the hook. @@ -341,15 +352,7 @@ (cond init? (do - (reset! canvas-init? true) - (wasm.api/apply-canvas-blur) - (if (wasm.api/has-captured-pixels?) - ;; Page switch: restore previously captured pixels (blurred) - (wasm.api/restore-previous-canvas-pixels) - ;; First load: try to draw a blurred page thumbnail - (when-let [frame-id (get page :thumbnail-frame-id)] - (when-let [uri (dm/get-in @st/state [:thumbnails frame-id])] - (wasm.api/draw-thumbnail-to-canvas uri))))) + (reset! canvas-init? true)) (pos? retries) (vreset! timeout-id-ref @@ -392,14 +395,15 @@ (when @canvas-init? (if (not @initialized?) (do + ;; Initial file open uses the same transition workflow as page switches, + ;; but with a solid background-color blurred placeholder. + (wasm.api/start-initial-load-transition! background) ;; Keep the blurred previous-page preview (page switch) or ;; blank canvas (first load) visible while shapes load. ;; The loading overlay is suppressed because on-shapes-ready ;; is set. (wasm.api/initialize-viewport - base-objects zoom vbox background 1 nil - (fn [] - (wasm.api/clear-canvas-pixels))) + base-objects zoom vbox background) (reset! initialized? true) (mf/set-ref-val! last-file-version-id-ref file-version-id)) (when (and (some? file-version-id) @@ -476,6 +480,21 @@ :style {:background-color background :pointer-events "none"}}] + ;; Show the transition image when we are opening a new file or switching to a new page + (when (and page-transition? (some? transition-image-url)) + (let [src transition-image-url] + [:img {:data-testid "canvas-wasm-transition" + :src src + :draggable false + :style {:position "absolute" + :inset 0 + :width "100%" + :height "100%" + :object-fit "cover" + :pointer-events "none" + :filter "blur(4px)"}}])) + + [:svg.viewport-controls {:xmlns "http://www.w3.org/2000/svg" :xmlnsXlink "http://www.w3.org/1999/xlink" @@ -776,9 +795,10 @@ (get objects-modified @hover-top-frame-id)) :view-only (not show-grid-editor?)}])] - [:g.scrollbar-wrapper {:clipPath "url(#clip-handlers)"} - [:> scroll-bars/viewport-scrollbars* - {:objects base-objects - :zoom zoom - :vbox vbox - :bottom-padding (when palete-size (+ palete-size 8))}]]]]])) + (when show-scrollbar? + [:g.scrollbar-wrapper {:clipPath "url(#clip-handlers)"} + [:> scroll-bars/viewport-scrollbars* + {:objects base-objects + :zoom zoom + :vbox vbox + :bottom-padding (when palete-size (+ palete-size 8))}]])]]])) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 0d69dd5bc2..1ebda86633 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -55,6 +55,109 @@ (def use-dpr? (contains? cf/flags :render-wasm-dpr)) +;; --- Page transition state (WASM viewport) +;; +;; Goal: avoid showing tile-by-tile rendering during page switches (and initial load), +;; by keeping a blurred snapshot overlay visible until WASM dispatches +;; `penpot:wasm:tiles-complete`. +;; +;; - `page-transition?`: true while the overlay should be considered active. +;; - `transition-image-url*`: URL used by the UI overlay (usually `blob:` from the +;; current WebGL canvas snapshot; on initial load it may be a tiny SVG data-url +;; derived from the page background color). +;; - `transition-epoch*`: monotonic counter used to ignore stale async work/events +;; when the user clicks pages rapidly (A -> B -> C). +;; - `transition-tiles-handler*`: the currently installed DOM event handler for +;; `penpot:wasm:tiles-complete`, so we can remove/replace it safely. +(defonce page-transition? (atom false)) +(defonce transition-image-url* (atom nil)) +(defonce transition-epoch* (atom 0)) +(defonce transition-tiles-handler* (atom nil)) + +(def ^:private transition-blur-css "blur(4px)") + +(defn- set-transition-blur! + [] + (when-let [canvas ^js wasm/canvas] + (dom/set-style! canvas "filter" transition-blur-css)) + (when-let [nodes (.querySelectorAll ^js ug/document ".blurrable")] + (doseq [^js node (array-seq nodes)] + (dom/set-style! node "filter" transition-blur-css)))) + +(defn- clear-transition-blur! + [] + (when-let [canvas ^js wasm/canvas] + (dom/set-style! canvas "filter" "")) + (when-let [nodes (.querySelectorAll ^js ug/document ".blurrable")] + (doseq [^js node (array-seq nodes)] + (dom/set-style! node "filter" "")))) + +(defn set-transition-image-from-background! + "Sets `transition-image-url*` to a data URL representing a solid background color." + [background] + (when (string? background) + (let [svg (str "" + "" + "")] + (reset! transition-image-url* + (str "data:image/svg+xml;charset=utf-8," (js/encodeURIComponent svg)))))) + +(defn begin-page-transition! + [] + (reset! page-transition? true) + (swap! transition-epoch* inc)) + +(defn end-page-transition! + [] + (reset! page-transition? false) + (when-let [prev @transition-tiles-handler*] + (.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" prev)) + (reset! transition-tiles-handler* nil) + (reset! transition-image-url* nil) + (clear-transition-blur!) + ;; Clear captured pixels so future transitions must explicitly capture again. + (set! wasm/canvas-snapshot-url nil)) + +(defn- set-transition-tiles-complete-handler! + "Installs a tiles-complete handler bound to the current transition epoch. + Replaces any previous handler so rapid page switching doesn't end the wrong transition." + [epoch f] + (when-let [prev @transition-tiles-handler*] + (.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" prev)) + (letfn [(handler [_] + (when (= epoch @transition-epoch*) + (.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" handler) + (reset! transition-tiles-handler* nil) + (f)))] + (reset! transition-tiles-handler* handler) + (.addEventListener ^js ug/document "penpot:wasm:tiles-complete" handler))) + +(defn start-initial-load-transition! + "Starts a page-transition workflow for initial file open. + + - Sets `page-transition?` to true + - Installs a tiles-complete handler to end the transition + - Uses a solid background-color placeholder as the transition image" + [background] + ;; If something already toggled `page-transition?` (e.g. legacy init code paths), + ;; ensure we still have a deterministic placeholder on initial load. + (when (or (not @page-transition?) (nil? @transition-image-url*)) + (set-transition-image-from-background! background)) + (when-not @page-transition? + ;; Start transition + bind the tiles-complete handler to this epoch. + (let [epoch (begin-page-transition!)] + (set-transition-tiles-complete-handler! epoch end-page-transition!)))) + +(defn listen-tiles-render-complete-once! + "Registers a one-shot listener for `penpot:wasm:tiles-complete`, dispatched from WASM + when a full tile pass finishes." + [f] + (.addEventListener ^js ug/document + "penpot:wasm:tiles-complete" + (fn [_] + (f)) + #js {:once true})) + (defn text-editor-wasm? [] (or (contains? cf/flags :feature-text-editor-wasm) @@ -94,16 +197,9 @@ (def ^:const TEXT_EDITOR_EVENT_NEEDS_LAYOUT 4) ;; Re-export public WebGL functions -(def capture-canvas-pixels webgl/capture-canvas-pixels) -(def restore-previous-canvas-pixels webgl/restore-previous-canvas-pixels) -(def clear-canvas-pixels webgl/clear-canvas-pixels) +(def capture-canvas-snapshot-url webgl/capture-canvas-snapshot-url) (def draw-thumbnail-to-canvas webgl/draw-thumbnail-to-canvas) -(defn has-captured-pixels? - "Returns true if there are saved canvas pixels from a previous page." - [] - (some? wasm/canvas-pixels)) - ;; Re-export public text editor functions (def text-editor-focus text-editor/text-editor-focus) (def text-editor-blur text-editor/text-editor-blur) @@ -1778,9 +1874,36 @@ (defn apply-canvas-blur [] - (when wasm/canvas (dom/set-style! wasm/canvas "filter" "blur(4px)")) - (let [controls-to-blur (dom/query-all (dom/get-element "viewport-controls") ".blurrable")] - (run! #(dom/set-style! % "filter" "blur(4px)") controls-to-blur))) + (let [already? @page-transition? + epoch (begin-page-transition!)] + (set-transition-tiles-complete-handler! epoch end-page-transition!) + ;; Two-phase transition: + ;; - Apply CSS blur to the live canvas immediately (no async wait), so the user + ;; sees the transition right away. + ;; - In parallel, capture a `blob:` snapshot URL; once ready, switch the overlay + ;; to that fixed image (and guard with `epoch` to avoid stale async updates). + (set-transition-blur!) + ;; Lock the snapshot for the whole transition: if the user clicks to another page + ;; while the transition is active, keep showing the original page snapshot until + ;; the final target page finishes rendering. + (if already? + (p/resolved nil) + (do + ;; If we already have a snapshot URL, use it immediately. + (when-let [url wasm/canvas-snapshot-url] + (when (string? url) + (reset! transition-image-url* url))) + + ;; Capture a fresh snapshot asynchronously and update the overlay as soon + ;; as it is ready (guarded by `epoch` to avoid stale async updates). + (-> (capture-canvas-snapshot-url) + (p/then (fn [url] + (when (and (string? url) + @page-transition? + (= epoch @transition-epoch*)) + (reset! transition-image-url* url)) + url)) + (p/catch (fn [_] nil))))))) (defn render-shape-pixels [shape-id scale] diff --git a/frontend/src/app/render_wasm/api/webgl.cljs b/frontend/src/app/render_wasm/api/webgl.cljs index eac0c42df7..7442947953 100644 --- a/frontend/src/app/render_wasm/api/webgl.cljs +++ b/frontend/src/app/render_wasm/api/webgl.cljs @@ -9,7 +9,6 @@ (:require [app.common.logging :as log] [app.render-wasm.wasm :as wasm] - [app.util.dom :as dom] [promesa.core :as p])) (defn max-texture-size @@ -144,38 +143,29 @@ void main() { (.bindTexture ^js gl (.-TEXTURE_2D ^js gl) nil) (.deleteTexture ^js gl texture)))) -(defn restore-previous-canvas-pixels - "Restores previous canvas pixels into the new canvas" - [] - (when-let [previous-canvas-pixels wasm/canvas-pixels] - (when-let [gl wasm/gl-context] - (draw-imagedata-to-webgl gl previous-canvas-pixels) - (set! wasm/canvas-pixels nil)))) +(defn capture-canvas-snapshot-url + "Captures the current viewport canvas as a PNG `blob:` URL and stores it in + `wasm/canvas-snapshot-url`. -(defn clear-canvas-pixels + Returns a promise resolving to the URL string (or nil)." [] - (when wasm/canvas - (let [context wasm/gl-context] - (.clearColor ^js context 0 0 0 0.0) - (.clear ^js context (.-COLOR_BUFFER_BIT ^js context)) - (.clear ^js context (.-DEPTH_BUFFER_BIT ^js context)) - (.clear ^js context (.-STENCIL_BUFFER_BIT ^js context))) - (dom/set-style! wasm/canvas "filter" "none") - (let [controls-to-unblur (dom/query-all (dom/get-element "viewport-controls") ".blurrable")] - (run! #(dom/set-style! % "filter" "none") controls-to-unblur)) - (set! wasm/canvas-pixels nil))) - -(defn capture-canvas-pixels - "Captures the pixels of the viewport canvas" - [] - (when wasm/canvas - (let [context wasm/gl-context - width (.-width wasm/canvas) - height (.-height wasm/canvas) - buffer (js/Uint8ClampedArray. (* width height 4)) - _ (.readPixels ^js context 0 0 width height (.-RGBA ^js context) (.-UNSIGNED_BYTE ^js context) buffer) - image-data (js/ImageData. buffer width height)] - (set! wasm/canvas-pixels image-data)))) + (if-let [^js canvas wasm/canvas] + (p/create + (fn [resolve _reject] + ;; Revoke previous snapshot to avoid leaking blob URLs. + (when-let [prev wasm/canvas-snapshot-url] + (when (and (string? prev) (.startsWith ^js prev "blob:")) + (js/URL.revokeObjectURL prev))) + (set! wasm/canvas-snapshot-url nil) + (.toBlob canvas + (fn [^js blob] + (if blob + (let [url (js/URL.createObjectURL blob)] + (set! wasm/canvas-snapshot-url url) + (resolve url)) + (resolve nil))) + "image/png"))) + (p/resolved nil))) (defn draw-thumbnail-to-canvas "Loads an image from `uri` and draws it stretched to fill the WebGL canvas. diff --git a/frontend/src/app/render_wasm/wasm.cljs b/frontend/src/app/render_wasm/wasm.cljs index c54091d5e2..5c43ba4899 100644 --- a/frontend/src/app/render_wasm/wasm.cljs +++ b/frontend/src/app/render_wasm/wasm.cljs @@ -12,8 +12,9 @@ ;; Reference to the HTML canvas element. (defonce canvas nil) -;; Reference to the captured pixels of the canvas (for page switching effect) -(defonce canvas-pixels nil) +;; Snapshot of the current canvas suitable for `` overlays. +;; This is typically a `blob:` URL created via `canvas.toBlob`. +(defonce canvas-snapshot-url nil) ;; Reference to the Emscripten GL context wrapper. (defonce gl-context-handle nil) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index e2c91a250a..f8624d90aa 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -43,6 +43,13 @@ const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3; const MAX_BLOCKING_TIME_MS: i32 = 32; const NODE_BATCH_THRESHOLD: i32 = 3; + +/// Dispatches `penpot:wasm:tiles-complete` on `document` so the UI can react when a full +/// tile pass has finished (e.g. remove page-transition blur). +fn notify_tiles_render_complete() { + #[cfg(target_arch = "wasm32")] + crate::run_script!("document.dispatchEvent(new CustomEvent('penpot:wasm:tiles-complete'))"); +} const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0; type ClipStack = Vec<(Rect, Option, Matrix)>; @@ -1750,6 +1757,7 @@ impl RenderState { self.cancel_animation_frame(); self.render_request_id = Some(wapi::request_animation_frame!()); } else { + notify_tiles_render_complete(); performance::end_measure!("render"); } } @@ -1767,6 +1775,7 @@ impl RenderState { self.render_shape_tree_partial(base_object, tree, timestamp, false)?; } self.flush_and_submit(); + notify_tiles_render_complete(); Ok(()) } From bfa1ae051febf8954ce4b12b20c0880d03eaf261 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Thu, 16 Apr 2026 16:07:49 +0200 Subject: [PATCH 81/89] :bug: Fix problem with dashboard thumbnails images --- frontend/src/app/main/render.cljs | 4 ++- .../app/main/ui/workspace/viewport_wasm.cljs | 2 +- frontend/src/app/render_wasm/api.cljs | 31 +++++++++---------- frontend/src/app/worker/thumbnails.cljs | 5 ++- 4 files changed, 22 insertions(+), 20 deletions(-) diff --git a/frontend/src/app/main/render.cljs b/frontend/src/app/main/render.cljs index 6e40dbcbda..d60366592c 100644 --- a/frontend/src/app/main/render.cljs +++ b/frontend/src/app/main/render.cljs @@ -492,7 +492,9 @@ (try (when (wasm.api/init-canvas-context os-canvas) (wasm.api/initialize-viewport - objects scale bounds "#000000" 0 + objects scale bounds + :background-opacity 0 + :on-render (fn [] (wasm.api/render-sync-shape object-id) (ts/raf diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index e90a584baa..db9e1365ca 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -408,7 +408,7 @@ (mf/set-ref-val! last-file-version-id-ref file-version-id)) (when (and (some? file-version-id) (not= file-version-id (mf/ref-val last-file-version-id-ref))) - (wasm.api/initialize-viewport base-objects zoom vbox background) + (wasm.api/initialize-viewport base-objects zoom vbox :background background) (mf/set-ref-val! last-file-version-id-ref file-version-id))))) (mf/with-effect [focus] diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 4ed44bb67b..0178f8afa5 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -1398,14 +1398,14 @@ loading begins, allowing callers to reveal the page content during transitions." ([objects] - (set-objects objects nil nil)) + (set-objects objects nil nil false)) ([objects render-callback] - (set-objects objects render-callback nil)) - ([objects render-callback on-shapes-ready] + (set-objects objects render-callback nil false)) + ([objects render-callback on-shapes-ready force-sync] (perf/begin-measure "set-objects") (let [shapes (shapes-in-tree-order objects) total-shapes (count shapes)] - (if (< total-shapes ASYNC_THRESHOLD) + (if (or force-sync (< total-shapes ASYNC_THRESHOLD)) (set-objects-sync shapes render-callback on-shapes-ready) (do (begin-shapes-loading!) @@ -1554,19 +1554,16 @@ (request-render "set-modifiers"))))) (defn initialize-viewport - ([base-objects zoom vbox background] - (initialize-viewport base-objects zoom vbox background 1 nil nil)) - ([base-objects zoom vbox background callback] - (initialize-viewport base-objects zoom vbox background 1 callback nil)) - ([base-objects zoom vbox background background-opacity callback] - (initialize-viewport base-objects zoom vbox background background-opacity callback nil)) - ([base-objects zoom vbox background background-opacity callback on-shapes-ready] - (let [rgba (sr-clr/hex->u32argb background background-opacity) - total-shapes (count (vals base-objects))] - (h/call wasm/internal-module "_set_canvas_background" rgba) - (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) - (h/call wasm/internal-module "_init_shapes_pool" total-shapes) - (set-objects base-objects callback on-shapes-ready)))) + [base-objects zoom vbox & + {:keys [background background-opacity on-render on-shapes-ready force-sync] + :or {background-opacity 1}}] + (let [rgba (when background (sr-clr/hex->u32argb background background-opacity)) + total-shapes (count (vals base-objects))] + + (when rgba (h/call wasm/internal-module "_set_canvas_background" rgba)) + (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) + (h/call wasm/internal-module "_init_shapes_pool" total-shapes) + (set-objects base-objects on-render on-shapes-ready force-sync))) (def ^:private default-context-options #js {:antialias false diff --git a/frontend/src/app/worker/thumbnails.cljs b/frontend/src/app/worker/thumbnails.cljs index 70d8216d14..d90536619d 100644 --- a/frontend/src/app/worker/thumbnails.cljs +++ b/frontend/src/app/worker/thumbnails.cljs @@ -171,7 +171,10 @@ zoom (/ width (:width vbox))] (wasm.api/initialize-viewport - objects zoom vbox bgcolor + objects zoom vbox + :background bgcolor + :force-sync true + :on-render (fn [] (if frame (wasm.api/render-sync-shape (:id frame)) From ea53d24dde6d86ab070d9561df892c7badc65561 Mon Sep 17 00:00:00 2001 From: Andres Gonzalez Date: Thu, 16 Apr 2026 17:56:25 +0200 Subject: [PATCH 82/89] :sparkles: Update antialias threshold --- render-wasm/src/render/options.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs index 7b1a49f540..27454ec90f 100644 --- a/render-wasm/src/render/options.rs +++ b/render-wasm/src/render/options.rs @@ -19,7 +19,7 @@ impl Default for RenderOptions { flags: 0, dpr: None, fast_mode: false, - antialias_threshold: 15.0, + antialias_threshold: 7.0, } } } From 97496d8ad796cae2ad98003cd33cef29454821f3 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Fri, 17 Apr 2026 12:30:13 +0200 Subject: [PATCH 83/89] :bug: Fix thumbnail generation --- render-wasm/src/render.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index e1897ae09b..4394d962cb 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1780,8 +1780,6 @@ impl RenderState { self.render_shape_tree_partial(base_object, tree, timestamp, false)?; } self.flush_and_submit(); - notify_tiles_render_complete(); - Ok(()) } From 9cf787d154ab830007ed35803be18ce4b6c65a90 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 17 Apr 2026 12:18:06 +0200 Subject: [PATCH 84/89] :bug: Update atlas when removing shape --- render-wasm/src/render.rs | 18 ++++++++++++++++++ render-wasm/src/render/surfaces.rs | 27 +++++++++++++++++++++++++++ render-wasm/src/state.rs | 26 ++++++++++++++++++-------- 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 4394d962cb..400917604e 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -2817,6 +2817,24 @@ impl RenderState { paint.set_color(self.background_color); s.canvas().draw_rect(tile_rect, &paint); }); + // Keep Cache surface coherent for render_from_cache. + if !self.options.is_fast_mode() { + if !self.cache_cleared_this_render { + self.surfaces.clear_cache(self.background_color); + self.cache_cleared_this_render = true; + } + let aligned_rect = self.get_aligned_tile_bounds(current_tile); + self.surfaces.apply_mut(SurfaceId::Cache as u32, |s| { + let mut paint = skia::Paint::default(); + paint.set_color(self.background_color); + s.canvas().draw_rect(aligned_rect, &paint); + }); + } + + // Clear atlas region to transparent so background shows through. + let _ = self + .surfaces + .clear_doc_rect_in_atlas(&mut self.gpu_state, self.render_area); } } } diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 3a1d20d900..e3a9512e08 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -289,6 +289,33 @@ impl Surfaces { Ok(()) } + pub fn clear_doc_rect_in_atlas( + &mut self, + gpu_state: &mut GpuState, + doc_rect: skia::Rect, + ) -> Result<()> { + if doc_rect.is_empty() { + return Ok(()); + } + + self.ensure_atlas_contains(gpu_state, doc_rect)?; + + // Destination is document-space rect mapped into atlas pixel coords. + let dst = skia::Rect::from_xywh( + (doc_rect.left - self.atlas_origin.x) * self.atlas_scale, + (doc_rect.top - self.atlas_origin.y) * self.atlas_scale, + doc_rect.width() * self.atlas_scale, + doc_rect.height() * self.atlas_scale, + ); + + let canvas = self.atlas.canvas(); + canvas.save(); + canvas.clip_rect(dst, None, true); + canvas.clear(skia::Color::TRANSPARENT); + canvas.restore(); + Ok(()) + } + pub fn clear_tiles(&mut self) { self.tiles.clear(); } diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 74b81d6336..c624dad43d 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -160,14 +160,24 @@ impl State { // Only remove the children when is being deleted from the owner if shape.parent_id.is_none() || shape.parent_id == Some(parent_id) { - let tiles::TileRect(rsx, rsy, rex, rey) = - self.render_state.get_tiles_for_shape(shape, &self.shapes); - for x in rsx..=rex { - for y in rsy..=rey { - let tile = tiles::Tile(x, y); - self.render_state.remove_cached_tile(tile); - self.render_state.tiles.remove_shape_at(tile, shape.id); - } + // IMPORTANT: + // Do NOT use `get_tiles_for_shape` here. That method intersects the shape + // tiles with the current interest area, which means we'd only invalidate + // the subset currently near the viewport. When the user later pans/zooms + // to reveal previously cached tiles, stale pixels could reappear. + // + // Instead, remove the shape from *all* tiles where it was indexed, and + // drop cached tiles for those entries. + let indexed_tiles: Vec = self + .render_state + .tiles + .get_tiles_of(shape.id) + .map(|t| t.iter().copied().collect()) + .unwrap_or_default(); + + for tile in indexed_tiles { + self.render_state.remove_cached_tile(tile); + self.render_state.tiles.remove_shape_at(tile, shape.id); } if let Some(shape_to_delete) = self.shapes.get(&id) { From 88dbfe7602a2333209e46ff5e37a5c771683cb67 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 17 Apr 2026 13:58:13 +0200 Subject: [PATCH 85/89] :bug: Fix restore renderer state after thumbnail render_shape_pixels --- render-wasm/src/render.rs | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 400917604e..8ad6fa79aa 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -230,6 +230,7 @@ impl NodeRenderState { /// - `enter(...)` / `exit(...)` should be called when entering and leaving shape /// render contexts. /// - `is_active()` returns whether the current shape is being rendered in focus. +#[derive(Clone)] pub struct FocusMode { shapes: Vec, active: bool, @@ -1792,6 +1793,26 @@ impl RenderState { ) -> Result<(Vec, i32, i32)> { let target_surface = SurfaceId::Export; + // `render_shape_pixels` is used by the workspace to render thumbnails using the + // same WASM renderer instance. It must not leak any state into the main + // viewport renderer (tile cache, atlas, focus mode, render context, etc.). + // + // In particular, `update_render_context` clears and reconfigures multiple + // render surfaces, and `render_area` drives atlas blits. If we don't restore + // them, the workspace can temporarily show missing tiles until the next + // interaction (e.g. zoom) forces a full context rebuild. + let saved_focus_mode = self.focus_mode.clone(); + let saved_export_context = self.export_context; + let saved_render_area = self.render_area; + let saved_render_area_with_margins = self.render_area_with_margins; + let saved_current_tile = self.current_tile; + let saved_pending_nodes = std::mem::take(&mut self.pending_nodes); + let saved_nested_fills = std::mem::take(&mut self.nested_fills); + let saved_nested_blurs = std::mem::take(&mut self.nested_blurs); + let saved_nested_shadows = std::mem::take(&mut self.nested_shadows); + let saved_ignore_nested_blurs = self.ignore_nested_blurs; + let saved_preview_mode = self.preview_mode; + // Reset focus mode so all shapes in the export tree are rendered. // Without this, leftover focus_mode state from the workspace could // cause shapes (and their background blur) to be skipped. @@ -1843,6 +1864,30 @@ impl RenderState { .expect("PNG encode failed"); let skia::ISize { width, height } = image.dimensions(); + // Restore the workspace render state. + self.focus_mode = saved_focus_mode; + self.export_context = saved_export_context; + self.render_area = saved_render_area; + self.render_area_with_margins = saved_render_area_with_margins; + self.current_tile = saved_current_tile; + self.pending_nodes = saved_pending_nodes; + self.nested_fills = saved_nested_fills; + self.nested_blurs = saved_nested_blurs; + self.nested_shadows = saved_nested_shadows; + self.ignore_nested_blurs = saved_ignore_nested_blurs; + self.preview_mode = saved_preview_mode; + + // Restore render-surface transforms for the workspace context. + // If we have a current tile, restore its tile render context; otherwise + // fall back to restoring the previous render_area (may be empty). + let workspace_scale = self.get_scale(); + if let Some(tile) = self.current_tile { + self.update_render_context(tile); + } else if !self.render_area.is_empty() { + self.surfaces + .update_render_context(self.render_area, workspace_scale); + } + Ok((data.as_bytes().to_vec(), width, height)) } From bc9496deaafe47f2898843bbca64cb8dd4d46b23 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 20 Apr 2026 07:34:30 +0200 Subject: [PATCH 86/89] :tada: Replace run_script tiles-complete dispatch with wapi extern binding --- render-wasm/src/js/wapi.js | 8 ++++++++ render-wasm/src/render.rs | 10 +++------- render-wasm/src/wapi.rs | 16 ++++++++++++++++ 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/render-wasm/src/js/wapi.js b/render-wasm/src/js/wapi.js index 4af5c0bf89..13f3fcb698 100644 --- a/render-wasm/src/js/wapi.js +++ b/render-wasm/src/js/wapi.js @@ -12,5 +12,13 @@ addToLibrary({ } else { return window.cancelAnimationFrame(frameId); } + }, + wapi_notifyTilesRenderComplete: function wapi_notifyTilesRenderComplete() { + // The corresponding listener lives on `document` (main thread), so in a + // worker context we simply skip the dispatch instead of crashing. + if (typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope) { + return; + } + document.dispatchEvent(new CustomEvent('penpot:wasm:tiles-complete')); } }); diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 8ad6fa79aa..93fd9ce591 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -44,12 +44,6 @@ const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3; const MAX_BLOCKING_TIME_MS: i32 = 32; const NODE_BATCH_THRESHOLD: i32 = 3; -/// Dispatches `penpot:wasm:tiles-complete` on `document` so the UI can react when a full -/// tile pass has finished (e.g. remove page-transition blur). -fn notify_tiles_render_complete() { - #[cfg(target_arch = "wasm32")] - crate::run_script!("document.dispatchEvent(new CustomEvent('penpot:wasm:tiles-complete'))"); -} const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0; type ClipStack = Vec<(Rect, Option, Matrix)>; @@ -1763,7 +1757,7 @@ impl RenderState { self.cancel_animation_frame(); self.render_request_id = Some(wapi::request_animation_frame!()); } else { - notify_tiles_render_complete(); + wapi::notify_tiles_render_complete!(); performance::end_measure!("render"); } } @@ -1781,6 +1775,8 @@ impl RenderState { self.render_shape_tree_partial(base_object, tree, timestamp, false)?; } self.flush_and_submit(); + wapi::notify_tiles_render_complete!(); + Ok(()) } diff --git a/render-wasm/src/wapi.rs b/render-wasm/src/wapi.rs index 1947f7e3c6..f9e7e65769 100644 --- a/render-wasm/src/wapi.rs +++ b/render-wasm/src/wapi.rs @@ -35,5 +35,21 @@ macro_rules! cancel_animation_frame { }; } +#[macro_export] +macro_rules! notify_tiles_render_complete { + () => {{ + #[cfg(target_arch = "wasm32")] + unsafe extern "C" { + pub fn wapi_notifyTilesRenderComplete(); + } + + #[cfg(target_arch = "wasm32")] + unsafe { + wapi_notifyTilesRenderComplete() + }; + }}; +} + pub use cancel_animation_frame; +pub use notify_tiles_render_complete; pub use request_animation_frame; From 1d454f379099e27130640b57a24ec375af85131b Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 20 Apr 2026 08:50:40 +0200 Subject: [PATCH 87/89] :bug: Fix stale tile cache when flex reflow changes modifier set between frames --- render-wasm/src/main.rs | 7 ++++++- render-wasm/src/render.rs | 2 -- render-wasm/src/state/shapes_pool.rs | 26 +++++++++++++++++++++++++- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 740fae8104..ee3a7815f3 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -866,7 +866,12 @@ pub extern "C" fn set_structure_modifiers() -> Result<()> { #[wasm_error] pub extern "C" fn clean_modifiers() -> Result<()> { with_state_mut!(state, { - state.shapes.clean_all(); + let prev_modifier_ids = state.shapes.clean_all(); + if !prev_modifier_ids.is_empty() { + state + .render_state + .update_tiles_shapes(&prev_modifier_ids, &mut state.shapes)?; + } }); Ok(()) } diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 93fd9ce591..5df0326ace 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1775,8 +1775,6 @@ impl RenderState { self.render_shape_tree_partial(base_object, tree, timestamp, false)?; } self.flush_and_submit(); - wapi::notify_tiles_render_complete!(); - Ok(()) } diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index 436d57f2ea..7e03befa01 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -278,11 +278,35 @@ impl ShapesPoolImpl { } } - pub fn clean_all(&mut self) { + /// Clears transient per-frame state (modifiers, structure, scale_content) + /// and returns the list of UUIDs that had a `modifier` applied at the + /// moment of cleaning. The caller can use that list to re-sync the tile + /// index / tile cache for those shapes: after cleaning their modifier is + /// gone, but if we don't touch their tiles they keep pointing at the + /// previous modified position and the tile texture cache may serve stale + /// pixels. + pub fn clean_all(&mut self) -> Vec { self.clean_shape_cache(); + + let modified_uuids: Vec = if self.modifiers.is_empty() { + Vec::new() + } else { + let mut idx_to_uuid: HashMap = + HashMap::with_capacity(self.uuid_to_idx.len()); + for (uuid, idx) in self.uuid_to_idx.iter() { + idx_to_uuid.insert(*idx, *uuid); + } + self.modifiers + .keys() + .filter_map(|idx| idx_to_uuid.get(idx).copied()) + .collect() + }; + self.modifiers = HashMap::default(); self.structure = HashMap::default(); self.scale_content = HashMap::default(); + + modified_uuids } pub fn subtree(&self, id: &Uuid) -> ShapesPoolImpl { From b5701923bad1cebc254727763a52d4a42a4e2a63 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Mon, 20 Apr 2026 16:14:44 +0200 Subject: [PATCH 88/89] :bug: Fix dragging shape --- frontend/src/app/main/ui/workspace/viewport_wasm.cljs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index db9e1365ca..8cb0910f1f 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -403,7 +403,7 @@ ;; The loading overlay is suppressed because on-shapes-ready ;; is set. (wasm.api/initialize-viewport - base-objects zoom vbox background) + base-objects zoom vbox :background background) (reset! initialized? true) (mf/set-ref-val! last-file-version-id-ref file-version-id)) (when (and (some? file-version-id) From c42eb6ff86dbc8a0018409f85352490fb0a0f0c9 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Mon, 20 Apr 2026 16:31:56 +0200 Subject: [PATCH 89/89] :bug: Fix problem with selection performance --- frontend/src/app/render_wasm/api.cljs | 15 +------------ frontend/src/app/render_wasm/shape.cljs | 6 +----- frontend/src/app/worker/index.cljs | 28 ------------------------- 3 files changed, 2 insertions(+), 47 deletions(-) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 0178f8afa5..ec064493e3 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -27,7 +27,6 @@ [app.main.router :as rt] [app.main.store :as st] [app.main.ui.shapes.text] - [app.main.worker :as mw] [app.render-wasm.api.fonts :as f] [app.render-wasm.api.shapes :as shapes] [app.render-wasm.api.texts :as t] @@ -1114,15 +1113,6 @@ (render-finish) (perf/end-measure "render-from-cache")) -(defn update-text-rect! - [id] - (when wasm/context-initialized? - (mw/emit! - {:cmd :index/update-text-rect - :page-id (:current-page-id @st/state) - :shape-id id - :dimensions (get-text-dimensions id)}))) - (defn- ensure-text-content "Guarantee that the shape always sends a valid text tree to WASM. When the content is nil (freshly created text) we fall back to @@ -1195,10 +1185,7 @@ "Synchronously update text layouts for all shapes and send rect updates to the worker index." [text-ids] - (run! (fn [id] - (f/update-text-layout id) - (update-text-rect! id)) - text-ids)) + (run! f/update-text-layout text-ids)) (defn process-pending [shapes thumbnails full on-complete] diff --git a/frontend/src/app/render_wasm/shape.cljs b/frontend/src/app/render_wasm/shape.cljs index 032f3d7926..ac61bbac2e 100644 --- a/frontend/src/app/render_wasm/shape.cljs +++ b/frontend/src/app/render_wasm/shape.cljs @@ -323,11 +323,7 @@ (vals) (rx/from) (rx/mapcat (fn [callback] (callback))) - (rx/reduce conj []) - (rx/tap - (fn [] - (when (cfh/text-shape? shape) - (api/update-text-rect! (:id shape))))))) + (rx/reduce conj []))) (rx/empty)))) (defn process-shape-changes! diff --git a/frontend/src/app/worker/index.cljs b/frontend/src/app/worker/index.cljs index c40f0b6fd8..3ff1f37d19 100644 --- a/frontend/src/app/worker/index.cljs +++ b/frontend/src/app/worker/index.cljs @@ -10,9 +10,7 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.files.changes :as ch] - [app.common.geom.matrix :as gmt] [app.common.geom.rect :as grc] - [app.common.geom.shapes :as gsh] [app.common.logging :as log] [app.common.time :as ct] [app.worker.impl :as impl] @@ -65,33 +63,7 @@ (log/dbg :hint "page index updated" :id page-id :elapsed elapsed ::log/sync? true)))) nil)) -(defmethod impl/handler :index/update-text-rect - [{:keys [page-id shape-id dimensions]}] - (let [page (dm/get-in @state [:pages-index page-id]) - objects (get page :objects) - shape (get objects shape-id) - center (gsh/shape->center shape) - transform (:transform shape (gmt/matrix)) - rect (-> (grc/make-rect dimensions) - (grc/rect->points)) - points (gsh/transform-points rect center transform) - selrect (gsh/calculate-selrect points (gsh/points->center points)) - - data {:position-data nil - :points points - :selrect selrect} - - shape (d/patch-object shape data) - - objects - (assoc objects shape-id shape)] - - (swap! state update-in [::text-rect page-id] assoc shape-id data) - (swap! state update-in [::selection page-id] selection/update-index-single objects shape) - nil)) - ;; FIXME: schema - (defmethod impl/handler :index/query-snap [{:keys [page-id frame-id axis ranges bounds] :as message}] (if-let [index (get @state ::snap)]