From 50df7cb5c4ab921e61ad434814a98dff2438a5ef Mon Sep 17 00:00:00 2001 From: Francis Santiago Date: Wed, 6 May 2026 20:06:35 +0200 Subject: [PATCH 01/22] :whale: Harden Nginx security headers Signed-off-by: Francis Santiago --- CHANGES.md | 2 ++ docker/devenv/files/nginx.conf | 29 +++++++++++++++++++++++++ docker/images/files/nginx.conf.template | 12 ++++++++++ 3 files changed, 43 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 367292def8..0f142fe5d5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,8 @@ ### :bug: Bugs fixed +- Harden Nginx responses with standard security headers and hide upstream `X-Powered-By` headers + ## 2.16.0 (Unreleased) ### :boom: Breaking changes & Deprecations diff --git a/docker/devenv/files/nginx.conf b/docker/devenv/files/nginx.conf index 3a6f50b4be..c7ac88ac5c 100644 --- a/docker/devenv/files/nginx.conf +++ b/docker/devenv/files/nginx.conf @@ -74,6 +74,11 @@ http { resolver 127.0.0.11 ipv6=off; etag off; + proxy_hide_header X-Powered-By; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header X-Frame-Options SAMEORIGIN always; root /home/penpot/penpot/frontend/resources/public; @@ -211,6 +216,10 @@ http { proxy_set_header User-Agent "curl/8.5.0"; proxy_set_header Host "raw.githubusercontent.com"; proxy_set_header Accept "*/*"; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header X-Frame-Options SAMEORIGIN always; add_header Access-Control-Allow-Origin $http_origin; proxy_buffering off; } @@ -235,6 +244,10 @@ http { proxy_cache penpot; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header X-Frame-Options SAMEORIGIN always; add_header Access-Control-Allow-Origin $http_origin; add_header Cache-Control max-age=86400; add_header X-Cache-Status $upstream_cache_status; @@ -257,16 +270,28 @@ http { proxy_cache penpot; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header X-Frame-Options SAMEORIGIN always; add_header Access-Control-Allow-Origin $http_origin; add_header Cache-Control max-age=86400; add_header X-Cache-Status $upstream_cache_status; } location ~* \.(jpg|png|svg|ttf|woff|woff2|gif)$ { + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header X-Frame-Options SAMEORIGIN always; add_header Cache-Control "public, max-age=604800" always; # 7 days } location ~* \.(js|css|wasm)$ { + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header X-Frame-Options SAMEORIGIN always; add_header Cache-Control "no-store" always; } @@ -274,6 +299,10 @@ http { return 301 " /404"; } + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header X-Frame-Options SAMEORIGIN always; add_header Cache-Control "no-store" always; try_files $uri /index.html$is_args$args /index.html =404; } diff --git a/docker/images/files/nginx.conf.template b/docker/images/files/nginx.conf.template index 0daab4b9d4..62d2113076 100644 --- a/docker/images/files/nginx.conf.template +++ b/docker/images/files/nginx.conf.template @@ -80,6 +80,11 @@ http { charset utf-8; etag off; + proxy_hide_header X-Powered-By; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header X-Frame-Options SAMEORIGIN always; root /var/www/app/; @@ -177,6 +182,10 @@ http { include /etc/nginx/overrides/location.d/*.conf; location ~* \.(js|css|jpg|png|svg|gif|ttf|woff|woff2|wasm|map)$ { + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header X-Frame-Options SAMEORIGIN always; add_header Cache-Control "public, max-age=604800" always; # 7 days } @@ -184,6 +193,9 @@ http { return 301 " /404"; } + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header X-Frame-Options SAMEORIGIN always; add_header Cache-Control "no-store, no-cache, max-age=0" always; try_files $uri /index.html$is_args$args /index.html =404; From 4f172afce5c0ecf2bc37c5888ef0169aa814a65d Mon Sep 17 00:00:00 2001 From: Francis Santiago Date: Thu, 7 May 2026 13:42:02 +0200 Subject: [PATCH 02/22] :whale: Reuse Nginx security headers config Signed-off-by: Francis Santiago --- .../devenv/files/nginx-security-headers.conf | 4 ++ docker/devenv/files/nginx.conf | 38 +++++-------------- docker/images/Dockerfile.frontend | 1 + .../images/files/nginx-security-headers.conf | 4 ++ docker/images/files/nginx.conf.template | 17 +++------ 5 files changed, 24 insertions(+), 40 deletions(-) create mode 100644 docker/devenv/files/nginx-security-headers.conf create mode 100644 docker/images/files/nginx-security-headers.conf diff --git a/docker/devenv/files/nginx-security-headers.conf b/docker/devenv/files/nginx-security-headers.conf new file mode 100644 index 0000000000..d41baf3a22 --- /dev/null +++ b/docker/devenv/files/nginx-security-headers.conf @@ -0,0 +1,4 @@ +add_header X-Content-Type-Options "nosniff" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; +add_header X-Frame-Options SAMEORIGIN always; diff --git a/docker/devenv/files/nginx.conf b/docker/devenv/files/nginx.conf index c7ac88ac5c..e93ca0750c 100644 --- a/docker/devenv/files/nginx.conf +++ b/docker/devenv/files/nginx.conf @@ -75,10 +75,7 @@ http { etag off; proxy_hide_header X-Powered-By; - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - add_header X-Frame-Options SAMEORIGIN always; + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; root /home/penpot/penpot/frontend/resources/public; @@ -97,6 +94,7 @@ http { proxy_pass $redirect_uri; proxy_ssl_server_name on; + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; add_header x-internal-redirect "$redirect_uri"; add_header x-cache-control "$redirect_cache_control"; add_header cache-control "$redirect_cache_control"; @@ -113,6 +111,7 @@ http { location /internal/assets { internal; alias /home/penpot/penpot/backend/assets; + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; add_header x-internal-redirect "$upstream_http_x_accel_redirect"; } @@ -191,6 +190,7 @@ http { location /wasm-playground { alias /home/penpot/penpot/frontend/resources/public/wasm-playground/; + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; add_header Cache-Control "no-cache, max-age=0"; autoindex on; } @@ -216,10 +216,7 @@ http { proxy_set_header User-Agent "curl/8.5.0"; proxy_set_header Host "raw.githubusercontent.com"; proxy_set_header Accept "*/*"; - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - add_header X-Frame-Options SAMEORIGIN always; + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; add_header Access-Control-Allow-Origin $http_origin; proxy_buffering off; } @@ -244,10 +241,7 @@ http { proxy_cache penpot; - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - add_header X-Frame-Options SAMEORIGIN always; + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; add_header Access-Control-Allow-Origin $http_origin; add_header Cache-Control max-age=86400; add_header X-Cache-Status $upstream_cache_status; @@ -270,28 +264,19 @@ http { proxy_cache penpot; - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - add_header X-Frame-Options SAMEORIGIN always; + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; add_header Access-Control-Allow-Origin $http_origin; add_header Cache-Control max-age=86400; add_header X-Cache-Status $upstream_cache_status; } location ~* \.(jpg|png|svg|ttf|woff|woff2|gif)$ { - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - add_header X-Frame-Options SAMEORIGIN always; + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; add_header Cache-Control "public, max-age=604800" always; # 7 days } location ~* \.(js|css|wasm)$ { - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - add_header X-Frame-Options SAMEORIGIN always; + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; add_header Cache-Control "no-store" always; } @@ -299,10 +284,7 @@ http { return 301 " /404"; } - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - add_header X-Frame-Options SAMEORIGIN always; + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; add_header Cache-Control "no-store" always; try_files $uri /index.html$is_args$args /index.html =404; } diff --git a/docker/images/Dockerfile.frontend b/docker/images/Dockerfile.frontend index 9f435551ad..1b44b9c3a1 100644 --- a/docker/images/Dockerfile.frontend +++ b/docker/images/Dockerfile.frontend @@ -17,6 +17,7 @@ ARG BUNDLE_PATH="./bundle-frontend/" COPY $BUNDLE_PATH /var/www/app/ COPY ./files/config.js /var/www/app/js/config.js COPY ./files/nginx.conf.template /tmp/nginx.conf.template +COPY ./files/nginx-security-headers.conf /etc/nginx/nginx-security-headers.conf COPY ./files/nginx-resolvers.conf.template /tmp/resolvers.conf.template COPY ./files/nginx-mime.types /etc/nginx/mime.types COPY ./files/nginx-external-locations.conf /etc/nginx/overrides/location.d/external-locations.conf diff --git a/docker/images/files/nginx-security-headers.conf b/docker/images/files/nginx-security-headers.conf new file mode 100644 index 0000000000..d41baf3a22 --- /dev/null +++ b/docker/images/files/nginx-security-headers.conf @@ -0,0 +1,4 @@ +add_header X-Content-Type-Options "nosniff" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; +add_header X-Frame-Options SAMEORIGIN always; diff --git a/docker/images/files/nginx.conf.template b/docker/images/files/nginx.conf.template index 62d2113076..f365cbd512 100644 --- a/docker/images/files/nginx.conf.template +++ b/docker/images/files/nginx.conf.template @@ -81,10 +81,7 @@ http { etag off; proxy_hide_header X-Powered-By; - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - add_header X-Frame-Options SAMEORIGIN always; + include /etc/nginx/nginx-security-headers.conf; root /var/www/app/; @@ -105,6 +102,7 @@ http { proxy_ssl_server_name on; proxy_pass $redirect_uri; + include /etc/nginx/nginx-security-headers.conf; add_header x-internal-redirect "$redirect_uri"; add_header x-cache-control "$redirect_cache_control"; add_header cache-control "$redirect_cache_control"; @@ -123,6 +121,7 @@ http { location /internal/assets { internal; alias /opt/data/assets; + include /etc/nginx/nginx-security-headers.conf; add_header x-internal-redirect "$upstream_http_x_accel_redirect"; } @@ -182,10 +181,7 @@ http { include /etc/nginx/overrides/location.d/*.conf; location ~* \.(js|css|jpg|png|svg|gif|ttf|woff|woff2|wasm|map)$ { - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - add_header X-Frame-Options SAMEORIGIN always; + include /etc/nginx/nginx-security-headers.conf; add_header Cache-Control "public, max-age=604800" always; # 7 days } @@ -193,10 +189,7 @@ http { return 301 " /404"; } - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - add_header X-Frame-Options SAMEORIGIN always; + include /etc/nginx/nginx-security-headers.conf; add_header Cache-Control "no-store, no-cache, max-age=0" always; try_files $uri /index.html$is_args$args /index.html =404; From e61d51288924554edaafafcfe77e2768cacd8230 Mon Sep 17 00:00:00 2001 From: Milos Milic <124778054+MilosM348@users.noreply.github.com> Date: Wed, 6 May 2026 17:39:26 +0200 Subject: [PATCH 03/22] :bug: Fix missing labels.open i18n key surfacing raw key as aria-label (#9320) --- CHANGES.md | 1 + frontend/translations/en.po | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 0f142fe5d5..167bcbee6c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -134,6 +134,7 @@ - Fix tooltip appearing two times when nested elements [Github #9031](https://github.com/penpot/penpot/issues/9031) - Fix broken update library notification link in the UI [Github #9070](https://github.com/penpot/penpot/issues/9070) - Fix plugin API `ShapeBase.component()` returning the outermost component instead of the immediate component in case of nested component instances [Github #9183](https://github.com/penpot/penpot/issues/9183) +- Fix missing `labels.open` translation that surfaced the raw key as the typography font open-button `aria-label`, breaking screen-reader output (by @MilosM348) ## 2.15.0 (Unreleased) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 5fe66d7884..0c524f9777 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -2948,6 +2948,10 @@ msgstr "Old password" msgid "labels.only-yours" msgstr "Only yours" +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:698 +msgid "labels.open" +msgstr "Open" + #: src/app/main/ui/comments.cljs:911, src/app/main/ui/comments.cljs:976, src/app/main/ui/workspace/palette.cljs:199, src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:107, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:906, src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:155, src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:213, src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:294, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:402, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1031, src/app/main/ui/workspace/sidebar/options/menus/text.cljs:316, src/app/main/ui/workspace/sidebar/options/menus/text.cljs:345, src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:146 msgid "labels.options" msgstr "Options" From cc2933468463f16a091d09306193fe127e72721d Mon Sep 17 00:00:00 2001 From: Dexterity <173429049+Dexterity104@users.noreply.github.com> Date: Wed, 6 May 2026 11:40:39 -0400 Subject: [PATCH 04/22] :bug: Fix swapped analytics event names on MCP tab-switch dialog (#9322) --- frontend/src/app/main/data/workspace/mcp.cljs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs index f4a9c9bacc..8ff9a44706 100644 --- a/frontend/src/app/main/data/workspace/mcp.cljs +++ b/frontend/src/app/main/data/workspace/mcp.cljs @@ -185,11 +185,11 @@ {:content (tr "notifications.mcp.active-in-another-tab") :cancel {:label (tr "labels.dismiss") :callback #(st/emit! (ntf/hide) - (ev/event {::ev/name "confirm-mcp-tab-switch" + (ev/event {::ev/name "dismiss-mcp-tab-switch" ::ev/origin "workspace-notification"}))} :accept {:label (tr "labels.switch") :callback #(st/emit! (connect-mcp) - (ev/event {::ev/name "dismiss-mcp-tab-switch" + (ev/event {::ev/name "confirm-mcp-tab-switch" ::ev/origin "workspace-notification"}))}}))) (rx/of (ntf/hide))))))) From 5fd758597ebc696a3655dd2c51a84c1658f14e1a Mon Sep 17 00:00:00 2001 From: Dexterity <173429049+Dexterity104@users.noreply.github.com> Date: Wed, 6 May 2026 11:42:06 -0400 Subject: [PATCH 05/22] :bug: Fix MCP "active in another tab" notification not clearing (#9321) --- frontend/src/app/main/data/workspace/mcp.cljs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs index 8ff9a44706..2884803a68 100644 --- a/frontend/src/app/main/data/workspace/mcp.cljs +++ b/frontend/src/app/main/data/workspace/mcp.cljs @@ -85,7 +85,7 @@ (update state :mcp assoc :connected-tab id) (and (= "disconnected" (:connection-status data)) - (= id (:connection-status mcp-state))) + (= id (:connected-tab mcp-state))) (update state :mcp dissoc :connected-tab) :else From 2fc4f35cde38f34abed30f753eb4e6306418fdc3 Mon Sep 17 00:00:00 2001 From: Dexterity <173429049+Dexterity104@users.noreply.github.com> Date: Wed, 6 May 2026 11:43:09 -0400 Subject: [PATCH 06/22] :lipstick: Fix typos in comments and docstrings (#9362) --- backend/src/app/binfile/common.clj | 4 ++-- backend/src/app/config.clj | 2 +- backend/src/app/http/sse.clj | 2 +- backend/src/app/rpc/cond.clj | 2 +- common/src/app/common/files/indices.cljc | 2 +- common/src/app/common/files/shapes_builder.cljc | 2 +- common/src/app/common/geom/shapes/flex_layout/bounds.cljc | 2 +- .../src/app/common/geom/shapes/flex_layout/layout_data.cljc | 2 +- .../src/app/common/geom/shapes/grid_layout/layout_data.cljc | 6 +++--- common/src/app/common/logic/libraries.cljc | 4 ++-- common/src/app/common/types/container.cljc | 2 +- common/src/app/common/types/shape/layout.cljc | 2 +- frontend/src/app/main.cljs | 6 +++--- frontend/src/app/main/data/workspace.cljs | 2 +- frontend/src/app/main/data/workspace/libraries.cljs | 2 +- .../src/app/main/data/workspace/tokens/application.cljs | 2 +- frontend/src/app/main/ui/context.cljs | 2 +- frontend/src/app/main/ui/shapes/filters.cljs | 2 +- 18 files changed, 24 insertions(+), 24 deletions(-) diff --git a/backend/src/app/binfile/common.clj b/backend/src/app/binfile/common.clj index f57b8775df..090d512f32 100644 --- a/backend/src/app/binfile/common.clj +++ b/backend/src/app/binfile/common.clj @@ -315,8 +315,8 @@ (defn get-file "Get file, resolve all features and apply migrations. - Usefull when you have plan to apply massive or not cirurgical - operations on file, because it removes the ovehead of lazy fetching + Useful when you have plan to apply massive or not surgical + operations on file, because it removes the overhead of lazy fetching and decoding." [cfg file-id & {:as opts}] (db/run! cfg get-file* file-id opts)) diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index 246d440c73..327d64da99 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -280,7 +280,7 @@ (sm/explainer schema:config)) (defn read-config - "Reads the configuration from enviroment variables and decodes all + "Reads the configuration from environment variables and decodes all known values." [& {:keys [prefix default] :or {prefix "penpot"}}] (->> (read-env prefix) diff --git a/backend/src/app/http/sse.clj b/backend/src/app/http/sse.clj index 765f0c894d..2bc76dc57b 100644 --- a/backend/src/app/http/sse.clj +++ b/backend/src/app/http/sse.clj @@ -21,7 +21,7 @@ (defn- write! [^OutputStream output ^bytes data] - (l/trc :hint "writting data" :data data :length (alength data)) + (l/trc :hint "writing data" :data data :length (alength data)) (.write output data) (.flush output)) diff --git a/backend/src/app/rpc/cond.clj b/backend/src/app/rpc/cond.clj index aeb7f7d99d..41c211bf36 100644 --- a/backend/src/app/rpc/cond.clj +++ b/backend/src/app/rpc/cond.clj @@ -19,7 +19,7 @@ of the object. This function can be applied to the object returned by the `get-object` but also to the RPC return value (in case you don't provide the return value calculated key under `::key` metadata prop. - - `::reuse-key?` enables reusing the key calculated on first time; usefull + - `::reuse-key?` enables reusing the key calculated on first time; useful when the target object is not retrieved on the RPC (typical on retrieving dependent objects). " diff --git a/common/src/app/common/files/indices.cljc b/common/src/app/common/files/indices.cljc index 4e177f052c..11eeaa9aed 100644 --- a/common/src/app/common/files/indices.cljc +++ b/common/src/app/common/files/indices.cljc @@ -13,7 +13,7 @@ (defn- generate-index "An optimized algorithm for calculate parents index that walk from top - to down starting from a provided shape-id. Usefull when you want to + to down starting from a provided shape-id. Useful when you want to create an index for the whole objects or subpart of the tree." [index objects shape-id parents] (let [shape (get objects shape-id) diff --git a/common/src/app/common/files/shapes_builder.cljc b/common/src/app/common/files/shapes_builder.cljc index a70d6eef9b..5a6de2bbe1 100644 --- a/common/src/app/common/files/shapes_builder.cljc +++ b/common/src/app/common/files/shapes_builder.cljc @@ -543,7 +543,7 @@ (update :svg-attrs dissoc :fill) (assoc-in [:fills 0 :fill-color] (clr/parse color-style))) - ;; Only create an opacity if the color is setted. Othewise can create problems down the line + ;; Only create an opacity if the color is set. Otherwise can create problems down the line (and (or (clr/color-string? color-attr) (clr/color-string? color-style)) (dm/get-in shape [:svg-attrs :fillOpacity])) (-> (update :svg-attrs dissoc :fillOpacity) diff --git a/common/src/app/common/geom/shapes/flex_layout/bounds.cljc b/common/src/app/common/geom/shapes/flex_layout/bounds.cljc index 56defa9cc3..14ade3c6e2 100644 --- a/common/src/app/common/geom/shapes/flex_layout/bounds.cljc +++ b/common/src/app/common/geom/shapes/flex_layout/bounds.cljc @@ -12,7 +12,7 @@ [app.common.geom.shapes.points :as gpo] [app.common.types.shape.layout :as ctl])) -;; Setted in app.common.geom.shapes.common-layout +;; Set in app.common.geom.shapes.common-layout ;; We do it this way because circular dependencies (def -child-min-width nil) 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 6d91a25707..c556955697 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 @@ -14,7 +14,7 @@ (def conjv (fnil conj [])) -;; Setted in app.common.geom.shapes.min-size-layout +;; Set in app.common.geom.shapes.min-size-layout ;; We do it this way because circular dependencies (def -child-min-width nil) 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 026c48fe41..a8a53156a5 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 @@ -39,7 +39,7 @@ ;; ;; 5. If any track still has an infinite growth limit set its growth limit to its base size. -;; - Distribute extra space accross spaned tracks +;; - Distribute extra space across spanned tracks ;; - Maximize tracks ;; ;; - Expand flexible tracks @@ -55,7 +55,7 @@ [app.common.math :as mth] [app.common.types.shape.layout :as ctl])) -;; Setted in app.common.geom.shapes.common-layout +;; Set in app.common.geom.shapes.common-layout ;; We do it this way because circular dependencies (def -child-min-width nil) @@ -449,7 +449,7 @@ column-tracks (set-auto-base-size column-tracks children shape-cells bounds objects :column) row-tracks (set-auto-base-size row-tracks children shape-cells bounds objects :row) - ;; Adjust multi-spaned cells with no flex columns + ;; Adjust multi-spanned cells with no flex columns column-tracks (set-auto-multi-span parent column-tracks children-map shape-cells bounds objects :column) row-tracks (set-auto-multi-span parent row-tracks children-map shape-cells bounds objects :row) diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index d1d03f68aa..39762ed139 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -1840,7 +1840,7 @@ ;; On texts, when we want to omit the touched attrs, both text (the actual letters) ;; and attrs (bold, font, etc) are in the same attr :content. - ;; If only one of them is touched, we want to adress this case and + ;; If only one of them is touched, we want to address this case and ;; only update the untouched one text-content-change? (and omit-touched? @@ -2163,7 +2163,7 @@ ;; On texts, both text (the actual letters) ;; and attrs (bold, font, etc) are in the same attr :content. - ;; If only one of them is touched, we want to adress this case and + ;; If only one of them is touched, we want to address this case and ;; only update the untouched one text-change? (and (not skip-operations?) diff --git a/common/src/app/common/types/container.cljc b/common/src/app/common/types/container.cljc index 1689386e36..883103b013 100644 --- a/common/src/app/common/types/container.cljc +++ b/common/src/app/common/types/container.cljc @@ -259,7 +259,7 @@ (some? (find-component-main objects shape only-direct-child?)))) (defn in-any-component? - "Check if the shape is part of any component (main or copy), wether it's + "Check if the shape is part of any component (main or copy), whether it's head or not." [objects shape] (or (ctk/in-component-copy? shape) diff --git a/common/src/app/common/types/shape/layout.cljc b/common/src/app/common/types/shape/layout.cljc index a3c9e31ed6..a8af8d90cc 100644 --- a/common/src/app/common/types/shape/layout.cljc +++ b/common/src/app/common/types/shape/layout.cljc @@ -1074,7 +1074,7 @@ (maybe-remove?))))) (defn check-deassigned-cells - "Clean the cells whith shapes that are no longer in the layout" + "Clean the cells with shapes that are no longer in the layout" [parent objects] (let [child-set (set (:shapes parent)) diff --git a/frontend/src/app/main.cljs b/frontend/src/app/main.cljs index 20830e78c7..775605f356 100644 --- a/frontend/src/app/main.cljs +++ b/frontend/src/app/main.cljs @@ -110,10 +110,10 @@ (defn ^:export init [options] - ;; WORKAROUND: we set this really not usefull property for signal a - ;; sideffect and prevent GCC remove it. We need it because we need + ;; WORKAROUND: we set this really not useful property for signal a + ;; side effect and prevent GCC remove it. We need it because we need ;; to populate the Date prototype with transit related properties - ;; before SES hardning is applied on loading MCP plugin + ;; before SES hardening is applied on loading MCP plugin (unchecked-set js/globalThis "penpotStartDate" (-> (ct/now) (t/encode-str) diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 4bc744174a..e184518feb 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -359,7 +359,7 @@ (rx/of (du/fetch-access-tokens)))) ;; Once the essential data is fetched, lets proceed to - ;; fetch teh file bunldle + ;; fetch the file bundle (rx/of (initialize-file team-id file-id))) (->> stream diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index 9fb3e85feb..c1b4ded657 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -696,7 +696,7 @@ (let [page-id (:current-page-id state) file-id (:current-file-id state) - ;; FIXME: revisit, innefficient access + ;; FIXME: revisit, inefficient access objects (dsh/lookup-page-objects state page-id) libraries (dsh/lookup-libraries state) diff --git a/frontend/src/app/main/data/workspace/tokens/application.cljs b/frontend/src/app/main/data/workspace/tokens/application.cljs index 7c3cc13c0f..41b9307344 100644 --- a/frontend/src/app/main/data/workspace/tokens/application.cljs +++ b/frontend/src/app/main/data/workspace/tokens/application.cljs @@ -731,7 +731,7 @@ (defn apply-spacing-token-separated "Handles edge-case for spacing token when applying token via toggle button. - Splits out `shape-ids` into seperate default actions: + Splits out `shape-ids` into separate default actions: - Layouts take the `default` update function - Shapes inside layout will only take margin" [{:keys [token shapes attr]}] diff --git a/frontend/src/app/main/ui/context.cljs b/frontend/src/app/main/ui/context.cljs index 30568be47c..ee1781a43f 100644 --- a/frontend/src/app/main/ui/context.cljs +++ b/frontend/src/app/main/ui/context.cljs @@ -35,7 +35,7 @@ (def sidebar "A context that intends to store the current sidebar position, - usefull for components that behaves distinctly if they are showed in + useful for components that behaves distinctly if they are showed in right sidebar or left sidebar. Possible values: `:right:` and `:left`." diff --git a/frontend/src/app/main/ui/shapes/filters.cljs b/frontend/src/app/main/ui/shapes/filters.cljs index 6e0adbebea..749b926055 100644 --- a/frontend/src/app/main/ui/shapes/filters.cljs +++ b/frontend/src/app/main/ui/shapes/filters.cljs @@ -137,7 +137,7 @@ (if (or (mth/close? 0.01 (:width selrect)) (mth/close? 0.01 (:height selrect))) - ;; We cannot use "objectBoundingbox" if the shape doesn't have width/heigth + ;; We cannot use "objectBoundingbox" if the shape doesn't have width/height ;; From the SVG spec (https://www.w3.org/TR/SVG11/coords.html#ObjectBoundingBox ;; Keyword objectBoundingBox should not be used when the geometry of the applicable element ;; has no width or no height, such as the case of a horizontal or vertical line, even when From 7e6e7baa71d027bdc508258e4236c95efc8ef881 Mon Sep 17 00:00:00 2001 From: Dexterity <173429049+Dexterity104@users.noreply.github.com> Date: Wed, 6 May 2026 11:43:58 -0400 Subject: [PATCH 07/22] :fire: Remove stray prn debug log in stroke-row* render (#9318) --- .../app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs index 0e6a28612a..723a658466 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs @@ -205,8 +205,6 @@ (when (some? on-reorder) [:> reorder-handler* {:ref dref}]) - (prn "stroke-row*" applied-tokens) - ;; Stroke Color ;; FIXME: memorize stroke color [:div {:class (stl/css :stroke-color-actions)} From 55d085117ba1be820344e32a146842dd33baaf64 Mon Sep 17 00:00:00 2001 From: BitCompass <1698200+bitcompass@users.noreply.github.com> Date: Wed, 6 May 2026 13:11:45 -0400 Subject: [PATCH 08/22] :recycle: Rename measurement and svg-defs components to defc* form (#9306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopts the rumext * suffix convention for the following components, invoking them with the [:> JS-style syntax to match the rest of the codebase (see e.g. rea*, single-selection* in viewport/selection): - measurements: size-display, distance-display-pill, selection-rect, distance-display, selection-guides, measurement - shapes/svg-defs: svg-node, svg-defs (also drop the now-redundant {::mf/wrap-props false} annotations) Updates all call sites in inspect/selection_feedback, shapes/shape, workspace/viewport, and workspace/viewport_wasm. Pure rename — no behavioral change. Signed-off-by: bitcompass Co-authored-by: Andrey Antukh --- .../main/ui/inspect/selection_feedback.cljs | 12 +++--- frontend/src/app/main/ui/measurements.cljs | 38 +++++++++---------- frontend/src/app/main/ui/shapes/shape.cljs | 2 +- frontend/src/app/main/ui/shapes/svg_defs.cljs | 30 +++++++-------- .../src/app/main/ui/workspace/viewport.cljs | 6 +-- .../app/main/ui/workspace/viewport_wasm.cljs | 2 +- 6 files changed, 44 insertions(+), 46 deletions(-) diff --git a/frontend/src/app/main/ui/inspect/selection_feedback.cljs b/frontend/src/app/main/ui/inspect/selection_feedback.cljs index 9b52adb05a..73899c679e 100644 --- a/frontend/src/app/main/ui/inspect/selection_feedback.cljs +++ b/frontend/src/app/main/ui/inspect/selection_feedback.cljs @@ -8,7 +8,7 @@ (:require [app.common.data :as d] [app.common.geom.shapes :as gsh] - [app.main.ui.measurements :refer [size-display measurement]] + [app.main.ui.measurements :refer [size-display* measurement*]] [rumext.v2 :as mf])) ;; ------------------------------------------------ @@ -64,9 +64,9 @@ [:g.selection-feedback {:pointer-events "none"} [:g.selected-shapes [:& selection-rect {:selrect selrect :zoom zoom}] - [:& size-display {:selrect selrect :zoom zoom}]] + [:> size-display* {:selrect selrect :zoom zoom}]] - [:& measurement {:bounds (assoc size :x 0 :y 0) - :selected-shapes selected-shapes - :hover-shape hover-shape - :zoom zoom}]]))) + [:> measurement* {:bounds (assoc size :x 0 :y 0) + :selected-shapes selected-shapes + :hover-shape hover-shape + :zoom zoom}]]))) diff --git a/frontend/src/app/main/ui/measurements.cljs b/frontend/src/app/main/ui/measurements.cljs index 370830fdb8..0e9d823996 100644 --- a/frontend/src/app/main/ui/measurements.cljs +++ b/frontend/src/app/main/ui/measurements.cljs @@ -96,7 +96,7 @@ ;; COMPONENTS ;; ------------------------------------------------ -(mf/defc size-display [{:keys [selrect zoom]}] +(mf/defc size-display* [{:keys [selrect zoom]}] (let [{:keys [x y width height]} selrect size-label (dm/str (fmt/format-number width) " x " (fmt/format-number height)) @@ -123,7 +123,7 @@ :font-size (/ font-size zoom)}} size-label]])) -(mf/defc distance-display-pill [{:keys [x y zoom distance bounds]}] +(mf/defc distance-display-pill* [{:keys [x y zoom distance bounds]}] (let [distance-pill-width (/ distance-pill-width zoom) distance-pill-height (/ distance-pill-height zoom) font-size (/ font-size zoom) @@ -167,7 +167,7 @@ :font-size font-size}} (fmt/format-pixels distance)]])) -(mf/defc selection-rect [{:keys [selrect zoom]}] +(mf/defc selection-rect* [{:keys [selrect zoom]}] (let [{:keys [x y width height]} selrect selection-rect-width (/ selection-rect-width zoom)] [:g.selection-rect @@ -179,7 +179,7 @@ :stroke hover-color :stroke-width selection-rect-width}}]])) -(mf/defc distance-display [{:keys [from to zoom bounds]}] +(mf/defc distance-display* [{:keys [from to zoom bounds]}] (let [fixed-x (if (gsh/fully-contained? from to) (+ (:x to) (/ (:width to) 2)) (+ (:x from) (/ (:width from) 2))) @@ -211,14 +211,14 @@ :style {:stroke distance-color :stroke-width distance-line-stroke}}] - [:& distance-display-pill + [:> distance-display-pill* {:x center-x :y center-y :zoom zoom :distance distance :bounds bounds}]]))))) -(mf/defc selection-guides [{:keys [bounds selrect zoom]}] +(mf/defc selection-guides* [{:keys [bounds selrect zoom]}] [:g.selection-guides (for [[idx [x1 y1 x2 y2]] (d/enumerate (calculate-guides bounds selrect))] [:line {:key (dm/str "guide-" idx) @@ -230,7 +230,7 @@ :stroke-width (/ select-guide-width zoom) :stroke-dasharray (/ select-guide-dasharray zoom)}}])]) -(mf/defc measurement +(mf/defc measurement* [{:keys [bounds frame selected-shapes hover-shape zoom]}] (let [selected-ids (into #{} (map :id) selected-shapes) selected-selrect (gsh/shapes->rect selected-shapes) @@ -240,23 +240,23 @@ (when (seq selected-shapes) [:g.measurement-feedback {:pointer-events "none"} - [:& selection-guides {:selrect selected-selrect - :bounds bounds - :zoom zoom}] - [:& size-display {:selrect selected-selrect :zoom zoom}] + [:> selection-guides* {:selrect selected-selrect + :bounds bounds + :zoom zoom}] + [:> size-display* {:selrect selected-selrect :zoom zoom}] (if (or (not hover-shape) (not hover-selected-shape?)) (when (and frame (not= uuid/zero (:id frame))) (let [frame-bb (-> (:points frame) (grc/points->rect))] [:g.hover-shapes - [:& selection-rect {:type :hover :selrect frame-bb :zoom zoom}] - [:& distance-display {:from frame-bb - :to selected-selrect - :zoom zoom - :bounds bounds-selrect}]])) + [:> selection-rect* {:type :hover :selrect frame-bb :zoom zoom}] + [:> distance-display* {:from frame-bb + :to selected-selrect + :zoom zoom + :bounds bounds-selrect}]])) [:g.hover-shapes - [:& selection-rect {:type :hover :selrect hover-selrect :zoom zoom}] - [:& size-display {:selrect hover-selrect :zoom zoom}] - [:& distance-display {:from hover-selrect :to selected-selrect :zoom zoom :bounds bounds-selrect}]])]))) + [:> selection-rect* {:type :hover :selrect hover-selrect :zoom zoom}] + [:> size-display* {:selrect hover-selrect :zoom zoom}] + [:> distance-display* {:from hover-selrect :to selected-selrect :zoom zoom :bounds bounds-selrect}]])]))) diff --git a/frontend/src/app/main/ui/shapes/shape.cljs b/frontend/src/app/main/ui/shapes/shape.cljs index ba69c5753e..d8df772a89 100644 --- a/frontend/src/app/main/ui/shapes/shape.cljs +++ b/frontend/src/app/main/ui/shapes/shape.cljs @@ -125,7 +125,7 @@ [:& ed/export-data {:shape shape}]) [:defs - [:& defs/svg-defs {:shape shape :render-id render-id}] + [:> defs/svg-defs* {:shape shape :render-id render-id}] ;; The filters for frames should be setup inside the container. (when-not (cfh/frame-shape? shape) diff --git a/frontend/src/app/main/ui/shapes/svg_defs.cljs b/frontend/src/app/main/ui/shapes/svg_defs.cljs index 39328b752c..e4404b8311 100644 --- a/frontend/src/app/main/ui/shapes/svg_defs.cljs +++ b/frontend/src/app/main/ui/shapes/svg_defs.cljs @@ -24,8 +24,7 @@ (str transform-matrix " " val) (str transform-matrix))))) -(mf/defc svg-node - {::mf/wrap-props false} +(mf/defc svg-node* [{:keys [type node prefix-id transform bounds]}] (cond (string? node) node @@ -91,12 +90,12 @@ [:> (name tag) props [:> wrapper wrapper-props (for [[index node] (d/enumerate content)] - [:& svg-node {:key (dm/str "node-" index) - :type type - :node node - :prefix-id prefix-id - :transform transform - :bounds bounds}])]]))) + [:> svg-node* {:key (dm/str "node-" index) + :type type + :node node + :prefix-id prefix-id + :transform transform + :bounds bounds}])]]))) (defn- get-svg-def-bounds [{:keys [tag attrs] :as node} shape transform] @@ -108,8 +107,7 @@ (gsh/transform-rect transform)) (gsb/get-shape-filter-bounds shape))) -(mf/defc svg-defs - {::mf/wrap-props false} +(mf/defc svg-defs* [{:keys [shape render-id]}] (let [defs (:svg-defs shape) @@ -131,9 +129,9 @@ (contains? defs id) (str render-id "-"))))] (for [[key node] defs] - [:& svg-node {:key (dm/str key) - :type (:type shape) - :node node - :prefix-id prefix-id - :transform transform - :bounds (get-svg-def-bounds node shape transform)}]))) + [:> svg-node* {:key (dm/str key) + :type (:type shape) + :node node + :prefix-id prefix-id + :transform transform + :bounds (get-svg-def-bounds node shape transform)}]))) diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index 26553b35d8..529e569f07 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -501,7 +501,7 @@ :modifiers modifiers}]) (when show-measures? - [:& msr/measurement + [:> msr/measurement* {:bounds vbox :selected-shapes selected-shapes :frame selected-frame @@ -510,7 +510,7 @@ ;; Show distances during movement with ALT (when (and (= transform :move) @alt? (seq selected-shapes)) - [:& msr/measurement + [:> msr/measurement* {:bounds vbox :selected-shapes selected-shapes :frame selected-frame @@ -522,7 +522,7 @@ duplicated-info (get-in @(deref state-var) [:workspace-local :duplicated])] (when (and (= transform :move) @alt? duplicated-info) [:g.duplicated-distance - [:& msr/distance-display + [:> msr/distance-display* {:from (get duplicated-info :selrect-original) :to (get duplicated-info :selrect-duplicated) :zoom zoom diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 915fc01f81..c15d0bcbad 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -654,7 +654,7 @@ :zoom zoom}]) (when show-measures? - [:& msr/measurement + [:> msr/measurement* {:bounds vbox :selected-shapes selected-shapes :frame selected-frame From 5c4d16fc2b7178ccefe38eb6a42dda6588827c97 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Tue, 5 May 2026 15:29:54 +0200 Subject: [PATCH 09/22] :zap: Coalesce live drag preview state and reduce sidebar churn --- frontend/src/app/main/data/workspace/modifiers.cljs | 13 +++++++------ .../src/app/main/data/workspace/transforms.cljs | 9 ++++++--- frontend/src/app/main/refs.cljs | 10 ++++++++-- frontend/src/app/main/streams.cljs | 10 ++++++++++ .../src/app/main/ui/workspace/sidebar/options.cljs | 2 +- 5 files changed, 32 insertions(+), 12 deletions(-) diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index 63a4be935f..c3a13c0dba 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -30,6 +30,7 @@ [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.undo :as dwu] [app.main.features :as features] + [app.main.streams :as ms] [app.render-wasm.api :as wasm.api] [app.render-wasm.shape :as wasm.shape] [beicon.v2.core :as rx] @@ -617,16 +618,16 @@ (defn set-temporary-selrect [selrect] (ptk/reify ::set-temporary-selrect - ptk/UpdateEvent - (update [_ state] - (assoc state :workspace-selrect selrect)))) + ptk/EffectEvent + (effect [_ _ _] + (rx/push! ms/workspace-selrect selrect)))) (defn set-temporary-modifiers [modifiers] (ptk/reify ::set-temporary-modifiers - ptk/UpdateEvent - (update [_ state] - (assoc state :workspace-wasm-modifiers modifiers)))) + ptk/EffectEvent + (effect [_ _ _] + (rx/push! ms/wasm-modifiers modifiers)))) (def ^:private xf:map-key (map key)) diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index a20794bfc2..c6493c9d1a 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -138,9 +138,12 @@ (ptk/reify ::finish-transform ptk/UpdateEvent (update [_ state] - (-> state - (update :workspace-local dissoc :transform :duplicate-move-started?) - (dissoc :workspace-selrect :workspace-wasm-modifiers))))) + (update state :workspace-local dissoc :transform :duplicate-move-started?)) + + ptk/EffectEvent + (effect [_ _ _] + (rx/push! ms/wasm-modifiers nil) + (rx/push! ms/workspace-selrect nil)))) ;; -- Resize -------------------------------------------------------- diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index 870e659746..01bb43a0cb 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -17,6 +17,8 @@ [app.main.data.helpers :as dsh] [app.main.data.workspace.tokens.selected-set :as dwts] [app.main.store :as st] + [app.main.streams :as ms] + [beicon.v2.core :as rx] [okulary.core :as l])) ;; ---- Global refs @@ -161,7 +163,9 @@ (l/derived :workspace-tokens st/state)) (def workspace-selrect - (l/derived :workspace-selrect st/state)) + (let [a (atom nil)] + (rx/sub! ms/workspace-selrect #(reset! a %)) + a)) ;; WARNING: Don't use directly from components, this is a proxy to ;; improve performance of selected-shapes and @@ -385,7 +389,9 @@ (l/derived :workspace-wasm-editor-styles st/state)) (def workspace-wasm-modifiers - (l/derived :workspace-wasm-modifiers st/state)) + (let [a (atom nil)] + (rx/sub! ms/wasm-modifiers #(reset! a %)) + a)) (def ^:private workspace-modifiers-with-objects (l/derived diff --git a/frontend/src/app/main/streams.cljs b/frontend/src/app/main/streams.cljs index d6ebf074b7..0cae9a246f 100644 --- a/frontend/src/app/main/streams.cljs +++ b/frontend/src/app/main/streams.cljs @@ -22,6 +22,16 @@ (or ^boolean (kbd/keyboard-event? event) ^boolean (mse/mouse-event? event))) +;; Live preview state for an interactive transform. Pushed by drag +;; events at the cadence set by upstream `rx/sample` (see +;; `transforms.cljs`); subscribed via plain atoms in `app.main.refs` so +;; updates bypass the Redux store and don't re-run unrelated lenses. +(defonce wasm-modifiers + (rx/behavior-subject nil)) + +(defonce workspace-selrect + (rx/behavior-subject nil)) + ;; --- Derived streams (defonce ^:private pointer diff --git a/frontend/src/app/main/ui/workspace/sidebar/options.cljs b/frontend/src/app/main/ui/workspace/sidebar/options.cljs index c7413af9ac..7f44c7502e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options.cljs @@ -73,7 +73,7 @@ nil))) (mf/defc shape-options* - {::mf/wrap [#(mf/throttle % 100)] + {::mf/wrap [#(mf/throttle % 200)] ::mf/private true} [{:keys [shapes shapes-with-children selected page-id file-id libraries]}] (if (= 1 (count selected)) From d457eb5e5c90d51a30759629b3fc8315f4062f05 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Tue, 5 May 2026 15:35:00 +0200 Subject: [PATCH 10/22] :zap: Translation-aware modifier propagation and lazy parent walks --- common/src/app/common/logic/shapes.cljc | 41 ++-- common/src/app/common/types/container.cljc | 71 ++++-- .../app/main/data/workspace/modifiers.cljs | 221 ++++++++++++------ .../src/app/main/data/workspace/shapes.cljs | 10 +- .../app/main/data/workspace/transforms.cljs | 38 +-- .../test/frontend_tests/helpers/wasm.cljs | 8 + .../logic/frame_guides_test.cljs | 11 +- 7 files changed, 262 insertions(+), 138 deletions(-) diff --git a/common/src/app/common/logic/shapes.cljc b/common/src/app/common/logic/shapes.cljc index 7c45de0fdf..70c9b63fde 100644 --- a/common/src/app/common/logic/shapes.cljc +++ b/common/src/app/common/logic/shapes.cljc @@ -75,27 +75,28 @@ (reduce check-shape changes mod-obj-changes))) (defn generate-update-shapes - [changes ids update-fn objects {:keys [attrs changed-sub-attr ignore-tree ignore-touched with-objects?]}] - (let [changes - (->> ids - (reduce - (fn [changes id] - (let [opts {:attrs attrs - :ignore-geometry? (get ignore-tree id) - :ignore-touched ignore-touched - :with-objects? with-objects?}] - (pcb/update-shapes changes [id] update-fn (d/without-nils opts)))) - (cond-> changes - (some? objects) (pcb/with-objects objects)))) - grid-ids - (->> ids (filter (partial ctl/grid-layout? objects))) + [changes ids update-fn objects {:keys [attrs changed-sub-attr ignore-tree ignore-touched with-objects? translation?]}] + (let [changes (reduce + (fn [changes id] + (let [opts {:attrs attrs + :ignore-geometry? (get ignore-tree id) + :ignore-touched ignore-touched + :with-objects? with-objects?}] + (pcb/update-shapes changes [id] update-fn (d/without-nils opts)))) + (cond-> changes + (some? objects) (pcb/with-objects objects)) + ids) + ;; Translation doesn't shift children between grid cells, so + ;; cell reassignment + child reorder are no-ops. + grid-ids (when-not translation? + (->> ids (filter (partial ctl/grid-layout? objects)))) + changes (cond-> changes + (seq grid-ids) + (-> (pcb/update-shapes grid-ids ctl/assign-cell-positions {:with-objects? true}) + (pcb/reorder-grid-children ids)) - changes - (-> changes - (pcb/update-shapes grid-ids ctl/assign-cell-positions {:with-objects? true}) - (pcb/reorder-grid-children ids) - (cond-> (not ignore-touched) - (generate-unapply-tokens objects changed-sub-attr)))] + (not ignore-touched) + (generate-unapply-tokens objects changed-sub-attr))] changes)) (defn- generate-update-shape-flags diff --git a/common/src/app/common/types/container.cljc b/common/src/app/common/types/container.cljc index 883103b013..7ae9e0e074 100644 --- a/common/src/app/common/types/container.cljc +++ b/common/src/app/common/types/container.cljc @@ -486,53 +486,78 @@ ;; or inside its main component if it's in a copy. comps-nesting-loop?))) +(defn parent-validation-cache + "Pre-computes the `children`-derived data for `find-valid-parent-and-frame-ids`. + Build once per gesture and pass as `cache`; values are delays so unused + branches stay unrealized." + [objects children libraries] + (let [children-ids (set (map :id children)) + top-children (remove #(contains? children-ids (:parent-id %)) children) + all-main? (every? ctk/main-instance? top-children) + get-variant-id (fn [shape] + (when (:component-id shape) + (-> (get-component-from-shape shape libraries) + :variant-id))) + descendants (delay (mapcat #(cfh/get-children-with-self objects %) children-ids)) + any-variant-container-descendant (delay (some ctk/is-variant-container? @descendants)) + descendants-variant-ids-set (delay (->> @descendants + (map get-variant-id) + set)) + any-main-descendant + (delay + (some + (fn [shape] + (some ctk/main-instance? (cfh/get-children-with-self objects (:id shape)))) + children))] + {:top-children top-children + :all-main? all-main? + :descendants descendants + :any-variant-container-descendant any-variant-container-descendant + :descendants-variant-ids-set descendants-variant-ids-set + :any-main-descendant any-main-descendant})) + (defn find-valid-parent-and-frame-ids "Navigate trough the ancestors until find one that is valid. Returns [ parent-id frame-id ]" ([parent-id objects children] - (find-valid-parent-and-frame-ids parent-id objects children false nil)) + (find-valid-parent-and-frame-ids parent-id objects children false nil nil)) ([parent-id objects children pasting? libraries] + (find-valid-parent-and-frame-ids parent-id objects children pasting? libraries nil)) + ([parent-id objects children pasting? libraries cache] (letfn [(get-frame [pid] (if (cfh/frame-shape? objects pid) pid (get-in objects [pid :frame-id])))] - ;; `descendants`, variant-id set, etc. depend only on the moved shapes, not on the - ;; candidate parent. Computing them once per drag (this fn is hot during move) - ;; avoids O(depth * subtree) work when walking invalid ancestors — common with - ;; variants and nested components. - (let [children-ids (into #{} (map :id) children) - top-children (remove #(contains? children-ids (:parent-id %)) children) - all-main? (every? ctk/main-instance? top-children) - get-variant-id - (fn [shape] - (when (:component-id shape) - (-> (get-component-from-shape shape libraries) - :variant-id))) - descendants (mapcat #(cfh/get-children-with-self objects %) children-ids) - any-variant-container-descendant (some ctk/is-variant-container? descendants) - descendants-variant-ids-set (into #{} (map get-variant-id) descendants) - ;; Same as (some #(some ctk/main-instance? (cfh/get-children-with-self objects (:id %))) - ;; children) but a single walk over `descendants`. - any-main-descendant (some ctk/main-instance? descendants)] + ;; Predicates below are ordered so cheap parent/ascendant checks + ;; short-circuit before the descendant delays are forced. + (let [{:keys [top-children all-main? any-variant-container-descendant + descendants-variant-ids-set any-main-descendant]} + (or cache (parent-validation-cache objects children libraries))] + (loop [parent-id parent-id] (let [parent (get objects parent-id) + + ;; We can always move the children to the parent they already have. + ;; But if we are pasting, those are new items, so it is considered a change no-changes? (and (every? #(= parent-id (:parent-id %)) top-children) (not pasting?)) + ascendants (cfh/get-parents-with-self objects parent-id) any-main-ascendant (some ctk/main-instance? ascendants) any-variant-container-ascendant (some ctk/is-variant-container? ascendants)] + (if (or no-changes? (and (not (invalid-structure-for-component? objects parent children pasting? libraries)) ;; If we are moving (not pasting) into a main component, no descendant can be main - (or pasting? (nil? any-main-descendant) (not (ctk/main-instance? parent))) + (or pasting? (not (ctk/main-instance? parent)) (nil? @any-main-descendant)) ;; Don't allow variant-container inside variant container nor main - (or (not any-variant-container-descendant) - (and (not any-variant-container-ascendant) (not any-main-ascendant))) + (or (and (not any-variant-container-ascendant) (not any-main-ascendant)) + (not @any-variant-container-descendant)) ;; If the parent is a variant-container, all the items should be main (or (not (ctk/is-variant-container? parent)) all-main?) ;; If we are pasting, the parent can't be a "brother" of any of the pasted items, ;; so not have the same variant-id of any descendant (or (not pasting?) (not (ctk/is-variant? parent)) - (not (contains? descendants-variant-ids-set (:variant-id parent)))))) + (not (contains? @descendants-variant-ids-set (:variant-id parent)))))) [parent-id (get-frame parent-id)] (recur (:parent-id parent))))))))) diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index c3a13c0dba..cf954eee54 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -631,9 +631,56 @@ (def ^:private xf:map-key (map key)) +(defn- expand-translation-entry + "Expand one translation-only geometry entry into [descendant-id matrix] + pairs covering the moved shape's full subtree (every descendant gets + the same matrix)." + [[id data] objects subtree-ids-by-id] + (let [m (:transform data) + sub (or (get subtree-ids-by-id id) + (cfh/get-children-ids-with-self objects id))] + (map (fn [sid] [sid m]) sub))) + +(defn- expand-translation-modifiers + "Pure translation propagates as identity to descendants: every shape in + the subtree gets the same matrix. Builds the flat [id matrix] list + directly, skipping the WASM tree walk + FFI roundtrip used by + `propagate-modifiers` for the general (resize/rotate) case. + + Only safe when pixel-snap is off: WASM applies pixel correction + per-shape (different scale/translation per descendant), which we + can't replicate cheaply on the CLJS side." + [geometry-entries objects subtree-ids-by-id] + (into [] + (mapcat #(expand-translation-entry % objects subtree-ids-by-id)) + geometry-entries)) + +(defn- translate-selrect + "Shift `selrect`'s center by (tx, ty). Width/height/transform are + invariant under pure translation, so only `:center` moves." + [selrect tx ty] + (update selrect :center + (fn [c] (gpt/point (+ (:x c) tx) (+ (:y c) ty))))) + +(defn- cached-translation-selrect + "Translation-only fast path for the live selection rect. Avoids a WASM + `get-selection-rect` call per drag frame by caching the gesture-start + base: on the first emission, ask WASM and back out the current delta + to recover the base; on every later emission, shift the cached base + by the new (tx, ty)." + [ids ^js first-matrix cache] + (let [tx (.-e first-matrix) + ty (.-f first-matrix)] + (if-let [base @cache] + (translate-selrect base tx ty) + (let [computed (wasm.api/get-selection-rect ids)] + (vreset! cache (translate-selrect computed (- tx) (- ty))) + computed)))) + #_:clj-kondo/ignore (defn set-wasm-modifiers - [modif-tree & {:keys [ignore-constraints ignore-snap-pixel] + [modif-tree & {:keys [ignore-constraints ignore-snap-pixel + subtree-ids-by-id selection-rect-cache] :or {ignore-constraints false ignore-snap-pixel false} :as params}] (ptk/reify ::set-wasm-modifiers @@ -658,14 +705,24 @@ wasm-props (:wasm-props state) objects (dsh/lookup-page-objects state) snap-pixel? - (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))] + (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) + + translation? + (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] (set-wasm-props! objects prev-wasm-props wasm-props) - (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree)) + (when-not translation? + (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree))) (let [geometry-entries (parse-geometry-modifiers modif-tree) - modifiers (wasm.api/propagate-modifiers geometry-entries snap-pixel?)] + modifiers + (if (and translation? (not snap-pixel?)) + (expand-translation-modifiers geometry-entries objects subtree-ids-by-id) + (wasm.api/propagate-modifiers geometry-entries snap-pixel?))] (wasm.api/set-modifiers modifiers) - (let [ids (into [] xf:map-key geometry-entries) - selrect (wasm.api/get-selection-rect ids)] + (let [ids (into [] xf:map-key geometry-entries) + selrect + (if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers)) + (cached-translation-selrect ids (second (first modifiers)) selection-rect-cache) + (wasm.api/get-selection-rect ids))] (rx/of (set-temporary-selrect selrect) (set-temporary-modifiers modifiers)))))))) @@ -694,92 +751,110 @@ #_:clj-kondo/ignore (defn apply-wasm-modifiers - [modif-tree & {:keys [ignore-constraints ignore-snap-pixel snap-ignore-axis undo-transation?] + [modif-tree & {:keys [ignore-constraints ignore-snap-pixel snap-ignore-axis undo-transation? + subtree-ids-by-id] :or {ignore-constraints false ignore-snap-pixel false snap-ignore-axis nil undo-transation? true} :as params}] (ptk/reify ::apply-wasm-modifiesr ptk/WatchEvent (watch [_ state _] - (wasm.api/clean-modifiers) - (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree)) + (let [translation? + (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] + (wasm.api/clean-modifiers) + (when-not translation? + (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree))) - ;; Apply property changes (e.g. grow-type) to WASM shapes before - ;; propagating geometry, so propagate_modifiers sees the updated state. - (doseq [[id {:keys [property value]}] (extract-property-changes modif-tree)] - (when (= property :grow-type) - (wasm.api/use-shape id) - (wasm.api/set-shape-grow-type value))) + ;; Apply property changes (e.g. grow-type) to WASM shapes before + ;; propagating geometry, so propagate_modifiers sees the updated state. + (doseq [[id {:keys [property value]}] (extract-property-changes modif-tree)] + (when (= property :grow-type) + (wasm.api/use-shape id) + (wasm.api/set-shape-grow-type value))) - (let [objects (dsh/lookup-page-objects state) + (let [objects (dsh/lookup-page-objects state) - geometry-entries - (parse-geometry-modifiers modif-tree) + geometry-entries + (parse-geometry-modifiers modif-tree) - snap-pixel? - (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) + snap-pixel? + (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) - transforms - (into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?)) + transforms + (if (and translation? (not snap-pixel?)) + ;; Mirror WASM `propagate_modifiers` in CLJS: splat the + ;; translation matrix onto every descendant. Without + ;; this step the commit would only touch the dragged + ;; primaries and descendants would snap back to their + ;; pre-drag positions on drop. + ;; + ;; Skipped when `snap-pixel?` is on: WASM applies + ;; per-shape pixel correction (different scale/translate + ;; per descendant) which we can't replicate cheaply on + ;; the CLJS side. + (reduce + (fn [acc [id data]] + (let [t (:transform data) + subtree-ids + (or (get subtree-ids-by-id id) + (cfh/get-children-ids-with-self objects id))] + (reduce (fn [a sid] (assoc a sid t)) acc subtree-ids))) + {} + geometry-entries) + (into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?))) - ;; Pure-translation gesture: every shape's modifier only - ;; contains `:move` operations (no resize/rotate/scale and - ;; no structural mutation) - translation? - (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree)) + ignore-tree + (calculate-ignore-tree-wasm transforms objects) - ignore-tree - (calculate-ignore-tree-wasm transforms objects) + options + (-> params + (assoc :reg-objects? true) + (assoc :ignore-tree ignore-tree) + (assoc :translation? translation?) + ;; Attributes that can change in the transform. This + ;; way we don't have to check all the attributes + (assoc :attrs transform-attrs)) - options - (-> params - (assoc :reg-objects? true) - (assoc :ignore-tree ignore-tree) - (assoc :translation? translation?) - ;; Attributes that can change in the transform. This - ;; way we don't have to check all the attributes - (assoc :attrs transform-attrs)) + modif-tree + (propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state)) - modif-tree - (propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state)) + ids + (into (set (keys modif-tree)) xf:without-uuid-zero (keys transforms)) - ids - (into (set (keys modif-tree)) xf:without-uuid-zero (keys transforms)) + update-shape + (fn [shape] + (let [shape-id (dm/get-prop shape :id) + transform (get transforms shape-id) + modifiers (dm/get-in modif-tree [shape-id :modifiers])] + (-> shape + (gsh/apply-transform transform) + (ctm/apply-structure-modifiers modifiers)))) - update-shape - (fn [shape] - (let [shape-id (dm/get-prop shape :id) - transform (get transforms shape-id) - modifiers (dm/get-in modif-tree [shape-id :modifiers])] - (-> shape - (gsh/apply-transform transform) - (ctm/apply-structure-modifiers modifiers)))) + bool-ids + (into #{} + (comp + (mapcat (partial cfh/get-parents-with-self objects)) + (filter cfh/bool-shape?) + (map :id)) + ids) - bool-ids - (into #{} - (comp - (mapcat (partial cfh/get-parents-with-self objects)) - (filter cfh/bool-shape?) - (map :id)) - ids) + undo-id (js/Symbol)] + (rx/concat + (if undo-transation? + (rx/of (dwu/start-undo-transaction undo-id)) + (rx/empty)) + (rx/of + (clear-local-transform) + (ptk/event ::dwg/move-frame-guides {:ids ids :transforms transforms}) + (ptk/event ::dwcm/move-frame-comment-threads transforms) + (dwsh/update-shapes ids update-shape options) - undo-id (js/Symbol)] - (rx/concat - (if undo-transation? - (rx/of (dwu/start-undo-transaction undo-id)) - (rx/empty)) - (rx/of - (clear-local-transform) - (ptk/event ::dwg/move-frame-guides {:ids ids :transforms transforms}) - (ptk/event ::dwcm/move-frame-comment-threads transforms) - (dwsh/update-shapes ids update-shape options) + ;; The update to the bool path needs to be in a different operation because it + ;; needs to have the updated children info + (dwsh/update-shapes bool-ids path/update-bool-shape (assoc options :with-objects? true))) - ;; The update to the bool path needs to be in a different operation because it - ;; needs to have the updated children info - (dwsh/update-shapes bool-ids path/update-bool-shape (assoc options :with-objects? true))) - - (if undo-transation? - (rx/of (dwu/commit-undo-transaction undo-id)) - (rx/empty))))))) + (if undo-transation? + (rx/of (dwu/commit-undo-transaction undo-id)) + (rx/empty)))))))) (def ^:private xf-rotation-shape diff --git a/frontend/src/app/main/data/workspace/shapes.cljs b/frontend/src/app/main/data/workspace/shapes.cljs index 44fb7539ca..cb2bcad26c 100644 --- a/frontend/src/app/main/data/workspace/shapes.cljs +++ b/frontend/src/app/main/data/workspace/shapes.cljs @@ -28,6 +28,8 @@ [beicon.v2.core :as rx] [potok.v2.core :as ptk])) +;; If anything a translation can mutate is added here, drop the +;; `(when-not translation? …)` guard in `update-shapes` below. (def ^:private update-layout-attr? #{:hidden}) (defn- add-undo-group @@ -180,8 +182,9 @@ (map :id)) update-layout-ids - (->> (into [] xf-update-layout ids) - (not-empty)) + (when-not translation? + (->> (into [] xf-update-layout ids) + (not-empty))) changes (-> (pcb/empty-changes it page-id) @@ -194,7 +197,8 @@ :changed-sub-attr changed-sub-attr :ignore-tree ignore-tree :ignore-touched ignore-touched - :with-objects? with-objects?}) + :with-objects? with-objects? + :translation? translation?}) (cond-> undo-group (pcb/set-undo-group undo-group)) (pcb/set-translation? translation?)) diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index c6493c9d1a..1dcf6a2688 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -658,10 +658,6 @@ ptk/WatchEvent (watch [_ state stream] (let [prev-cell-data (volatile! nil) - ;; Cache the resolved valid parent while hovering the same raw target frame. - ;; `find-valid-parent-and-frame-ids` may walk many ancestors for variants/components, - ;; and the result is stable during the gesture (objects/libraries are constant here). - find-valid-for-raw-cache (volatile! {:raw nil :pair nil}) page-id (:current-page-id state) libraries (dsh/lookup-libraries state) objects (dsh/lookup-page-objects state page-id) @@ -708,7 +704,21 @@ (rx/map #(array pos %)))))))] (if (empty? shapes) (rx/of (finish-transform)) - (let [move-stream + ;; Per-gesture caches: `shapes`/`objects`/`libraries` are + ;; stable for the gesture, so build once and thread through. + (let [parent-validation-cache + (ctn/parent-validation-cache objects shapes libraries) + + subtree-ids-by-id + (into {} + (map (fn [id] + [id (cfh/get-children-ids-with-self objects id)])) + ids) + + selection-rect-cache + (volatile! nil) + + move-stream (->> position ;; We ask for the snap position but we continue even if the result is not available (rx/with-latest-from snap-delta) @@ -722,14 +732,8 @@ (fn [[move-vector mod?]] (let [position (gpt/add from-position move-vector) exclude-frames (if mod? exclude-frames exclude-frames-siblings) - raw-target (ctst/top-nested-frame objects position exclude-frames) - cache @find-valid-for-raw-cache - [target-frame _] - (if (= raw-target (:raw cache)) - (:pair cache) - (let [pair (ctn/find-valid-parent-and-frame-ids raw-target objects shapes false libraries)] - (vreset! find-valid-for-raw-cache {:raw raw-target :pair pair}) - pair)) + target-frame (ctst/top-nested-frame objects position exclude-frames) + [target-frame _] (ctn/find-valid-parent-and-frame-ids target-frame objects shapes false libraries parent-validation-cache) flex-layout? (ctl/flex-layout? objects target-frame) grid-layout? (ctl/grid-layout? objects target-frame) drop-index (when flex-layout? (gslf/get-drop-index target-frame objects position)) @@ -782,7 +786,10 @@ (rx/sample 16) (rx/map (fn [[modifiers snap-ignore-axis]] - (dwm/set-wasm-modifiers modifiers :snap-ignore-axis snap-ignore-axis)))) + (dwm/set-wasm-modifiers modifiers + :snap-ignore-axis snap-ignore-axis + :subtree-ids-by-id subtree-ids-by-id + :selection-rect-cache selection-rect-cache)))) (->> move-stream (rx/with-latest-from ms/mouse-position-alt) @@ -807,7 +814,8 @@ (dwu/start-undo-transaction undo-id) (dwm/apply-wasm-modifiers modifiers :snap-ignore-axis snap-ignore-axis - :undo-transation? false) + :undo-transation? false + :subtree-ids-by-id subtree-ids-by-id) (move-shapes-to-frame ids target-frame drop-index drop-cell) (finish-transform) (dwu/commit-undo-transaction undo-id)))))))) diff --git a/frontend/test/frontend_tests/helpers/wasm.cljs b/frontend/test/frontend_tests/helpers/wasm.cljs index f64d7b7d59..4bfe953c0a 100644 --- a/frontend/test/frontend_tests/helpers/wasm.cljs +++ b/frontend/test/frontend_tests/helpers/wasm.cljs @@ -75,6 +75,11 @@ (track! :set-structure-modifiers) nil) +(defn- mock-set-modifiers + [_modifiers] + (track! :set-modifiers) + nil) + (defn- mock-set-shape-grow-type [_grow-type] (track! :set-shape-grow-type) @@ -141,6 +146,7 @@ {:clean-modifiers wasm.api/clean-modifiers :set-structure-modifiers wasm.api/set-structure-modifiers :propagate-modifiers wasm.api/propagate-modifiers + :set-modifiers wasm.api/set-modifiers :set-shape-grow-type wasm.api/set-shape-grow-type :set-shape-text-content wasm.api/set-shape-text-content :set-shape-text-images wasm.api/set-shape-text-images @@ -152,6 +158,7 @@ (set! wasm.api/clean-modifiers mock-clean-modifiers) (set! wasm.api/set-structure-modifiers mock-set-structure-modifiers) (set! wasm.api/propagate-modifiers mock-propagate-modifiers) + (set! wasm.api/set-modifiers mock-set-modifiers) (set! wasm.api/set-shape-grow-type mock-set-shape-grow-type) (set! wasm.api/set-shape-text-content mock-set-shape-text-content) (set! wasm.api/set-shape-text-images mock-set-shape-text-images) @@ -167,6 +174,7 @@ (set! wasm.api/clean-modifiers (:clean-modifiers orig)) (set! wasm.api/set-structure-modifiers (:set-structure-modifiers orig)) (set! wasm.api/propagate-modifiers (:propagate-modifiers orig)) + (set! wasm.api/set-modifiers (:set-modifiers orig)) (set! wasm.api/set-shape-grow-type (:set-shape-grow-type orig)) (set! wasm.api/set-shape-text-content (:set-shape-text-content orig)) (set! wasm.api/set-shape-text-images (:set-shape-text-images orig)) diff --git a/frontend/test/frontend_tests/logic/frame_guides_test.cljs b/frontend/test/frontend_tests/logic/frame_guides_test.cljs index e20bc99e26..3ed35d3a91 100644 --- a/frontend/test/frontend_tests/logic/frame_guides_test.cljs +++ b/frontend/test/frontend_tests/logic/frame_guides_test.cljs @@ -56,10 +56,13 @@ ;; guide has moved (t/is (= (:position guide') 100)) - ;; WASM mocks were exercised - (t/is (pos? (thw/call-count :clean-modifiers))) - (t/is (pos? (thw/call-count :set-structure-modifiers))) - (t/is (pos? (thw/call-count :propagate-modifiers))))))))))) + ;; WASM bridge was exercised. `dw/update-position` + ;; routes through `apply-wasm-modifiers`, which for + ;; translation-only updates calls only `clean-modifiers` + ;; and computes the per-descendant transforms in CLJS + ;; (skipping `set-structure-modifiers` and + ;; `propagate-modifiers`). + (t/is (pos? (thw/call-count :clean-modifiers))))))))))) From 173ef0dbb05a7a221c7d156de0a1267219308261 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 6 May 2026 19:15:00 +0200 Subject: [PATCH 11/22] :bug: Avoid opaque fill check in drag crop cache hot path --- render-wasm/src/shapes.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 22e9e1e9d8..83ecb89023 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1411,16 +1411,10 @@ impl Shape { } } - let has_opaque_fill = self - .fills - .iter() - .any(|f| math::is_close_to(f.opacity(), 1.0)); - self.blur.is_none() && self.shadows.is_empty() && (self.opacity - 1.0).abs() <= 1e-4 && self.blend_mode().0 == skia::BlendMode::SrcOver - && has_opaque_fill } /// Fill + visible strokes in **document space** for clipping interactive drag textures. From 2ceddc3932de03b97a693a270b73573ec54e151a Mon Sep 17 00:00:00 2001 From: tmimmanuel <14046872+tmimmanuel@users.noreply.github.com> Date: Wed, 6 May 2026 13:40:20 -1000 Subject: [PATCH 12/22] :recycle: Migrate debug icons-preview to modern component syntax (#9381) Signed-off-by: tmimmanuel <155203395+tmimmanuel@users.noreply.github.com> Co-authored-by: tmimmanuel <155203395+tmimmanuel@users.noreply.github.com> Co-authored-by: Andrey Antukh --- frontend/src/app/main/ui.cljs | 4 ++-- .../src/app/main/ui/debug/icons_preview.cljs | 17 +++++++---------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index 39ff4493dd..ca37930376 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -18,7 +18,7 @@ [app.main.router :as rt] [app.main.store :as st] [app.main.ui.context :as ctx] - [app.main.ui.debug.icons-preview :refer [icons-preview]] + [app.main.ui.debug.icons-preview :refer [icons-preview*]] [app.main.ui.debug.playground :refer [playground]] [app.main.ui.ds.product.loader :refer [loader*]] [app.main.ui.error-boundary :refer [error-boundary*]] @@ -211,7 +211,7 @@ :debug-icons-preview (when *assert* - [:& icons-preview]) + [:> icons-preview*]) :debug-playground (when *assert* diff --git a/frontend/src/app/main/ui/debug/icons_preview.cljs b/frontend/src/app/main/ui/debug/icons_preview.cljs index 03c7bd0d99..b519c26774 100644 --- a/frontend/src/app/main/ui/debug/icons_preview.cljs +++ b/frontend/src/app/main/ui/debug/icons_preview.cljs @@ -7,9 +7,8 @@ [cuerdas.core :as str] [rumext.v2 :as mf])) -(mf/defc icons-gallery - {::mf/wrap-props false - ::mf/private true} +(mf/defc icons-gallery* + {::mf/private true} [] (let [entries (->> (seq (js/Object.entries deprecated-icon/default)) (sort-by first))] @@ -21,9 +20,8 @@ val [:span key]])])) -(mf/defc cursors-gallery - {::mf/wrap-props false - ::mf/private true} +(mf/defc cursors-gallery* + {::mf/private true} [] (let [rotation (mf/use-state 0) entries (->> (seq (js/Object.entries c/default)) @@ -43,12 +41,11 @@ [:span (pr-str key)]]))])) -(mf/defc icons-preview - {::mf/wrap-props false} +(mf/defc icons-preview* [] [:article {:class (stl/css :container)} [:h2 {:class (stl/css :title)} "Cursors"] - [:& cursors-gallery] + [:> cursors-gallery*] [:h2 {:class (stl/css :title)} "Icons"] - [:& icons-gallery]]) + [:> icons-gallery*]]) From deb3085de58ef0a42af29f9c9e20307689c602d9 Mon Sep 17 00:00:00 2001 From: tmimmanuel <14046872+tmimmanuel@users.noreply.github.com> Date: Wed, 6 May 2026 13:40:38 -1000 Subject: [PATCH 13/22] :recycle: Migrate frame-preview to modern component syntax (#9382) Signed-off-by: tmimmanuel <155203395+tmimmanuel@users.noreply.github.com> Co-authored-by: tmimmanuel <155203395+tmimmanuel@users.noreply.github.com> Co-authored-by: Andrey Antukh --- frontend/src/app/main/ui.cljs | 2 +- frontend/src/app/main/ui/frame_preview.cljs | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index ca37930376..c8a14df857 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -362,7 +362,7 @@ :share share}]) :frame-preview - [:& frame-preview/frame-preview] + [:> frame-preview/frame-preview*] nil)])) diff --git a/frontend/src/app/main/ui/frame_preview.cljs b/frontend/src/app/main/ui/frame_preview.cljs index de15702d97..ca2f2288ee 100644 --- a/frontend/src/app/main/ui/frame_preview.cljs +++ b/frontend/src/app/main/ui/frame_preview.cljs @@ -9,9 +9,8 @@ [app.common.data :as d] [rumext.v2 :as mf])) -(mf/defc frame-preview - {::mf/wrap-props false - ::mf/wrap [mf/memo]} +(mf/defc frame-preview* + {::mf/wrap [mf/memo]} [] (let [iframe-ref (mf/use-ref nil) From 4f1512186f53868c6fe36fc1bef0017e152e74b8 Mon Sep 17 00:00:00 2001 From: tmimmanuel <14046872+tmimmanuel@users.noreply.github.com> Date: Wed, 6 May 2026 13:41:10 -1000 Subject: [PATCH 14/22] :recycle: Migrate components/code-block to modern component syntax (#9384) Signed-off-by: tmimmanuel <155203395+tmimmanuel@users.noreply.github.com> Co-authored-by: tmimmanuel <155203395+tmimmanuel@users.noreply.github.com> Co-authored-by: Andrey Antukh --- frontend/src/app/main/ui/components/code_block.cljs | 3 +-- frontend/src/app/main/ui/inspect/code.cljs | 10 +++++----- .../src/app/main/ui/workspace/tokens/export/modal.cljs | 4 ++-- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/main/ui/components/code_block.cljs b/frontend/src/app/main/ui/components/code_block.cljs index b15f4eb33d..86c163dae0 100644 --- a/frontend/src/app/main/ui/components/code_block.cljs +++ b/frontend/src/app/main/ui/components/code_block.cljs @@ -16,8 +16,7 @@ (def highlight-fn (delay (modules/load-fn 'app.util.code-highlight/highlight!))) -(mf/defc code-block - {::mf/wrap-props false} +(mf/defc code-block* [{:keys [code type]}] (let [block-ref (mf/use-ref) code (str/trim code)] diff --git a/frontend/src/app/main/ui/inspect/code.cljs b/frontend/src/app/main/ui/inspect/code.cljs index 661bee9075..8a79c70308 100644 --- a/frontend/src/app/main/ui/inspect/code.cljs +++ b/frontend/src/app/main/ui/inspect/code.cljs @@ -17,7 +17,7 @@ [app.main.fonts :as fonts] [app.main.refs :as refs] [app.main.store :as st] - [app.main.ui.components.code-block :refer [code-block]] + [app.main.ui.components.code-block :refer [code-block*]] [app.main.ui.components.copy-button :refer [copy-button*]] [app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]] [app.main.ui.hooks.resize :refer [use-resize-hook]] @@ -299,8 +299,8 @@ (when-not collapsed-css? [:div {:class (stl/css :code-row-display) :style {:--code-height (dm/str (or style-size 400) "px")}} - [:& code-block {:type style-type - :code style-code}]]) + [:> code-block* {:type style-type + :code style-code}]]) [:div {:class (stl/css :resize-area) :on-pointer-down on-style-pointer-down @@ -340,8 +340,8 @@ (when-not collapsed-markup? [:div {:class (stl/css :code-row-display) :style {:--code-height (dm/str (or markup-size 400) "px")}} - [:& code-block {:type markup-type - :code markup-code}]]) + [:> code-block* {:type markup-type + :code markup-code}]]) [:div {:class (stl/css :resize-area) :on-pointer-down on-markup-pointer-down diff --git a/frontend/src/app/main/ui/workspace/tokens/export/modal.cljs b/frontend/src/app/main/ui/workspace/tokens/export/modal.cljs index 0db9bc5fcb..ed7e7930f6 100644 --- a/frontend/src/app/main/ui/workspace/tokens/export/modal.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/export/modal.cljs @@ -13,7 +13,7 @@ [app.main.data.modal :as modal] [app.main.refs :as refs] [app.main.store :as st] - [app.main.ui.components.code-block :refer [code-block]] + [app.main.ui.components.code-block :refer [code-block*]] [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.foundations.typography.heading :refer [heading*]] @@ -67,7 +67,7 @@ [:> export-tab* {:is-disabled is-disabled :on-export on-export} [:div {:class (stl/css :json-preview)} - [:> code-block {:code tokens-json :type "json"}]]])) + [:> code-block* {:code tokens-json :type "json"}]]])) (defn download-tokens-zip! [multi-file-entries] (let [writer (-> (zip/blob-writer {:mtype "application/zip"}) From a08f052da00e7f2c8c7c9f0611b55ee577f6bc6c Mon Sep 17 00:00:00 2001 From: Dexterity <173429049+Dexterity104@users.noreply.github.com> Date: Wed, 6 May 2026 19:43:15 -0400 Subject: [PATCH 15/22] :bug: Remove stray println debug logs from dashboard team invitations (#9365) --- frontend/src/app/main/ui/dashboard/team.cljs | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index 3425c5cb4c..be0a257ac3 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -927,7 +927,6 @@ on-error (fn [form] (let [{:keys [type code] :as error} (ex-data form)] - (println form) (cond (and (= :validation type) (= :profile-is-muted code)) @@ -989,7 +988,6 @@ new-direction (if (= current-field :status) (if (= current-direction :asc) :desc :asc) :asc)] - (println @invitations) (swap! sort-state assoc :field :status :direction new-direction) (swap! invitations #(let [sorted (sort-by (juxt :expired :email) %)] (if (= new-direction :desc) From 6e186143d5691e1bafb8eb89c493106b3f2bb505 Mon Sep 17 00:00:00 2001 From: wdeveloper16 Date: Thu, 7 May 2026 08:44:09 +0200 Subject: [PATCH 16/22] :recycle: Migrate viewport debug and workspace shape debug components to modern syntax (#9395) Co-authored-by: wdeveloper16 --- .../app/main/ui/workspace/shapes/bool.cljs | 2 +- .../app/main/ui/workspace/shapes/common.cljs | 2 +- .../app/main/ui/workspace/shapes/debug.cljs | 21 ++--- .../app/main/ui/workspace/shapes/frame.cljs | 4 +- .../app/main/ui/workspace/shapes/group.cljs | 11 +-- .../app/main/ui/workspace/shapes/path.cljs | 2 +- .../app/main/ui/workspace/shapes/svg_raw.cljs | 2 +- .../app/main/ui/workspace/shapes/text.cljs | 2 +- .../src/app/main/ui/workspace/viewport.cljs | 38 ++++---- .../app/main/ui/workspace/viewport/debug.cljs | 92 ++++++------------- .../app/main/ui/workspace/viewport_wasm.cljs | 52 +++++------ 11 files changed, 92 insertions(+), 136 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/shapes/bool.cljs b/frontend/src/app/main/ui/workspace/shapes/bool.cljs index 536bd7fe68..db69d45585 100644 --- a/frontend/src/app/main/ui/workspace/shapes/bool.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/bool.cljs @@ -41,5 +41,5 @@ [:& bool-shape {:shape shape :childs childs}] (when *assert* - [:& wsd/shape-debug {:shape shape}])])))) + [:> wsd/shape-debug* {:shape shape}])])))) diff --git a/frontend/src/app/main/ui/workspace/shapes/common.cljs b/frontend/src/app/main/ui/workspace/shapes/common.cljs index e09092ca7f..43d3056d58 100644 --- a/frontend/src/app/main/ui/workspace/shapes/common.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/common.cljs @@ -25,4 +25,4 @@ [:> shape-container {:shape shape} [:& component {:shape shape}] (when *assert* - [:& wsd/shape-debug {:shape shape}])]))) + [:> wsd/shape-debug* {:shape shape}])]))) diff --git a/frontend/src/app/main/ui/workspace/shapes/debug.cljs b/frontend/src/app/main/ui/workspace/shapes/debug.cljs index a1a97e65dc..551baea09d 100644 --- a/frontend/src/app/main/ui/workspace/shapes/debug.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/debug.cljs @@ -23,7 +23,7 @@ [cuerdas.core :as str] [rumext.v2 :as mf])) -(mf/defc debug-bounding-boxes +(mf/defc debug-bounding-boxes* [{:keys [shape]}] (let [points (->> (:points shape) (map #(dm/fmt "%,%" (dm/get-prop % :x) (dm/get-prop % :y))) @@ -47,11 +47,9 @@ :stroke-width 1 :stroke color}]])) -(mf/defc debug-text-bounds - {::mf/wrap-props false} - [props] - (let [shape (unchecked-get props "shape") - zoom (mf/deref refs/selected-zoom) +(mf/defc debug-text-bounds* + [{:keys [shape]}] + (let [zoom (mf/deref refs/selected-zoom) bounding-box (gst/shape->rect shape) ctx (js* "document.createElement(\"canvas\").getContext(\"2d\")")] [:g {:transform (gsh/transform-str shape)} @@ -91,8 +89,7 @@ :style {:stroke "green" :stroke-width (/ 2 zoom)}}]]))])) -(mf/defc debug-bool-shape - {::mf/wrap-props false} +(mf/defc debug-bool-shape* [{:keys [shape]}] (let [objects (mf/deref refs/workspace-page-objects) @@ -172,17 +169,17 @@ (when hp [:circle {:data-i i :key (dm/str "c13-" i) :cx (:x hp) :cy (:y hp) :r radius :fill "green"}])]))])) -(mf/defc shape-debug +(mf/defc shape-debug* [{:keys [shape]}] [:* (when ^boolean (dbg/enabled? :bounding-boxes) - [:& debug-bounding-boxes {:shape shape}]) + [:> debug-bounding-boxes* {:shape shape}]) (when (and ^boolean (dbg/enabled? :bool-shapes) ^boolean (cfh/bool-shape? shape)) - [:& debug-bool-shape {:shape shape}]) + [:> debug-bool-shape* {:shape shape}]) (when (and ^boolean (dbg/enabled? :text-outline) ^boolean (cfh/text-shape? shape) ^boolean (seq (:position-data shape))) - [:& debug-text-bounds {:shape shape}])]) + [:> debug-text-bounds* {:shape shape}])]) diff --git a/frontend/src/app/main/ui/workspace/shapes/frame.cljs b/frontend/src/app/main/ui/workspace/shapes/frame.cljs index e6db4677a2..42af2a3ff0 100644 --- a/frontend/src/app/main/ui/workspace/shapes/frame.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/frame.cljs @@ -47,7 +47,7 @@ [:& shape-container {:shape shape :ref ref} [:& frame-shape {:shape shape :childs childs}] (when *assert* - [:& wsd/shape-debug {:shape shape}])])))) + [:> wsd/shape-debug* {:shape shape}])])))) (defn check-props [new-props old-props] @@ -230,5 +230,5 @@ [:& frame-shape {:shape shape :ref content-ref}]])] (when *assert* - [:& wsd/shape-debug {:shape shape}])])))) + [:> wsd/shape-debug* {:shape shape}])])))) diff --git a/frontend/src/app/main/ui/workspace/shapes/group.cljs b/frontend/src/app/main/ui/workspace/shapes/group.cljs index d98d58a2f7..4b0b073079 100644 --- a/frontend/src/app/main/ui/workspace/shapes/group.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/group.cljs @@ -18,11 +18,9 @@ [shape-wrapper] (let [group-shape (group/group-shape shape-wrapper)] (mf/fnc group-wrapper - {::mf/wrap [#(mf/memo' % check-shape-props)] - ::mf/wrap-props false} - [props] - (let [shape (unchecked-get props "shape") - shape-id (dm/get-prop shape :id) + {::mf/wrap [#(mf/memo' % check-shape-props)]} + [{:keys [shape]}] + (let [shape-id (dm/get-prop shape :id) childs* (mf/with-memo [shape-id] (refs/children-objects shape-id)) @@ -33,5 +31,4 @@ {:shape shape :childs childs}] (when *assert* - [:& wsd/shape-debug {:shape shape}])])))) - + [:> wsd/shape-debug* {:shape shape}])])))) diff --git a/frontend/src/app/main/ui/workspace/shapes/path.cljs b/frontend/src/app/main/ui/workspace/shapes/path.cljs index 0f8da115b3..41c37e420d 100644 --- a/frontend/src/app/main/ui/workspace/shapes/path.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/path.cljs @@ -55,4 +55,4 @@ :pointer-events (when editing? "none")} [:& path/path-shape {:shape shape}] (when *assert* - [:& wsd/shape-debug {:shape shape}])])) + [:> wsd/shape-debug* {:shape shape}])])) diff --git a/frontend/src/app/main/ui/workspace/shapes/svg_raw.cljs b/frontend/src/app/main/ui/workspace/shapes/svg_raw.cljs index de1701e016..2eaa47ef4b 100644 --- a/frontend/src/app/main/ui/workspace/shapes/svg_raw.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/svg_raw.cljs @@ -29,7 +29,7 @@ [:& svg-raw-shape {:shape shape :childs childs}] (when *assert* - [:& wsd/shape-debug {:shape shape}])] + [:> wsd/shape-debug* {:shape shape}])] [:& svg-raw-shape {:shape shape :childs childs}]))))) diff --git a/frontend/src/app/main/ui/workspace/shapes/text.cljs b/frontend/src/app/main/ui/workspace/shapes/text.cljs index dfd2bcde83..601f5148fa 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text.cljs @@ -36,4 +36,4 @@ [:& text/text-shape {:shape shape}]] (when *assert* - [:& wsd/shape-debug {:shape shape}])])) + [:> wsd/shape-debug* {:shape shape}])])) diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index 529e569f07..a77bcdfeb7 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -653,34 +653,34 @@ ;; DEBUG LAYOUT DROP-ZONES (when (dbg/enabled? :layout-drop-zones) - [:& wvd/debug-drop-zones {:selected-shapes selected-shapes - :objects base-objects - :hover-top-frame-id @hover-top-frame-id - :zoom zoom}]) + [:> wvd/debug-drop-zones* {:selected-shapes selected-shapes + :objects base-objects + :hover-top-frame-id @hover-top-frame-id + :zoom zoom}]) (when (dbg/enabled? :layout-content-bounds) - [:& wvd/debug-content-bounds {:selected-shapes selected-shapes - :objects base-objects - :hover-top-frame-id @hover-top-frame-id - :zoom zoom}]) + [:> wvd/debug-content-bounds* {:selected-shapes selected-shapes + :objects base-objects + :hover-top-frame-id @hover-top-frame-id + :zoom zoom}]) (when (dbg/enabled? :layout-lines) - [:& wvd/debug-layout-lines {:selected-shapes selected-shapes - :objects base-objects - :hover-top-frame-id @hover-top-frame-id - :zoom zoom}]) - - (when (dbg/enabled? :parent-bounds) - [:& wvd/debug-parent-bounds {:selected-shapes selected-shapes + [:> wvd/debug-layout-lines* {:selected-shapes selected-shapes :objects base-objects :hover-top-frame-id @hover-top-frame-id :zoom zoom}]) + (when (dbg/enabled? :parent-bounds) + [:> wvd/debug-parent-bounds* {:selected-shapes selected-shapes + :objects base-objects + :hover-top-frame-id @hover-top-frame-id + :zoom zoom}]) + (when (dbg/enabled? :grid-layout) - [:& wvd/debug-grid-layout {:selected-shapes selected-shapes - :objects base-objects - :hover-top-frame-id @hover-top-frame-id - :zoom zoom}]) + [:> wvd/debug-grid-layout* {:selected-shapes selected-shapes + :objects base-objects + :hover-top-frame-id @hover-top-frame-id + :zoom zoom}]) (when show-selection-handlers? [:g.selection-handlers {:clipPath "url(#clip-handlers)"} diff --git a/frontend/src/app/main/ui/workspace/viewport/debug.cljs b/frontend/src/app/main/ui/workspace/viewport/debug.cljs index 2b1587533b..d213533852 100644 --- a/frontend/src/app/main/ui/workspace/viewport/debug.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/debug.cljs @@ -21,17 +21,11 @@ [rumext.v2 :as mf])) ;; Helper to debug the bounds when set the "hug" content property -(mf/defc debug-content-bounds +(mf/defc debug-content-bounds* "Debug component to show the auto-layout drop areas" - {::mf/wrap-props false} - [props] + [{:keys [objects zoom selected-shapes hover-top-frame-id]}] - (let [objects (unchecked-get props "objects") - zoom (unchecked-get props "zoom") - selected-shapes (unchecked-get props "selected-shapes") - hover-top-frame-id (unchecked-get props "hover-top-frame-id") - - selected-frame + (let [selected-frame (when (and (= (count selected-shapes) 1) (= :frame (-> selected-shapes first :type))) (first selected-shapes)) @@ -72,17 +66,11 @@ :r (/ 4 zoom) :style {:fill "red"}}])]])))) -(mf/defc debug-layout-lines +(mf/defc debug-layout-lines* "Debug component to show the auto-layout drop areas" - {::mf/wrap-props false} - [props] + [{:keys [objects zoom selected-shapes hover-top-frame-id]}] - (let [objects (unchecked-get props "objects") - zoom (unchecked-get props "zoom") - selected-shapes (unchecked-get props "selected-shapes") - hover-top-frame-id (unchecked-get props "hover-top-frame-id") - - selected-frame + (let [selected-frame (when (and (= (count selected-shapes) 1) (= :frame (-> selected-shapes first :type))) (first selected-shapes)) @@ -117,18 +105,12 @@ [:polygon {:points (->> points (map #(dm/fmt "%, %" (:x %) (:y %))) (str/join " ")) :style {:stroke "red" :stroke-width (/ 2 zoom) :stroke-dasharray (dm/str (/ 10 zoom) " " (/ 5 zoom))}}]]))])))) -(mf/defc debug-drop-zones +(mf/defc debug-drop-zones* "Debug component to show the auto-layout drop areas" - {::mf/wrap [#(mf/memo' % (mf/check-props ["objects" "selected-shapes" "hover-top-frame-id"]))] - ::mf/wrap-props false} - [props] + {::mf/wrap [#(mf/memo' % (mf/check-props ["objects" "selected-shapes" "hover-top-frame-id"]))]} + [{:keys [objects zoom selected-shapes hover-top-frame-id]}] - (let [objects (unchecked-get props "objects") - zoom (unchecked-get props "objects") - selected-shapes (unchecked-get props "selected-shapes") - hover-top-frame-id (unchecked-get props "hover-top-frame-id") - - selected-frame + (let [selected-frame (when (and (= (count selected-shapes) 1) (= :frame (-> selected-shapes first :type))) (first selected-shapes)) @@ -159,15 +141,11 @@ :fill "black"} (:index drop-area)]])])))) -(mf/defc shape-parent-bound - {::mf/wrap [#(mf/memo' % (mf/check-props ["shape" "parent"]))] - ::mf/wrap-props false} - [props] +(mf/defc shape-parent-bound* + {::mf/wrap [#(mf/memo' % (mf/check-props ["shape" "parent"]))]} + [{:keys [shape parent zoom]}] - (let [shape (unchecked-get props "shape") - parent (unchecked-get props "parent") - zoom (unchecked-get props "zoom") - [i1 i2 i3 i4] (gpo/parent-coords-bounds (:points shape) (:points parent))] + (let [[i1 i2 i3 i4] (gpo/parent-coords-bounds (:points shape) (:points parent))] [:* [:polygon {:points (->> [i1 i2 i3 i4] (map #(dm/fmt "%,%" (:x %) (:y %))) (str/join ",")) :style {:fill "none" :stroke "red" :stroke-width (/ 1 zoom)}}] @@ -183,16 +161,10 @@ :y2 (:y i4) :style {:stroke "blue" :stroke-width (/ 1 zoom)}}]])) -(mf/defc debug-parent-bounds - {::mf/wrap-props false} - [props] +(mf/defc debug-parent-bounds* + [{:keys [objects zoom selected-shapes hover-top-frame-id]}] - (let [objects (unchecked-get props "objects") - zoom (unchecked-get props "zoom") - selected-shapes (unchecked-get props "selected-shapes") - hover-top-frame-id (unchecked-get props "hover-top-frame-id") - - selected-frame + (let [selected-frame (when (and (= (count selected-shapes) 1) (= :frame (-> selected-shapes first :type))) (first selected-shapes)) @@ -207,10 +179,10 @@ [:g.debug-parent-bounds {:pointer-events "none"} (for [[idx child] (d/enumerate children)] [:* - [:> shape-parent-bound {:key (dm/str "bound-" idx) - :zoom zoom - :shape child - :parent parent}] + [:> shape-parent-bound* {:key (dm/str "bound-" idx) + :zoom zoom + :shape child + :parent parent}] (let [child-bounds (:points child) points @@ -223,16 +195,10 @@ :r (/ 2 zoom) :style {:fill "red"}}]))])])))) -(mf/defc debug-grid-layout - {::mf/wrap-props false} - [props] +(mf/defc debug-grid-layout* + [{:keys [objects zoom selected-shapes hover-top-frame-id]}] - (let [objects (unchecked-get props "objects") - zoom (unchecked-get props "zoom") - selected-shapes (unchecked-get props "selected-shapes") - hover-top-frame-id (unchecked-get props "hover-top-frame-id") - - selected-frame + (let [selected-frame (when (and (= (count selected-shapes) 1) (= :frame (-> selected-shapes first :type))) (first selected-shapes)) @@ -277,13 +243,9 @@ :style {:stroke "red" :stroke-width (/ 1 zoom)}}]))])))) -(mf/defc debug-text-wasm-position-data - {::mf/wrap-props false} - [props] - (let [zoom (unchecked-get props "zoom") - selected-shapes (unchecked-get props "selected-shapes") - - selected-text +(mf/defc debug-text-wasm-position-data* + [{:keys [zoom selected-shapes]}] + (let [selected-text (when (and (= (count selected-shapes) 1) (= :text (-> selected-shapes first :type))) (first selected-shapes)) diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index c15d0bcbad..000e8bd010 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -782,37 +782,37 @@ ;; DEBUG LAYOUT DROP-ZONES (when (dbg/enabled? :layout-drop-zones) - [:& wvd/debug-drop-zones {:selected-shapes selected-shapes - :objects base-objects - :hover-top-frame-id @hover-top-frame-id - :zoom zoom}]) - - (when (dbg/enabled? :layout-content-bounds) - [:& wvd/debug-content-bounds {:selected-shapes selected-shapes - :objects base-objects - :hover-top-frame-id @hover-top-frame-id - :zoom zoom}]) - - (when (dbg/enabled? :layout-lines) - [:& wvd/debug-layout-lines {:selected-shapes selected-shapes - :objects base-objects - :hover-top-frame-id @hover-top-frame-id - :zoom zoom}]) - - (when (dbg/enabled? :parent-bounds) - [:& wvd/debug-parent-bounds {:selected-shapes selected-shapes - :objects base-objects - :hover-top-frame-id @hover-top-frame-id - :zoom zoom}]) - - (when (dbg/enabled? :grid-layout) - [:& wvd/debug-grid-layout {:selected-shapes selected-shapes + [:> wvd/debug-drop-zones* {:selected-shapes selected-shapes :objects base-objects :hover-top-frame-id @hover-top-frame-id :zoom zoom}]) + (when (dbg/enabled? :layout-content-bounds) + [:> wvd/debug-content-bounds* {:selected-shapes selected-shapes + :objects base-objects + :hover-top-frame-id @hover-top-frame-id + :zoom zoom}]) + + (when (dbg/enabled? :layout-lines) + [:> wvd/debug-layout-lines* {:selected-shapes selected-shapes + :objects base-objects + :hover-top-frame-id @hover-top-frame-id + :zoom zoom}]) + + (when (dbg/enabled? :parent-bounds) + [:> wvd/debug-parent-bounds* {:selected-shapes selected-shapes + :objects base-objects + :hover-top-frame-id @hover-top-frame-id + :zoom zoom}]) + + (when (dbg/enabled? :grid-layout) + [:> wvd/debug-grid-layout* {:selected-shapes selected-shapes + :objects base-objects + :hover-top-frame-id @hover-top-frame-id + :zoom zoom}]) + (when (dbg/enabled? :text-outline) - [:& wvd/debug-text-wasm-position-data + [:> wvd/debug-text-wasm-position-data* {:selected-shapes selected-shapes :objects base-objects :zoom zoom}]) From bc13dfcf9e1d22d97cc30f963ac665219fa3ec53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= Date: Wed, 6 May 2026 15:54:06 +0200 Subject: [PATCH 17/22] :sparkles: Refactor subscriptions page --- .../app/main/ui/settings/subscription.cljs | 220 +++++++++--------- .../app/main/ui/settings/subscription.scss | 42 ++-- 2 files changed, 134 insertions(+), 128 deletions(-) diff --git a/frontend/src/app/main/ui/settings/subscription.cljs b/frontend/src/app/main/ui/settings/subscription.cljs index 18aaca3e5c..51d5df31ba 100644 --- a/frontend/src/app/main/ui/settings/subscription.cljs +++ b/frontend/src/app/main/ui/settings/subscription.cljs @@ -27,75 +27,114 @@ [rumext.v2 :as mf])) (mf/defc plan-card* + {::mf/wrap [mf/memo]} [{:keys [card-title card-title-icon price-value price-period cancel-at benefits-title benefits - cta-text - cta-link - cta-text-trial - cta-link-trial - cta-text-with-icon - cta-link-with-icon + cta-text cta-link + cta-text-trial cta-link-trial + cta-text-with-icon cta-link-with-icon code-action editors recommended show-button-cta]}] - [:div {:class (stl/css-case :plan-card true - :plan-card-highlight recommended)} - [:div {:class (stl/css :plan-card-header)} - [:div {:class (stl/css :plan-card-title-container)} - (when card-title-icon - [:> icon* {:icon-id card-title-icon - :class (stl/css :plan-title-icon) - :size "s"}]) - [:h4 {:class (stl/css :plan-card-title)} card-title] - (when recommended - [:& badge-notification {:content (tr "subscription.settings.recommended") - :size :small - :is-focus true}]) - (when editors [:span {:class (stl/css :plan-editors)} (tr "subscription.settings.editors" editors)])] - (when (and price-value price-period) - [:div {:class (stl/css :plan-price)} - [:span {:class (stl/css :plan-price-value)} price-value] - [:span {:class (stl/css :plan-price-period)} " / " price-period]]) - (when cancel-at - [:div {:class (stl/css :plan-cancel)} - [:span {:class (stl/css :plan-cancel-date)} cancel-at]])] - (when benefits-title [:h5 {:class (stl/css :benefits-title)} benefits-title]) - [:ul {:class (stl/css :benefits-list)} - (for [benefit benefits] - [:li {:key (dm/str benefit) :class (stl/css :benefit)} "- " benefit])] - (when (and cta-link cta-text show-button-cta) - [:> button* {:variant "primary" - :type "button" - :class (stl/css-case :bottom-button (not (and cta-link-trial cta-text-trial))) - :on-click cta-link} cta-text]) - (when (and cta-link-trial cta-text-trial) - [:button {:class (stl/css :cta-button :bottom-link) - :on-click cta-link-trial} cta-text-trial]) - (when (and cta-link-with-icon cta-text-with-icon) - [:button {:class (stl/css :cta-button :more-info) - :on-click cta-link-with-icon} cta-text-with-icon - [:> icon* {:icon-id "open-link" - :size "s"}]]) - (when (and cta-link cta-text (not show-button-cta)) - [:button {:class (stl/css-case :cta-button true - :bottom-link (not (or (and cta-link-trial cta-text-trial) code-action))) - :on-click cta-link} cta-text]) - (when code-action - [:button {:class (stl/css-case :cta-button true - :activate-by-code (= code-action :activate) - :renew-by-code (= code-action :renovate) - :bottom-link (= code-action :renovate)) - ;; TODO add renovation modal - :on-click (when (= code-action :activate) - #(st/emit! (modal/show {:type :nitrate-code-activation})))} - (if (= code-action :activate) - (tr "subscription.settings.activate-by-code") - (tr "nitrate.subscription.settings.renew-with-code"))])]) + (let [has-trial? (and cta-link-trial cta-text-trial) + has-cta-with-icon? (and cta-link-with-icon cta-text-with-icon) + has-cta-button (and cta-link cta-text show-button-cta) + has-cta-link (and cta-link cta-text (not show-button-cta))] + [:div {:class (stl/css-case :plan-card true + :plan-card-highlight recommended)} + [:div {:class (stl/css :plan-card-header)} + [:div {:class (stl/css :plan-card-title-container)} + (when card-title-icon + [:> icon* {:icon-id card-title-icon + :class (stl/css :plan-title-icon) + :size "s"}]) + [:h4 {:class (stl/css :plan-card-title)} card-title] + (when recommended + [:& badge-notification {:content (tr "subscription.settings.recommended") + :size :small + :is-focus true}]) + (when editors + [:span {:class (stl/css :plan-editors)} (tr "subscription.settings.editors" editors)])] + (when (and price-value price-period) + [:div {:class (stl/css :plan-price)} + [:span {:class (stl/css :plan-price-value)} price-value] + [:span {:class (stl/css :plan-price-period)} " / " price-period]]) + (when cancel-at + [:div {:class (stl/css :plan-cancel)} + [:span {:class (stl/css :plan-cancel-date)} cancel-at]])] + (when benefits-title + [:h5 {:class (stl/css :benefits-title)} benefits-title]) + [:ul {:class (stl/css :benefits-list)} + (for [benefit benefits] + [:li {:key (dm/str benefit) :class (stl/css :benefit)} "- " benefit])] + + (when has-cta-button + [:> button* {:variant "primary" + :type "button" + :class (stl/css-case :bottom-button (not has-trial?)) + :on-click cta-link} cta-text]) + (when has-trial? + [:button {:class (stl/css :cta-button :bottom-link) + :on-click cta-link-trial} cta-text-trial]) + (when has-cta-with-icon? + [:button {:class (stl/css :cta-button :more-info) + :on-click cta-link-with-icon} cta-text-with-icon + [:> icon* {:icon-id "open-link" + :size "s"}]]) + (when has-cta-link + [:button {:class (stl/css-case :cta-button true + :bottom-link (not (or has-trial? code-action))) + :on-click cta-link} cta-text]) + (when code-action + [:button {:class (stl/css-case :cta-button true + :activate-by-code (= code-action :activate) + :renew-by-code (= code-action :renovate) + :bottom-link (= code-action :renovate)) + ;; TODO add renovation modal + :on-click (when (= code-action :activate) + #(st/emit! (modal/show {:type :nitrate-code-activation})))} + (if (= code-action :activate) + (tr "subscription.settings.activate-by-code") + (tr "nitrate.subscription.settings.renew-with-code"))])])) + +(defn- get-subscription-name [subscription-type subscribe-to-trial?] + (if subscribe-to-trial? + (if (= subscription-type "unlimited") + (tr "subscription.settings.unlimited-trial") + (tr "subscription.settings.enterprise-trial")) + (case subscription-type + "professional" (tr "subscription.settings.professional") + "unlimited" (tr "subscription.settings.unlimited") + "enterprise" (tr "subscription.settings.enterprise")))) + +(mf/defc ^:private editors-section* + [{:keys [editors]}] + (let [show-editors-list* (mf/use-state false) + show-editors-list (deref show-editors-list*) + handle-click (mf/use-fn + (fn [event] + (dom/stop-propagation event) + (swap! show-editors-list* not)))] + [:* + [:p {:class (stl/css :editors-text)} + (tr "subscription.settings.management.dialog.currently-editors-title" (c (count editors)))] + [:button {:class (stl/css :cta-button :show-editors-button) :on-click handle-click} + (tr "subscription.settings.management.dialog.editors") + [:> icon* {:icon-id (if show-editors-list i/arrow-up i/arrow-down) + :class (stl/css :icon-dropdown) + :size "s"}]] + (when show-editors-list + [:* + [:p {:class (stl/css :editors-text :editors-list-warning)} + (tr "subscription.settings.management.dialog.editors-explanation")] + [:ul {:class (stl/css :editors-list)} + (for [editor editors] + [:li {:key (dm/str (:id editor)) :class (stl/css :team-name)} "- " (:name editor)])]])])) (defn- make-management-form-schema [min-editors] [:map {:title "SeatsForm"} @@ -114,14 +153,7 @@ (deref unlimited-modal-step*) subscription-name - (if subscribe-to-trial - (if (= subscription-type "unlimited") - (tr "subscription.settings.unlimited-trial") - (tr "subscription.settings.enterprise-trial")) - (case subscription-type - "professional" (tr "subscription.settings.professional") - "unlimited" (tr "subscription.settings.unlimited") - "enterprise" (tr "subscription.settings.enterprise"))) + (get-subscription-name subscription-type subscribe-to-trial) min-editors (if (seq editors) (count editors) 1) @@ -184,18 +216,6 @@ (st/emit! (ptk/event ::ev/event {::ev/name "close-subscription-modal"})) (modal/hide!))) - show-editors-list* - (mf/use-state false) - - show-editors-list - (deref show-editors-list*) - - handle-click - (mf/use-fn - (fn [event] - (dom/stop-propagation event) - (swap! show-editors-list* not))) - on-submit (mf/use-fn (mf/deps current-subscription unlimited-modal-step*) @@ -225,20 +245,7 @@ [:div {:class (stl/css :modal-content)} (when (and (seq editors) (not= unlimited-modal-step 2)) - [:* [:p {:class (stl/css :editors-text)} - (tr "subscription.settings.management.dialog.currently-editors-title" (c (count editors)))] - [:button {:class (stl/css :cta-button :show-editors-button) :on-click handle-click} - (tr "subscription.settings.management.dialog.editors") - [:> icon* {:icon-id (if show-editors-list i/arrow-up i/arrow-down) - :class (stl/css :icon-dropdown) - :size "s"}]] - (when show-editors-list - [:* - [:p {:class (stl/css :editors-text :editors-list-warning)} - (tr "subscription.settings.management.dialog.editors-explanation")] - [:ul {:class (stl/css :editors-list)} - (for [editor editors] - [:li {:key (dm/str (:id editor)) :class (stl/css :team-name)} "- " (:name editor)])]])]) + [:> editors-section* {:editors editors}]) (when (and (or (and (= subscription-type "professional") @@ -265,20 +272,20 @@ :class (stl/css :input-field)}]] [:div {:class (stl/css :editors-cost)} [:span {:class (stl/css :modal-text-medium)} - (when (> (get-in @form [:clean-data :min-members]) 25) + (when (> (dm/get-in @form [:clean-data :min-members]) 25) [:> i18n/tr-html* {:class (stl/css :modal-text-cap) :tag-name "span" :content (tr "subscription.settings.management.dialog.price-month" "175")}]) [:> i18n/tr-html* - {:class (stl/css-case :text-strikethrough (> (get-in @form [:clean-data :min-members]) 25)) + {:class (stl/css-case :text-strikethrough (> (dm/get-in @form [:clean-data :min-members]) 25)) :tag-name "span" :content (tr "subscription.settings.management.dialog.price-month" - (* 7 (or (get-in @form [:clean-data :min-members]) 0)))}]] + (* 7 (or (dm/get-in @form [:clean-data :min-members]) 0)))}]] [:span {:class (stl/css :modal-text-medium)} (tr "subscription.settings.management.dialog.payment-explanation")]]] - (when (get-in @form [:errors :min-members]) + (when (dm/get-in @form [:errors :min-members]) [:div {:class (stl/css :error-message)} (tr "subscription.settings.management.dialog.input-error")]) @@ -527,15 +534,15 @@ :benefits ["Loren ipsum", "Loren ipsum", "Loren ipsum"] - :cta-text-with-icon (when (:licenses connectivity) "Control Center") - :cta-link-with-icon (when (:licenses connectivity) dnt/go-to-nitrate-cc) - :cta-text (if (:licenses connectivity) + :cta-text-with-icon (when (not (:manual nitrate-license)) "Control Center") + :cta-link-with-icon (when (not (:manual nitrate-license)) dnt/go-to-nitrate-cc) + :cta-text (if (and (:licenses connectivity) (not (:manual nitrate-license))) (tr "subscription.settings.manage-your-subscription") (tr "nitrate.subscription.settings.manual-cancel")) - :cta-link (if (:licenses connectivity) + :cta-link (if (and (:licenses connectivity) (not (:manual nitrate-license))) dnt/go-to-nitrate-billing open-cancel-contact-sales-modal) - :code-action (when (and (not (:licenses connectivity)) (:manual nitrate-license)) :renovate)}] + :code-action (when (:manual nitrate-license) :renovate)}] (case subscription-type "professional" [:> plan-card* {:card-title (tr "subscription.settings.professional") @@ -615,7 +622,11 @@ (tr "subscription.settings.professional.autosave-benefit"), (tr "subscription.settings.professional.teams-editors-benefit")] :cta-text (tr "subscription.settings.subscribe") - :cta-link #(open-subscription-modal "professional") + :cta-link (if (and (contains? cf/flags :nitrate) nitrate? (= subscription-type "nitrate")) + (if (:licenses connectivity) + dnt/go-to-nitrate-billing + open-cancel-contact-sales-modal) + go-to-payments) :cta-text-with-icon (tr "subscription.settings.more-information") :cta-link-with-icon go-to-pricing-page}]) @@ -769,8 +780,7 @@ [:> icon* {:icon-id "close" :size "m"}]] [:div {:class (stl/css :modal-title :subscription-title)} - (str "Switch to " subscription-type " plan?")] - + (dm/str "Switch to " subscription-type " plan?")] [:div {:class (stl/css :modal-content)} [:div {:class (stl/css :modal-text-medium)} "When you downgrade:"] diff --git a/frontend/src/app/main/ui/settings/subscription.scss b/frontend/src/app/main/ui/settings/subscription.scss index 18ca4400f2..09f1f8e370 100644 --- a/frontend/src/app/main/ui/settings/subscription.scss +++ b/frontend/src/app/main/ui/settings/subscription.scss @@ -38,10 +38,6 @@ margin-block-start: var(--sp-s); } -.membership.first { - margin-block-start: var(--sp-l); -} - .membership-date { @include t.use-typography("body-small"); @@ -109,11 +105,11 @@ border: 1.75px solid var(--color-foreground-primary); stroke-width: 2.25px; padding: px2rem(3); +} - svg { - block-size: var(--sp-m); - inline-size: var(--sp-m); - } +.plan-title-icon svg { + block-size: var(--sp-m); + inline-size: var(--sp-m); } .plan-card-title, @@ -292,16 +288,16 @@ justify-content: center; max-inline-size: $sz-224; - svg { - inline-size: 100%; - block-size: auto; - } - @media (width <= 992px) { display: none; } } +.modal-start svg { + inline-size: 100%; + block-size: auto; +} + .editors-text { @include t.use-typography("body-medium"); @@ -363,21 +359,21 @@ } .radio-btns { - label { - @include t.use-typography("body-large"); - - padding: 0; - display: flex; - align-items: center; - color: var(--color-foreground-secondary); - } - display: flex; flex-direction: column; - padding: 0 0 var(--sp-xl) 0; + padding-block-end: var(--sp-xl); gap: var(--sp-s); } +.radio-btns label { + @include t.use-typography("body-large"); + + padding: 0; + display: flex; + align-items: center; + color: var(--color-foreground-secondary); +} + .modal-contact-content { gap: var(--sp-xl); } From 10a0e9e78cd1f4d60ac03274b0235c2af41249c7 Mon Sep 17 00:00:00 2001 From: Xaviju Date: Thu, 7 May 2026 12:34:37 +0200 Subject: [PATCH 18/22] :recycle: Revert ESC keypress closes plugins (#9267) --- .../src/app/main/data/workspace/shortcuts.cljs | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs index e7ff9a99ed..c25c712681 100644 --- a/frontend/src/app/main/data/workspace/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/shortcuts.cljs @@ -6,12 +6,10 @@ (ns app.main.data.workspace.shortcuts (:require - [app.common.data.macros :as dm] [app.main.data.common :as dcm] [app.main.data.event :as ev] [app.main.data.exports.assets :as de] [app.main.data.modal :as modal] - [app.main.data.plugins :as dpl] [app.main.data.preview :as dp] [app.main.data.profile :as du] [app.main.data.shortcuts :as ds] @@ -32,7 +30,6 @@ [app.main.store :as st] [app.main.ui.hooks.resize :as r] [app.util.dom :as dom] - [beicon.v2.core :as rx] [potok.v2.core :as ptk])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -51,17 +48,6 @@ (when (and can-edit? (not read-only?)) (run! st/emit! events)))) -(def esc-pressed - (ptk/reify ::esc-pressed - ptk/WatchEvent - (watch [_ state _] - (rx/of - :interrupt - (let [selection (dm/get-in state [:workspace-local :selected])] - (if (empty? selection) - (dpl/close-current-plugin) - (dw/deselect-all true))))))) - ;; Shortcuts format https://github.com/ccampbell/mousetrap (def base-shortcuts @@ -149,7 +135,7 @@ :escape {:tooltip (ds/esc) :command "escape" :subsections [:edit] - :fn #(st/emit! esc-pressed)} + :fn #(st/emit! :interrupt (dw/deselect-all true))} :find {:tooltip (ds/meta "F") :command (ds/c-mod "f") :subsections [:edit] :fn #(st/emit! (dw/open-layers-search :find))} From f79cfafae5a7661e27724431bfd4ef1fbcca2869 Mon Sep 17 00:00:00 2001 From: Juanfran Date: Tue, 28 Apr 2026 13:30:35 +0200 Subject: [PATCH 19/22] :sparkles: Show nitrate checkout error on subscription page When the Stripe checkout fails to start, the subscription page now shows an inline error in the Business Nitrate card under the CTA instead of a toast. When the post-payment activation fails, the toast message is updated to point users to support@penpot.app. The nitrate-form modal also passed a URI object to build-nitrate-callback-urls while the underlying append-query-param relied on lambdaisland's u/parse, which only accepts strings. Switched to the local u/uri helper so both strings and URI records work, so failures opened from the modal land on the subscription page. --- frontend/src/app/main/data/nitrate.cljs | 49 ++++++++++++--- .../src/app/main/ui/nitrate/nitrate_form.cljs | 3 +- .../app/main/ui/settings/subscription.cljs | 61 +++++++++++++++---- .../app/main/ui/settings/subscription.scss | 7 +++ .../frontend_tests/data/nitrate_test.cljs | 45 ++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + frontend/translations/en.po | 9 +++ frontend/translations/es.po | 9 +++ 8 files changed, 166 insertions(+), 19 deletions(-) create mode 100644 frontend/test/frontend_tests/data/nitrate_test.cljs diff --git a/frontend/src/app/main/data/nitrate.cljs b/frontend/src/app/main/data/nitrate.cljs index cd4328191e..9f89218084 100644 --- a/frontend/src/app/main/data/nitrate.cljs +++ b/frontend/src/app/main/data/nitrate.cljs @@ -13,6 +13,7 @@ [app.util.i18n :refer [tr]] [app.util.storage :as storage] [beicon.v2.core :as rx] + [cuerdas.core :as str] [potok.v2.core :as ptk])) (def ^:private nitrate-entry-active-key ::nitrate-entry-active) @@ -74,14 +75,48 @@ (let [href (dm/str "/control-center/licenses/billing?callback=" (js/encodeURIComponent go-to-subscription-url))] (st/emit! (rt/nav-raw :href href)))) +(def nitrate-checkout-error-token "nitrate-checkout-error") +(def nitrate-checkout-finish-error-token "nitrate-checkout-finish-error") +(def nitrate-checkout-cancelled-token "nitrate-checkout-cancelled") + +(defn- append-query-param + [url key value] + (let [assoc-q (fn [u] + (update u :query + (fn [q] + (-> (u/query-string->map (or q "")) + (assoc (name key) value) + u/map->query-string)))) + parsed (u/uri url) + fragment (:fragment parsed)] + (if (str/blank? fragment) + (str (assoc-q parsed)) + (-> parsed + (assoc :fragment (str (assoc-q (u/parse fragment)))) + str)))) + +(defn build-nitrate-callback-urls + "Build the success/error/cancel callback URLs from a base URL by appending + a `subscription` query param identifying the outcome." + [base-url] + (let [build (fn [token] + (append-query-param base-url :subscription token))] + {:success-callback (build "subscribed-to-penpot-nitrate") + :error-callback (build nitrate-checkout-error-token) + :finish-error-callback (build nitrate-checkout-finish-error-token) + :cancel-callback (build nitrate-checkout-cancelled-token)})) + (defn go-to-buy-nitrate-license - ([subscription] - (go-to-buy-nitrate-license subscription nil)) - ([subscription callback] - (let [params (cond-> {:subscription subscription} - callback (assoc :callback callback)) - href (dm/str "/control-center/licenses/start?" (u/map->query-string params))] - (st/emit! (rt/nav-raw :href href))))) + [subscription base-url] + (let [{:keys [success-callback error-callback finish-error-callback cancel-callback]} + (build-nitrate-callback-urls base-url) + params {:subscription subscription + :callback success-callback + :error_callback error-callback + :finish_error_callback finish-error-callback + :cancel_callback cancel-callback} + href (dm/str "/control-center/licenses/start?" (u/map->query-string params))] + (st/emit! (rt/nav-raw :href href)))) (defn fetch-connectivity [] diff --git a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs index 13143d5337..bb2bfecadd 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs +++ b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs @@ -40,7 +40,8 @@ (mf/use-fn (mf/deps form) (fn [] - (dnt/go-to-buy-nitrate-license (-> @form :clean-data :subscription name)))) + (let [subscription (-> @form :clean-data :subscription name)] + (dnt/go-to-buy-nitrate-license subscription dnt/go-to-subscription-url)))) on-activate-click (mf/use-fn diff --git a/frontend/src/app/main/ui/settings/subscription.cljs b/frontend/src/app/main/ui/settings/subscription.cljs index 51d5df31ba..972189e6b8 100644 --- a/frontend/src/app/main/ui/settings/subscription.cljs +++ b/frontend/src/app/main/ui/settings/subscription.cljs @@ -4,12 +4,12 @@ [app.common.data.macros :as dm] [app.common.schema :as sm] [app.common.time :as ct] - [app.common.uri :as u] [app.config :as cf] [app.main.data.auth :as da] [app.main.data.event :as ev] [app.main.data.modal :as modal] [app.main.data.nitrate :as dnt] + [app.main.data.notifications :as ntf] [app.main.refs :as refs] [app.main.router :as rt] [app.main.store :as st] @@ -39,7 +39,8 @@ code-action editors recommended - show-button-cta]}] + show-button-cta + inline-error]}] (let [has-trial? (and cta-link-trial cta-text-trial) has-cta-with-icon? (and cta-link-with-icon cta-text-with-icon) @@ -100,7 +101,9 @@ #(st/emit! (modal/show {:type :nitrate-code-activation})))} (if (= code-action :activate) (tr "subscription.settings.activate-by-code") - (tr "nitrate.subscription.settings.renew-with-code"))])])) + (tr "nitrate.subscription.settings.renew-with-code"))]) + (when inline-error + [:p {:class (stl/css :inline-error)} inline-error])])) (defn- get-subscription-name [subscription-type subscribe-to-trial?] (if subscribe-to-trial? @@ -399,6 +402,30 @@ (= params-subscription "subscribed-to-penpot-enterprise") (= params-subscription "subscribed-to-penpot-nitrate")) + nitrate-toast-message + (condp = params-subscription + dnt/nitrate-checkout-finish-error-token (tr "subscription.error.nitrate.checkout-finish-failed") + dnt/nitrate-checkout-cancelled-token (tr "subscription.error.nitrate.checkout-cancelled") + nil) + + nitrate-toast-level + (cond + (= params-subscription dnt/nitrate-checkout-cancelled-token) :info + (some? nitrate-toast-message) :error) + + show-nitrate-start-error? + (= params-subscription dnt/nitrate-checkout-error-token) + + nitrate-start-error* + (mf/use-state false) + + nitrate-start-error? + (deref nitrate-start-error*) + + nitrate-start-error-message + (when nitrate-start-error? + (tr "subscription.error.nitrate.checkout-failed")) + success-modal-is-trial? (-> route :params :query :trial) @@ -485,10 +512,26 @@ (mf/with-effect [authenticated? show-subscription-success-modal? show-trial-subscription-modal? + show-nitrate-start-error? success-modal-is-trial? + nitrate-toast-message + nitrate-toast-level subscription] (when ^boolean authenticated? + (when ^boolean show-nitrate-start-error? + (reset! nitrate-start-error* true)) (cond + (some? nitrate-toast-message) + (st/emit! + (ntf/show {:content nitrate-toast-message + :type :toast + :level nitrate-toast-level + :timeout 7000}) + (rt/nav :settings-subscription {} {::rt/replace true})) + + ^boolean show-nitrate-start-error? + (st/emit! (rt/nav :settings-subscription {} {::rt/replace true})) + ^boolean show-trial-subscription-modal? (st/emit! @@ -676,7 +719,8 @@ :cta-text-with-icon (tr "subscription.settings.more-information") :cta-link-with-icon go-to-pricing-page :code-action :activate - :show-button-cta (not nitrate-license)}])]]])) + :show-button-cta (not nitrate-license) + :inline-error nitrate-start-error-message}])]]])) (def ^:private schema:nitrate-form @@ -703,13 +747,8 @@ (mf/use-fn (mf/deps form) (fn [] - (let [subscription (-> @form :clean-data :subscription name) - return-url (dm/str - (rt/get-current-href) - "?" - (u/map->query-string - {:subscription "subscribed-to-penpot-nitrate"}))] - (dnt/go-to-buy-nitrate-license subscription return-url))))] + (let [subscription (-> @form :clean-data :subscription name)] + (dnt/go-to-buy-nitrate-license subscription (rt/get-current-href)))))] [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog)} diff --git a/frontend/src/app/main/ui/settings/subscription.scss b/frontend/src/app/main/ui/settings/subscription.scss index 09f1f8e370..436a512042 100644 --- a/frontend/src/app/main/ui/settings/subscription.scss +++ b/frontend/src/app/main/ui/settings/subscription.scss @@ -194,6 +194,13 @@ margin-block-start: var(--sp-xl); } +.inline-error { + @include t.use-typography("body-small"); + + color: var(--color-foreground-error); + margin-block: var(--sp-s) 0; +} + .modal-overlay { @extend %modal-overlay-base; } diff --git a/frontend/test/frontend_tests/data/nitrate_test.cljs b/frontend/test/frontend_tests/data/nitrate_test.cljs new file mode 100644 index 0000000000..cfe179b0ea --- /dev/null +++ b/frontend/test/frontend_tests/data/nitrate_test.cljs @@ -0,0 +1,45 @@ +;; 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.nitrate-test + (:require + [app.common.uri :as u] + [app.main.data.nitrate :as dnt] + [cljs.test :as t :include-macros true])) + +(t/deftest build-nitrate-callback-urls-preserves-hash-query + (t/testing "appends subscription to an existing query inside the hash route" + (let [callbacks (dnt/build-nitrate-callback-urls + "https://localhost:3449/#/dashboard/recent?team-id=e6666530-0216-81c8-8007-f17d6087b74f")] + (t/is (= "https://localhost:3449/#/dashboard/recent?team-id=e6666530-0216-81c8-8007-f17d6087b74f&subscription=subscribed-to-penpot-nitrate" + (:success-callback callbacks))) + (t/is (= "https://localhost:3449/#/dashboard/recent?team-id=e6666530-0216-81c8-8007-f17d6087b74f&subscription=nitrate-checkout-error" + (:error-callback callbacks))) + (t/is (= "https://localhost:3449/#/dashboard/recent?team-id=e6666530-0216-81c8-8007-f17d6087b74f&subscription=nitrate-checkout-finish-error" + (:finish-error-callback callbacks))) + (t/is (= "https://localhost:3449/#/dashboard/recent?team-id=e6666530-0216-81c8-8007-f17d6087b74f&subscription=nitrate-checkout-cancelled" + (:cancel-callback callbacks)))))) + +(t/deftest build-nitrate-callback-urls-adds-hash-query-when-missing + (t/testing "adds a hash query when the route has no query string yet" + (let [callbacks (dnt/build-nitrate-callback-urls + "https://localhost:3449/#/settings/subscriptions")] + (t/is (= "https://localhost:3449/#/settings/subscriptions?subscription=subscribed-to-penpot-nitrate" + (:success-callback callbacks)))))) + +(t/deftest build-nitrate-callback-urls-adds-regular-query-without-hash + (t/testing "falls back to the regular URL query when there is no hash route" + (let [callbacks (dnt/build-nitrate-callback-urls + "https://localhost:3449/control-center/licenses/billing?foo=bar")] + (t/is (= "https://localhost:3449/control-center/licenses/billing?foo=bar&subscription=subscribed-to-penpot-nitrate" + (:success-callback callbacks)))))) + +(t/deftest build-nitrate-callback-urls-accepts-uri-object + (t/testing "accepts a URI object as base url (used by the nitrate-form modal)" + (let [callbacks (dnt/build-nitrate-callback-urls + (u/uri "https://localhost:3449/#/settings/subscriptions"))] + (t/is (= "https://localhost:3449/#/settings/subscriptions?subscription=nitrate-checkout-error" + (:error-callback callbacks)))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 5f9078f910..065ed3bf80 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -3,6 +3,7 @@ [cljs.test :as t] [frontend-tests.basic-shapes-test] [frontend-tests.copy-as-svg-test] + [frontend-tests.data.nitrate-test] [frontend-tests.data.repo-test] [frontend-tests.data.uploads-test] [frontend-tests.data.viewer-test] @@ -49,6 +50,7 @@ (t/run-tests 'frontend-tests.basic-shapes-test 'frontend-tests.copy-as-svg-test + 'frontend-tests.data.nitrate-test 'frontend-tests.data.repo-test 'frontend-tests.errors-test 'frontend-tests.main-errors-test diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 0c524f9777..6b14173adc 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -5166,6 +5166,15 @@ msgstr "Inviting people while on the Unlimited plan" msgid "subscription.dashboard.upgrade-plan.power-up" msgstr "Power up" +msgid "subscription.error.nitrate.checkout-cancelled" +msgstr "Payment was not completed. Try again whenever you're ready." + +msgid "subscription.error.nitrate.checkout-failed" +msgstr "We couldn't start the checkout. Please try again. If the problem persists, contact us: support@penpot.app." + +msgid "subscription.error.nitrate.checkout-finish-failed" +msgstr "We’re having trouble activating your subscription. Please try again. If the problem persists, contact us: support@penpot.app." + #: src/app/main/ui/settings/sidebar.cljs:116, src/app/main/ui/settings/subscription.cljs:425, src/app/main/ui/settings/subscription.cljs:462 msgid "subscription.labels" msgstr "Subscription" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 4bbf92126e..f81279149f 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -5084,6 +5084,15 @@ msgstr "Invita a personas mientras estás en el plan Unlimited" msgid "subscription.dashboard.upgrade-plan.power-up" msgstr "Mejora" +msgid "subscription.error.nitrate.checkout-cancelled" +msgstr "El pago no se completó. Inténtalo de nuevo cuando quieras." + +msgid "subscription.error.nitrate.checkout-failed" +msgstr "No hemos podido iniciar el proceso de pago. Inténtalo de nuevo. Si el problema persiste, contáctanos: support@penpot.app." + +msgid "subscription.error.nitrate.checkout-finish-failed" +msgstr "Estamos teniendo problemas para activar tu suscripción. Inténtalo de nuevo. Si el problema persiste, contáctanos: support@penpot.app." + #: src/app/main/ui/settings/sidebar.cljs:116, src/app/main/ui/settings/subscription.cljs:425, src/app/main/ui/settings/subscription.cljs:462 msgid "subscription.labels" msgstr "Suscripción" From dd1ceae667b1e63ba7b114661d57559bae53c727 Mon Sep 17 00:00:00 2001 From: Renzo <170978465+RenzoMXD@users.noreply.github.com> Date: Thu, 7 May 2026 13:10:48 +0200 Subject: [PATCH 20/22] :bug: Fix plugin API fills/strokes arrays read-only (#9161) * :bug: Fix plugin API fills/strokes arrays read-only Signed-off-by: RenzoMXD <170978465+RenzoMXD@users.noreply.github.com> * :bug: Support mutable plugin fill and stroke gradients --------- Signed-off-by: RenzoMXD <170978465+RenzoMXD@users.noreply.github.com> Signed-off-by: Andrey Antukh Co-authored-by: Andrey Antukh --- CHANGES.md | 1 + frontend/src/app/plugins/format.cljs | 116 ++++++++++++++++-- frontend/src/app/plugins/shape.cljs | 76 +++++++----- .../plugins/context_shapes_test.cljs | 94 +++++++++++++- 4 files changed, 240 insertions(+), 47 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 167bcbee6c..196ffa7153 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -135,6 +135,7 @@ - Fix broken update library notification link in the UI [Github #9070](https://github.com/penpot/penpot/issues/9070) - Fix plugin API `ShapeBase.component()` returning the outermost component instead of the immediate component in case of nested component instances [Github #9183](https://github.com/penpot/penpot/issues/9183) - Fix missing `labels.open` translation that surfaced the raw key as the typography font open-button `aria-label`, breaking screen-reader output (by @MilosM348) +- Fix plugin API `shape.fills` and `shape.strokes` arrays being read-only [Github #8357](https://github.com/penpot/penpot/issues/8357) ## 2.15.0 (Unreleased) diff --git a/frontend/src/app/plugins/format.cljs b/frontend/src/app/plugins/format.cljs index f0ff928fc8..9a488a5e71 100644 --- a/frontend/src/app/plugins/format.cljs +++ b/frontend/src/app/plugins/format.cljs @@ -26,6 +26,97 @@ (when (some? coll) (apply array (keep format-fn coll)))) +(defn- numeric-index? + [prop] + (and (string? prop) (boolean (re-matches #"\d+" prop)))) + +(defn- normalize-exclusive-color-props! + [target prop] + (case prop + "fillColor" + (do + (js-delete target "fillColorGradient") + (js-delete target "fillImage")) + + "fillColorGradient" + (do + (js-delete target "fillColor") + (js-delete target "fillImage")) + + "fillImage" + (do + (js-delete target "fillColor") + (js-delete target "fillColorGradient")) + + "strokeColor" + (js-delete target "strokeColorGradient") + + "strokeColorGradient" + (js-delete target "strokeColor") + + nil)) + +(declare wrap-mutable-value) + +(defn- wrap-mutable-object + [^js js-obj commit!] + (doseq [prop (js/Object.keys js-obj)] + (obj/set! js-obj prop (wrap-mutable-value (obj/get js-obj prop) commit!))) + (js/Proxy. js-obj + #js {:set (fn [target prop value] + (obj/set! target prop (wrap-mutable-value value commit!)) + (normalize-exclusive-color-props! target prop) + (commit!) + true) + :deleteProperty (fn [target prop] + (js-delete target prop) + (commit!) + true)})) + +(defn- wrap-mutable-array + [^js js-arr commit!] + (doseq [index (range (.-length js-arr))] + (obj/set! js-arr index (wrap-mutable-value (obj/get js-arr index) commit!))) + (js/Proxy. js-arr + #js {:set (fn [target prop value] + (if (or (numeric-index? prop) (= prop "length")) + (do + (if (numeric-index? prop) + (obj/set! target prop (wrap-mutable-value value commit!)) + (obj/set! target prop value)) + (commit!) + true) + false)) + :deleteProperty (fn [target prop] + (if (numeric-index? prop) + (do + (js-delete target prop) + true) + false))})) + +(defn- wrap-mutable-value + [value commit!] + (cond + (obj/array? value) + (wrap-mutable-array value commit!) + + (obj/plain-object? value) + (wrap-mutable-object value commit!) + + :else + value)) + +(defn wrap-mutable-element + [^js js-obj commit!] + (when (some? js-obj) + (wrap-mutable-value js-obj commit!))) + +(defn mutable-proxy-array + [coll format-fn commit-fn] + (let [raw-arr (format-array format-fn coll) + commit! (fn [] (commit-fn raw-arr))] + (wrap-mutable-array raw-arr commit!))) + (defn format-mixed [value] (if (= value :multiple) @@ -198,16 +289,17 @@ :fillImage (format-image fill-image)}))) (defn format-fills - [fills] - (cond - (= fills :multiple) - "mixed" + ([fills] (format-fills fills nil)) + ([fills commit-fn] + (cond + (= fills :multiple) "mixed" + (= fills "mixed") "mixed" - (= fills "mixed") - "mixed" + (and (some? fills) (fn? commit-fn)) + (mutable-proxy-array fills format-fill commit-fn) - (some? fills) - (format-array format-fill fills))) + :else + (format-array format-fill fills)))) ;; export interface Stroke { ;; strokeColor?: string; @@ -240,9 +332,11 @@ :strokeColorGradient (format-gradient stroke-color-gradient)}))) (defn format-strokes - [strokes] - (when (some? strokes) - (format-array format-stroke strokes))) + ([strokes] (format-strokes strokes nil)) + ([strokes commit-fn] + (if (and (some? strokes) (fn? commit-fn)) + (mutable-proxy-array strokes format-stroke commit-fn) + (format-array format-stroke strokes)))) ;; export interface Blur { ;; id?: string; diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index 63037e405c..b6b610920e 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -173,6 +173,38 @@ :hidden false} blur)) +(defn commit-fills! + [plugin-id ^js self value] + (let [shape (u/proxy->shape self) + id (:id shape) + value (parser/parse-fills value)] + (cond + (not (sm/validate [:vector types.fills/schema:fill] value)) + (u/not-valid plugin-id :fills value) + + (cfh/text-shape? shape) + (st/emit! (dwt/update-attrs id {:fills value})) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :fills "Plugin doesn't have 'content:write' permission") + + :else + (st/emit! (dwsh/update-shapes [id] #(assoc % :fills value)))))) + +(defn commit-strokes! + [plugin-id ^js self value] + (let [id (obj/get self "$id") + value (parser/parse-strokes value)] + (cond + (not (sm/validate [:vector cts/schema:stroke] value)) + (u/not-valid plugin-id :strokes value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :strokes "Plugin doesn't have 'content:write' permission") + + :else + (st/emit! (dwsh/update-shapes [id] #(assoc % :strokes value)))))) + (defn shape-proxy? [p] (obj/type-of? p "ShapeProxy")) @@ -726,43 +758,19 @@ ;; Strokes and fills :fills {:this true - :get #(if (cfh/text-shape? data) - (-> % u/proxy->shape text-props :fills format/format-fills) - (-> % u/proxy->shape :fills format/format-fills)) - :set - (fn [self value] - (let [shape (u/proxy->shape self) - id (:id shape) - value (parser/parse-fills value)] - (cond - (not (sm/validate [:vector types.fills/schema:fill] value)) - (u/not-valid plugin-id :fills value) - - (cfh/text-shape? shape) - (st/emit! (dwt/update-attrs id {:fills value})) - - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :fills "Plugin doesn't have 'content:write' permission") - - :else - (st/emit! (dwsh/update-shapes [id] #(assoc % :fills value))))))} + :get (fn [^js self] + (let [fills (if (cfh/text-shape? data) + (-> self u/proxy->shape text-props :fills) + (-> self u/proxy->shape :fills))] + (format/format-fills fills #(commit-fills! plugin-id self %)))) + :set (fn [self value] (commit-fills! plugin-id self value))} :strokes {:this true - :get #(-> % u/proxy->shape :strokes format/format-strokes) - :set - (fn [self value] - (let [id (obj/get self "$id") - value (parser/parse-strokes value)] - (cond - (not (sm/validate [:vector cts/schema:stroke] value)) - (u/not-valid plugin-id :strokes value) - - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :strokes "Plugin doesn't have 'content:write' permission") - - :else - (st/emit! (dwsh/update-shapes [id] #(assoc % :strokes value))))))} + :get (fn [^js self] + (format/format-strokes (-> self u/proxy->shape :strokes) + #(commit-strokes! plugin-id self %))) + :set (fn [self value] (commit-strokes! plugin-id self value))} :layoutChild {:this true diff --git a/frontend/test/frontend_tests/plugins/context_shapes_test.cljs b/frontend/test/frontend_tests/plugins/context_shapes_test.cljs index 39b44e861f..80a3051ba0 100644 --- a/frontend/test/frontend_tests/plugins/context_shapes_test.cljs +++ b/frontend/test/frontend_tests/plugins/context_shapes_test.cljs @@ -11,6 +11,7 @@ [app.common.uuid :as uuid] [app.main.store :as st] [app.plugins.api :as api] + [app.util.object :as obj] [cljs.test :as t :include-macros true] [frontend-tests.helpers.state :as ths] [frontend-tests.helpers.wasm :as thw])) @@ -30,7 +31,28 @@ ^js shape (.createRectangle context) get-shape-path - #(vector :files (aget file "$id") :data :pages-index (aget page "$id") :objects (aget shape "$id") %)] + #(vector :files (aget file "$id") :data :pages-index (aget page "$id") :objects (aget shape "$id") %) + + gradient + (fn [] + #js {:type "linear" + :startX 0.5 + :startY 0 + :endX 0.5 + :endY 1 + :width 1 + :stops #js [#js {:color "#b400ff" :opacity 1 :offset 0} + #js {:color "#0c3fd5" :opacity 1 :offset 1}]}) + + parsed-gradient + {:type :linear + :start-x 0.5 + :start-y 0 + :end-x 0.5 + :end-y 1 + :width 1 + :stops [{:color "#b400ff" :opacity 1 :offset 0} + {:color "#0c3fd5" :opacity 1 :offset 1}]}] (t/testing "Basic shape properites" (t/testing " - name" @@ -218,7 +240,75 @@ (t/is (= (get-in @store (get-shape-path :strokes)) [{:stroke-color "#fabada" :stroke-opacity 1 :stroke-width 5}])) (t/is (= (-> (. ^js shape -strokes) (aget 0) (aget "strokeColor")) "#fabada")) (t/is (= (-> (. ^js shape -strokes) (aget 0) (aget "strokeOpacity")) 1)) - (t/is (= (-> (. ^js shape -strokes) (aget 0) (aget "strokeWidth")) 5)))) + (t/is (= (-> (. ^js shape -strokes) (aget 0) (aget "strokeWidth")) 5))) + + (t/testing " - fills per-element property mutation (bug #8357)" + (set! (.-fills shape) #js [#js {:fillColor "#fabada" :fillOpacity 1}]) + (obj/set! (aget (.-fills shape) 0) "fillColor" "#ff0000") + (t/is (= (get-in @store (get-shape-path :fills)) [{:fill-color "#ff0000" :fill-opacity 1}])) + (t/is (= (-> (. shape -fills) (aget 0) (aget "fillColor")) "#ff0000"))) + + (t/testing " - fills element replacement (bug #8357)" + (set! (.-fills shape) #js [#js {:fillColor "#fabada" :fillOpacity 1}]) + (aset (.-fills shape) 0 #js {:fillColor "#00ff00" :fillOpacity 0.5}) + (t/is (= (get-in @store (get-shape-path :fills)) [{:fill-color "#00ff00" :fill-opacity 0.5}]))) + + (t/testing " - fills push/pop (bug #8357)" + (set! (.-fills shape) #js [#js {:fillColor "#fabada" :fillOpacity 1}]) + (.push (.-fills shape) #js {:fillColor "#00ff00" :fillOpacity 1}) + (t/is (= (get-in @store (get-shape-path :fills)) + [{:fill-color "#fabada" :fill-opacity 1} + {:fill-color "#00ff00" :fill-opacity 1}])) + (.pop (.-fills shape)) + (t/is (= (get-in @store (get-shape-path :fills)) [{:fill-color "#fabada" :fill-opacity 1}]))) + + (t/testing " - fills gradient assignment replaces solid color (bug #8357)" + (set! (.-fills shape) #js [#js {:fillColor "#fabada" :fillOpacity 1}]) + (obj/set! (aget (.-fills shape) 0) "fillColorGradient" (gradient)) + (t/is (= (get-in @store (get-shape-path :fills)) + [{:fill-opacity 1 :fill-color-gradient parsed-gradient}])) + (t/is (nil? (-> (. shape -fills) (aget 0) (aget "fillColor"))))) + + (t/testing " - fills nested gradient mutation (bug #8357)" + (set! (.-fills shape) #js [#js {:fillColorGradient (gradient) :fillOpacity 1}]) + (let [fill-gradient (-> (. shape -fills) (aget 0) (aget "fillColorGradient")) + stop (-> fill-gradient (aget "stops") (aget 0))] + (obj/set! fill-gradient "startX" 0.25) + (obj/set! stop "color" "#ffffff") + (t/is (= (get-in @store (get-shape-path :fills)) + [{:fill-opacity 1 + :fill-color-gradient (-> parsed-gradient + (assoc :start-x 0.25) + (assoc-in [:stops 0 :color] "#ffffff"))}])))) + + (t/testing " - strokes per-element property mutation (bug #8357)" + (set! (.-strokes shape) #js [#js {:strokeColor "#fabada" :strokeOpacity 1 :strokeWidth 5}]) + (obj/set! (aget (.-strokes shape) 0) "strokeColor" "#0000ff") + (t/is (= (get-in @store (get-shape-path :strokes)) [{:stroke-color "#0000ff" :stroke-opacity 1 :stroke-width 5}]))) + + (t/testing " - strokes element replacement (bug #8357)" + (set! (.-strokes shape) #js [#js {:strokeColor "#fabada" :strokeOpacity 1 :strokeWidth 5}]) + (aset (.-strokes shape) 0 #js {:strokeColor "#00ff00" :strokeOpacity 0.5 :strokeWidth 2}) + (t/is (= (get-in @store (get-shape-path :strokes)) [{:stroke-color "#00ff00" :stroke-opacity 0.5 :stroke-width 2}]))) + + (t/testing " - strokes gradient assignment replaces solid color (bug #8357)" + (set! (.-strokes shape) #js [#js {:strokeColor "#fabada" :strokeOpacity 1 :strokeWidth 5}]) + (obj/set! (aget (.-strokes shape) 0) "strokeColorGradient" (gradient)) + (t/is (= (get-in @store (get-shape-path :strokes)) + [{:stroke-opacity 1 :stroke-width 5 :stroke-color-gradient parsed-gradient}]))) + + (t/testing " - strokes nested gradient mutation (bug #8357)" + (set! (.-strokes shape) #js [#js {:strokeColorGradient (gradient) :strokeOpacity 1 :strokeWidth 5}]) + (let [stroke-gradient (-> (. shape -strokes) (aget 0) (aget "strokeColorGradient")) + stop (-> stroke-gradient (aget "stops") (aget 1))] + (obj/set! stroke-gradient "endY" 0.75) + (obj/set! stop "opacity" 0.25) + (t/is (= (get-in @store (get-shape-path :strokes)) + [{:stroke-opacity 1 + :stroke-width 5 + :stroke-color-gradient (-> parsed-gradient + (assoc :end-y 0.75) + (assoc-in [:stops 1 :opacity] 0.25))}]))))) (t/testing "Relative properties" (let [board (.createBoard context)] From 3136b39404d80706cb21c2519e3f5f193a015991 Mon Sep 17 00:00:00 2001 From: Madalena Melo Date: Thu, 7 May 2026 13:29:25 +0200 Subject: [PATCH 21/22] :sparkles: Update issue templates to include the issue type (#9345) * :sparkles: Update issue templates to include the issue type Added the type "bug" to the "New render bug report" and the "Bug report" templates and the type "feature" to the "Feature request template". This will allow us to use the issue Type instead of labels to identify what kind of issue is being created. * :sparkles: Update bug_report.md to request screen recordings Update the Screenshots section to also request screen recordings Signed-off-by: Madalena Melo --------- Signed-off-by: Madalena Melo --- .github/ISSUE_TEMPLATE/bug_report.md | 39 +++++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 21 ++++++++++ .../ISSUE_TEMPLATE/new-render-bug-report.md | 1 + 3 files changed, 61 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000000..b1cf4d0ff4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,39 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' +type: Bug + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screen recordings and screenshots** +If possible, add screen recordings or screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000000..e255ea9831 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,21 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' +type: Feature + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/new-render-bug-report.md b/.github/ISSUE_TEMPLATE/new-render-bug-report.md index b93b98b444..0861b85a71 100644 --- a/.github/ISSUE_TEMPLATE/new-render-bug-report.md +++ b/.github/ISSUE_TEMPLATE/new-render-bug-report.md @@ -4,6 +4,7 @@ about: Create a report about the bugs you have found in the new render title: '' labels: new render assignees: claragvinola +type: Bug --- From ddad228849070d01121fe9bf9a687fde86bd4277 Mon Sep 17 00:00:00 2001 From: Xaviju Date: Thu, 7 May 2026 14:01:43 +0200 Subject: [PATCH 22/22] :books: Update CONTRIBUTING (#9418) --- CONTRIBUTING.md | 75 +++++++++++++++++++++++++++++-------------------- 1 file changed, 44 insertions(+), 31 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 532413194d..a12d23bdb9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -159,7 +159,7 @@ To save time on both sides, please avoid submitting PRs that: ### Good first issues -We use the `easy fix` label to mark issues appropriate for newcomers. +We use the `good first issue` label to mark issues appropriate for newcomers. ## Commit Guidelines @@ -175,26 +175,26 @@ Commit messages must follow this format: ### Commit types -| Emoji | Description | -|-------|-------------| -| :bug: | Bug fix | -| :sparkles: | Improvement or enhancement | -| :tada: | New feature | -| :recycle: | Refactor | -| :lipstick: | Cosmetic changes | -| :ambulance: | Critical bug fix | -| :books: | Documentation | -| :construction: | Work in progress | -| :boom: | Breaking change | -| :wrench: | Configuration update | -| :zap: | Performance improvement | -| :whale: | Docker-related change | -| :paperclip: | Other non-relevant changes | -| :arrow_up: | Dependency update | -| :arrow_down: | Dependency downgrade | -| :fire: | Removal of code or files | +| Emoji | Description | +| ---------------------- | -------------------------- | +| :bug: | Bug fix | +| :sparkles: | Improvement or enhancement | +| :tada: | New feature | +| :recycle: | Refactor | +| :lipstick: | Cosmetic changes | +| :ambulance: | Critical bug fix | +| :books: | Documentation | +| :construction: | Work in progress | +| :boom: | Breaking change | +| :wrench: | Configuration update | +| :zap: | Performance improvement | +| :whale: | Docker-related change | +| :paperclip: | Other non-relevant changes | +| :arrow_up: | Dependency update | +| :arrow_down: | Dependency downgrade | +| :fire: | Removal of code or files | | :globe_with_meridians: | Add or update translations | -| :rocket: | Epic or highlight | +| :rocket: | Epic or highlight | ### Rules @@ -231,6 +231,19 @@ We use [cljfmt](https://github.com/weavejester/cljfmt) for formatting and ./scripts/lint ``` +For frontend SCSS, we use `stylelint` for linting and +`Prettier` for formatting: + +```bash +cd frontend + +# Lint SCSS +pnpm run lint:scss (does not modify files) + +# Fix SCSS formatting (modifies files in place) +pnpm run fmt:scss +``` + Ideally, run these as git pre-commit hooks. [Husky](https://typicode.github.io/husky/#/) is a convenient option for setting this up. @@ -260,23 +273,23 @@ By submitting code you agree to and can certify the following: > By making a contribution to this project, I certify that: > > (a) The contribution was created in whole or in part by me and I have the -> right to submit it under the open source license indicated in the file; or +> right to submit it under the open source license indicated in the file; or > > (b) The contribution is based upon previous work that, to the best of my -> knowledge, is covered under an appropriate open source license and I have -> the right under that license to submit that work with modifications, -> whether created in whole or in part by me, under the same open source -> license (unless I am permitted to submit under a different license), as -> indicated in the file; or +> knowledge, is covered under an appropriate open source license and I have +> the right under that license to submit that work with modifications, +> whether created in whole or in part by me, under the same open source +> license (unless I am permitted to submit under a different license), as +> indicated in the file; or > > (c) The contribution was provided directly to me by some other person who -> certified (a), (b) or (c) and I have not modified it. +> certified (a), (b) or (c) and I have not modified it. > > (d) I understand and agree that this project and the contribution are public -> and that a record of the contribution (including all personal information -> I submit with it, including my sign-off) is maintained indefinitely and -> may be redistributed consistent with this project or the open source -> license(s) involved. +> and that a record of the contribution (including all personal information +> I submit with it, including my sign-off) is maintained indefinitely and +> may be redistributed consistent with this project or the open source +> license(s) involved. ### Signed-off-by