diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index 0fc65cd329..2349a25ca0 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -1230,6 +1230,7 @@ {:cmd :export-shapes :profile-id (:profile-id @st/state) :wait true + :is-wasm false :exports [{:file-id file-id :page-id page-id :object-id id diff --git a/frontend/test/frontend_tests/helpers/wasm.cljs b/frontend/test/frontend_tests/helpers/wasm.cljs new file mode 100644 index 0000000000..f64d7b7d59 --- /dev/null +++ b/frontend/test/frontend_tests/helpers/wasm.cljs @@ -0,0 +1,192 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns frontend-tests.helpers.wasm + "Test helpers for mocking WASM API boundary functions. + + In the Node.js test environment the WASM binary is not available, + but the `render-wasm/v1` feature flag is enabled by default, so + every geometry-modifying event takes the WASM code path. + This namespace provides lightweight mock implementations that let + the Clojure-side logic execute normally while stubbing out every + call that would touch the WASM heap. + + Each mock tracks how many times it was called via `call-counts`. + Use `(call-count :propagate-modifiers)` in test assertions to + verify the WASM code path was exercised." + (:require + [app.common.data :as d] + [app.render-wasm.api :as wasm.api] + [app.render-wasm.api.fonts :as wasm.fonts])) + +;; --- Call tracking --------------------------------------------------- + +(def ^:private call-counts + "Atom holding a map of mock-name → number of calls since last reset." + (atom {})) + +(defn- track! + "Increment the call count for `mock-name`." + [mock-name] + (swap! call-counts update mock-name (fnil inc 0))) + +(defn call-count + "Return how many times mock `mock-name` was called since setup. + `mock-name` is a keyword, e.g. `:propagate-modifiers`." + [mock-name] + (get @call-counts mock-name 0)) + +(defn reset-call-counts! + "Reset all call counts to zero." + [] + (reset! call-counts {})) + +;; --- Mock implementations -------------------------------------------- + +(defn- mock-propagate-modifiers + "Passthrough mock for `wasm.api/propagate-modifiers`. + + Receives `entries` — a vector of `[uuid {:transform matrix, :kind kw}]` + pairs produced by `parse-geometry-modifiers` — and returns a vector + of `[uuid matrix]` pairs that `apply-wasm-modifiers` converts to a + transforms map via `(into {} result)`. + + This effectively tells the caller \"apply exactly the transform that + was requested\", which is what the real WASM engine does for simple + moves / resizes without constraints." + [entries _pixel-precision] + (track! :propagate-modifiers) + (when (d/not-empty? entries) + (into [] + (map (fn [[id data]] + (d/vec2 id (:transform data)))) + entries))) + +(defn- mock-clean-modifiers + [] + (track! :clean-modifiers) + nil) + +(defn- mock-set-structure-modifiers + [_entries] + (track! :set-structure-modifiers) + nil) + +(defn- mock-set-shape-grow-type + [_grow-type] + (track! :set-shape-grow-type) + nil) + +(defn- mock-set-shape-text-content + [_shape-id _content] + (track! :set-shape-text-content) + nil) + +(defn- mock-set-shape-text-images + ([_shape-id _content] + (track! :set-shape-text-images) + nil) + ([_shape-id _content _thumbnail?] + (track! :set-shape-text-images) + nil)) + +(defn- mock-get-text-dimensions + ([] + (track! :get-text-dimensions) + {:x 0 :y 0 :width 100 :height 20 :max-width 100}) + ([_id] + (track! :get-text-dimensions) + {:x 0 :y 0 :width 100 :height 20 :max-width 100})) + +(defn- mock-font-stored? + [_font-data _emoji?] + (track! :font-stored?) + true) + +(defn- mock-make-font-data + [font] + (track! :make-font-data) + {:wasm-id 0 + :weight (or (:font-weight font) "400") + :style (or (:font-style font) "normal") + :emoji? false}) + +(defn- mock-get-content-fonts + [_content] + (track! :get-content-fonts) + []) + +;; --- Persistent mock installation via `set!` -------------------------- +;; +;; These use `set!` to directly mutate the module-level JS vars, making +;; the mocks persist across async boundaries. They are intended to be +;; used with `t/use-fixtures :each` which correctly sequences `:after` +;; to run only after the async test's `done` callback fires. + +(def ^:private originals + "Stores the original WASM function values so they can be restored." + (atom {})) + +(defn setup-wasm-mocks! + "Install WASM mocks via `set!` that persist across async boundaries. + Also resets call counts. Call `teardown-wasm-mocks!` to restore." + [] + ;; Reset call tracking + (reset-call-counts!) + ;; Save originals + (reset! originals + {:clean-modifiers wasm.api/clean-modifiers + :set-structure-modifiers wasm.api/set-structure-modifiers + :propagate-modifiers wasm.api/propagate-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 + :get-text-dimensions wasm.api/get-text-dimensions + :font-stored? wasm.fonts/font-stored? + :make-font-data wasm.fonts/make-font-data + :get-content-fonts wasm.fonts/get-content-fonts}) + ;; Install mocks + (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-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) + (set! wasm.api/get-text-dimensions mock-get-text-dimensions) + (set! wasm.fonts/font-stored? mock-font-stored?) + (set! wasm.fonts/make-font-data mock-make-font-data) + (set! wasm.fonts/get-content-fonts mock-get-content-fonts)) + +(defn teardown-wasm-mocks! + "Restore the original WASM functions saved by `setup-wasm-mocks!`." + [] + (let [orig @originals] + (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-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)) + (set! wasm.api/get-text-dimensions (:get-text-dimensions orig)) + (set! wasm.fonts/font-stored? (:font-stored? orig)) + (set! wasm.fonts/make-font-data (:make-font-data orig)) + (set! wasm.fonts/get-content-fonts (:get-content-fonts orig))) + (reset! originals {})) + +(defn with-wasm-mocks* + "Calls `(thunk)` with all WASM API boundary functions replaced by + safe mocks, restoring the originals when the thunk returns. + + NOTE: Teardown happens synchronously when `thunk` returns. For + async tests (e.g. those using `tohs/run-store-async`), use + `setup-wasm-mocks!` / `teardown-wasm-mocks!` via + `t/use-fixtures :each` instead." + [thunk] + (setup-wasm-mocks!) + (try + (thunk) + (finally + (teardown-wasm-mocks!)))) diff --git a/frontend/test/frontend_tests/logic/components_and_tokens.cljs b/frontend/test/frontend_tests/logic/components_and_tokens.cljs index 9e830c9a76..2ec37b8db1 100644 --- a/frontend/test/frontend_tests/logic/components_and_tokens.cljs +++ b/frontend/test/frontend_tests/logic/components_and_tokens.cljs @@ -23,11 +23,15 @@ [cljs.test :as t :include-macros true] [frontend-tests.helpers.pages :as thp] [frontend-tests.helpers.state :as ths] + [frontend-tests.helpers.wasm :as thw] [frontend-tests.tokens.helpers.state :as tohs] [frontend-tests.tokens.helpers.tokens :as toht])) (t/use-fixtures :each - {:before thp/reset-idmap!}) + {:before (fn [] + (thp/reset-idmap!) + (thw/setup-wasm-mocks!)) + :after thw/teardown-wasm-mocks!}) (defn- setup-base-file [] @@ -426,7 +430,10 @@ (t/is (mth/close? (get c-frame1' :width) 200)) (t/is (mth/close? (get c-frame1' :height) 200)) - (t/is (empty? (:touched c-frame1'))))))))] + (t/is (empty? (:touched c-frame1'))) + + (t/testing "WASM mocks were exercised" + (t/is (pos? (thw/call-count :propagate-modifiers)))))))))] (tohs/run-store-async store step2 events identity)))) diff --git a/frontend/test/frontend_tests/logic/frame_guides_test.cljs b/frontend/test/frontend_tests/logic/frame_guides_test.cljs index 996d84cf4a..e20bc99e26 100644 --- a/frontend/test/frontend_tests/logic/frame_guides_test.cljs +++ b/frontend/test/frontend_tests/logic/frame_guides_test.cljs @@ -13,7 +13,8 @@ [app.main.data.workspace.guides :as-alias dwg] [cljs.test :as t :include-macros true] [frontend-tests.helpers.pages :as thp] - [frontend-tests.helpers.state :as ths])) + [frontend-tests.helpers.state :as ths] + [frontend-tests.helpers.wasm :as thw])) (t/use-fixtures :each {:before thp/reset-idmap!}) @@ -22,35 +23,43 @@ (t/deftest test-remove-swap-slot-copy-paste-blue1-to-root (t/async done - (let [;; ==== Setup - file (-> (cthf/sample-file :file1) - (ctho/add-frame :frame1)) - store (ths/setup-store file) - frame1 (cths/get-shape file :frame1) + (thw/with-wasm-mocks* + (fn [] + (let [;; ==== Setup + file (-> (cthf/sample-file :file1) + (ctho/add-frame :frame1)) + store (ths/setup-store file) + frame1 (cths/get-shape file :frame1) - guide {:axis :x - :frame-id (:id frame1) - :id (uuid/next) - :position 0} + guide {:axis :x + :frame-id (:id frame1) + :id (uuid/next) + :position 0} - ;; ==== Action - events - [(dw/update-guides guide) - (dw/update-position (:id frame1) {:x 100})]] + ;; ==== Action + events + [(dw/update-guides guide) + (dw/update-position (:id frame1) {:x 100})]] - (ths/run-store - store done events - (fn [new-state] - (let [;; ==== Get - file' (ths/get-file-from-state new-state) - page' (cthf/current-page file') + (ths/run-store + store done events + (fn [new-state] + (let [;; ==== Get + file' (ths/get-file-from-state new-state) + page' (cthf/current-page file') + + guide' (-> page' + :guides + (vals) + (first))] + ;; ==== Check + ;; 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))))))))))) - guide' (-> page' - :guides - (vals) - (first))] - ;; ==== Check - ;; guide has moved - (t/is (= (:position guide') 100)))))))) diff --git a/frontend/test/frontend_tests/plugins/context_shapes_test.cljs b/frontend/test/frontend_tests/plugins/context_shapes_test.cljs index 94fe2756d8..39b44e861f 100644 --- a/frontend/test/frontend_tests/plugins/context_shapes_test.cljs +++ b/frontend/test/frontend_tests/plugins/context_shapes_test.cljs @@ -12,242 +12,250 @@ [app.main.store :as st] [app.plugins.api :as api] [cljs.test :as t :include-macros true] - [frontend-tests.helpers.state :as ths])) + [frontend-tests.helpers.state :as ths] + [frontend-tests.helpers.wasm :as thw])) (t/deftest test-common-shape-properties - (let [;; ==== Setup - store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)) + (thw/with-wasm-mocks* + (fn [] + (let [;; ==== Setup + store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)) - ^js context (api/create-context "00000000-0000-0000-0000-000000000000") + ^js context (api/create-context "00000000-0000-0000-0000-000000000000") - _ (set! st/state store) + _ (set! st/state store) - ^js file (. context -currentFile) - ^js page (. context -currentPage) - ^js shape (.createRectangle context) + ^js file (. context -currentFile) + ^js page (. context -currentPage) + ^js shape (.createRectangle context) - get-shape-path - #(vector :files (aget file "$id") :data :pages-index (aget page "$id") :objects (aget shape "$id") %)] + get-shape-path + #(vector :files (aget file "$id") :data :pages-index (aget page "$id") :objects (aget shape "$id") %)] - (t/testing "Basic shape properites" - (t/testing " - name" - (set! (.-name shape) "TEST") - (t/is (= (.-name shape) "TEST")) - (t/is (= (get-in @store (get-shape-path :name)) "TEST"))) + (t/testing "Basic shape properites" + (t/testing " - name" + (set! (.-name shape) "TEST") + (t/is (= (.-name shape) "TEST")) + (t/is (= (get-in @store (get-shape-path :name)) "TEST"))) - (t/testing " - x" - (set! (.-x shape) 10) - (t/is (= (.-x shape) 10)) - (t/is (= (get-in @store (get-shape-path :x)) 10)) + (t/testing " - x" + (set! (.-x shape) 10) + (t/is (= (.-x shape) 10)) + (t/is (= (get-in @store (get-shape-path :x)) 10)) - (set! (.-x shape) "fail") - (t/is (= (.-x shape) 10)) - (t/is (= (get-in @store (get-shape-path :x)) 10))) + (set! (.-x shape) "fail") + (t/is (= (.-x shape) 10)) + (t/is (= (get-in @store (get-shape-path :x)) 10))) - (t/testing " - y" - (set! (.-y shape) 50) - (t/is (= (.-y shape) 50)) - (t/is (= (get-in @store (get-shape-path :y)) 50)) + (t/testing " - y" + (set! (.-y shape) 50) + (t/is (= (.-y shape) 50)) + (t/is (= (get-in @store (get-shape-path :y)) 50)) - (set! (.-y shape) "fail") - (t/is (= (.-y shape) 50)) - (t/is (= (get-in @store (get-shape-path :y)) 50))) + (set! (.-y shape) "fail") + (t/is (= (.-y shape) 50)) + (t/is (= (get-in @store (get-shape-path :y)) 50))) - (t/testing " - resize" - (.resize shape 250 300) - (t/is (= (.-width shape) 250)) - (t/is (= (.-height shape) 300)) - (t/is (= (get-in @store (get-shape-path :width)) 250)) - (t/is (= (get-in @store (get-shape-path :height)) 300)) + (t/testing " - resize" + (.resize shape 250 300) + (t/is (= (.-width shape) 250)) + (t/is (= (.-height shape) 300)) + (t/is (= (get-in @store (get-shape-path :width)) 250)) + (t/is (= (get-in @store (get-shape-path :height)) 300)) - (.resize shape 0 0) - (t/is (= (.-width shape) 250)) - (t/is (= (.-height shape) 300)) - (t/is (= (get-in @store (get-shape-path :width)) 250)) - (t/is (= (get-in @store (get-shape-path :height)) 300))) + (.resize shape 0 0) + (t/is (= (.-width shape) 250)) + (t/is (= (.-height shape) 300)) + (t/is (= (get-in @store (get-shape-path :width)) 250)) + (t/is (= (get-in @store (get-shape-path :height)) 300))) - (t/testing " - blocked" - (set! (.-blocked shape) true) - (t/is (= (.-blocked shape) true)) - (t/is (= (get-in @store (get-shape-path :blocked)) true)) + (t/testing " - blocked" + (set! (.-blocked shape) true) + (t/is (= (.-blocked shape) true)) + (t/is (= (get-in @store (get-shape-path :blocked)) true)) - (set! (.-blocked shape) false) - (t/is (= (.-blocked shape) false)) - (t/is (= (get-in @store (get-shape-path :blocked)) false))) + (set! (.-blocked shape) false) + (t/is (= (.-blocked shape) false)) + (t/is (= (get-in @store (get-shape-path :blocked)) false))) - (t/testing " - hidden" - (set! (.-hidden shape) true) - (t/is (= (.-hidden shape) true)) - (t/is (= (get-in @store (get-shape-path :hidden)) true)) + (t/testing " - hidden" + (set! (.-hidden shape) true) + (t/is (= (.-hidden shape) true)) + (t/is (= (get-in @store (get-shape-path :hidden)) true)) - (set! (.-hidden shape) false) - (t/is (= (.-hidden shape) false)) - (t/is (= (get-in @store (get-shape-path :hidden)) false))) + (set! (.-hidden shape) false) + (t/is (= (.-hidden shape) false)) + (t/is (= (get-in @store (get-shape-path :hidden)) false))) - (t/testing " - proportionLock" - (set! (.-proportionLock shape) true) - (t/is (= (.-proportionLock shape) true)) - (t/is (= (get-in @store (get-shape-path :proportion-lock)) true))) + (t/testing " - proportionLock" + (set! (.-proportionLock shape) true) + (t/is (= (.-proportionLock shape) true)) + (t/is (= (get-in @store (get-shape-path :proportion-lock)) true))) - (t/testing " - constraintsHorizontal" - (set! (.-constraintsHorizontal shape) "fail") - (t/is (not= (.-constraintsHorizontal shape) "fail")) - (t/is (not= (get-in @store (get-shape-path :constraints-h)) "fail")) + (t/testing " - constraintsHorizontal" + (set! (.-constraintsHorizontal shape) "fail") + (t/is (not= (.-constraintsHorizontal shape) "fail")) + (t/is (not= (get-in @store (get-shape-path :constraints-h)) "fail")) - (set! (.-constraintsHorizontal shape) "right") - (t/is (= (.-constraintsHorizontal shape) "right")) - (t/is (= (get-in @store (get-shape-path :constraints-h)) :right))) + (set! (.-constraintsHorizontal shape) "right") + (t/is (= (.-constraintsHorizontal shape) "right")) + (t/is (= (get-in @store (get-shape-path :constraints-h)) :right))) - (t/testing " - constraintsVertical" - (set! (.-constraintsVertical shape) "fail") - (t/is (not= (.-constraintsVertical shape) "fail")) - (t/is (not= (get-in @store (get-shape-path :constraints-v)) "fail")) + (t/testing " - constraintsVertical" + (set! (.-constraintsVertical shape) "fail") + (t/is (not= (.-constraintsVertical shape) "fail")) + (t/is (not= (get-in @store (get-shape-path :constraints-v)) "fail")) - (set! (.-constraintsVertical shape) "bottom") - (t/is (= (.-constraintsVertical shape) "bottom")) - (t/is (= (get-in @store (get-shape-path :constraints-v)) :bottom))) + (set! (.-constraintsVertical shape) "bottom") + (t/is (= (.-constraintsVertical shape) "bottom")) + (t/is (= (get-in @store (get-shape-path :constraints-v)) :bottom))) - (t/testing " - borderRadius" - (set! (.-borderRadius shape) 10) - (t/is (= (.-borderRadius shape) 10)) - (t/is (= (get-in @store (get-shape-path :r1)) 10)) + (t/testing " - borderRadius" + (set! (.-borderRadius shape) 10) + (t/is (= (.-borderRadius shape) 10)) + (t/is (= (get-in @store (get-shape-path :r1)) 10)) - (set! (.-borderRadiusTopLeft shape) 20) - (t/is (= (.-borderRadiusTopLeft shape) 20)) - (t/is (= (get-in @store (get-shape-path :r1)) 20)) - (t/is (= (get-in @store (get-shape-path :r2)) 10)) - (t/is (= (get-in @store (get-shape-path :r3)) 10)) - (t/is (= (get-in @store (get-shape-path :r4)) 10)) + (set! (.-borderRadiusTopLeft shape) 20) + (t/is (= (.-borderRadiusTopLeft shape) 20)) + (t/is (= (get-in @store (get-shape-path :r1)) 20)) + (t/is (= (get-in @store (get-shape-path :r2)) 10)) + (t/is (= (get-in @store (get-shape-path :r3)) 10)) + (t/is (= (get-in @store (get-shape-path :r4)) 10)) - (set! (.-borderRadiusTopRight shape) 30) - (set! (.-borderRadiusBottomRight shape) 40) - (set! (.-borderRadiusBottomLeft shape) 50) - (t/is (= (.-borderRadiusTopRight shape) 30)) - (t/is (= (.-borderRadiusBottomRight shape) 40)) - (t/is (= (.-borderRadiusBottomLeft shape) 50)) + (set! (.-borderRadiusTopRight shape) 30) + (set! (.-borderRadiusBottomRight shape) 40) + (set! (.-borderRadiusBottomLeft shape) 50) + (t/is (= (.-borderRadiusTopRight shape) 30)) + (t/is (= (.-borderRadiusBottomRight shape) 40)) + (t/is (= (.-borderRadiusBottomLeft shape) 50)) - (t/is (= (get-in @store (get-shape-path :r1)) 20)) - (t/is (= (get-in @store (get-shape-path :r2)) 30)) - (t/is (= (get-in @store (get-shape-path :r3)) 40)) - (t/is (= (get-in @store (get-shape-path :r4)) 50))) + (t/is (= (get-in @store (get-shape-path :r1)) 20)) + (t/is (= (get-in @store (get-shape-path :r2)) 30)) + (t/is (= (get-in @store (get-shape-path :r3)) 40)) + (t/is (= (get-in @store (get-shape-path :r4)) 50))) - (t/testing " - opacity" - (set! (.-opacity shape) 0.5) - (t/is (= (.-opacity shape) 0.5)) - (t/is (= (get-in @store (get-shape-path :opacity)) 0.5))) + (t/testing " - opacity" + (set! (.-opacity shape) 0.5) + (t/is (= (.-opacity shape) 0.5)) + (t/is (= (get-in @store (get-shape-path :opacity)) 0.5))) - (t/testing " - blendMode" - (set! (.-blendMode shape) "multiply") - (t/is (= (.-blendMode shape) "multiply")) - (t/is (= (get-in @store (get-shape-path :blend-mode)) :multiply)) + (t/testing " - blendMode" + (set! (.-blendMode shape) "multiply") + (t/is (= (.-blendMode shape) "multiply")) + (t/is (= (get-in @store (get-shape-path :blend-mode)) :multiply)) - (set! (.-blendMode shape) "fail") - (t/is (= (.-blendMode shape) "multiply")) - (t/is (= (get-in @store (get-shape-path :blend-mode)) :multiply))) + (set! (.-blendMode shape) "fail") + (t/is (= (.-blendMode shape) "multiply")) + (t/is (= (get-in @store (get-shape-path :blend-mode)) :multiply))) - (t/testing " - shadows" - (let [shadow #js {:style "drop-shadow" - :color #js {:color "#FABADA" :opacity 1}}] - (set! (.-shadows shape) #js [shadow]) - (let [shadow-id (uuid/uuid (aget (aget (aget shape "shadows") 0) "id"))] - (t/is (= (-> (. shape -shadows) (aget 0) (aget "style")) "drop-shadow")) - (t/is (= (get-in @store (get-shape-path :shadow)) [{:id shadow-id - :style :drop-shadow - :offset-x 4 - :offset-y 4 - :blur 4 - :spread 0 - :color {:color "#fabada" :opacity 1} - :hidden false}])))) - (let [shadow #js {:style "fail"}] - (set! (.-shadows shape) #js [shadow]) - (t/is (= (-> (. shape -shadows) (aget 0) (aget "style")) "drop-shadow")))) + (t/testing " - shadows" + (let [shadow #js {:style "drop-shadow" + :color #js {:color "#FABADA" :opacity 1}}] + (set! (.-shadows shape) #js [shadow]) + (let [shadow-id (uuid/uuid (aget (aget (aget shape "shadows") 0) "id"))] + (t/is (= (-> (. shape -shadows) (aget 0) (aget "style")) "drop-shadow")) + (t/is (= (get-in @store (get-shape-path :shadow)) [{:id shadow-id + :style :drop-shadow + :offset-x 4 + :offset-y 4 + :blur 4 + :spread 0 + :color {:color "#fabada" :opacity 1} + :hidden false}])))) + (let [shadow #js {:style "fail"}] + (set! (.-shadows shape) #js [shadow]) + (t/is (= (-> (. shape -shadows) (aget 0) (aget "style")) "drop-shadow")))) - (t/testing " - blur" - (set! (.-blur shape) #js {:value 10}) - (t/is (= (-> (. shape -blur) (aget "type")) "layer-blur")) - (t/is (= (-> (. shape -blur) (aget "value")) 10)) - (t/is (= (-> (. shape -blur) (aget "hidden")) false)) - (let [id (-> (. shape -blur) (aget "id") uuid/uuid)] - (t/is (= (get-in @store (get-shape-path :blur)) {:id id :type :layer-blur :value 10 :hidden false})))) + (t/testing " - blur" + (set! (.-blur shape) #js {:value 10}) + (t/is (= (-> (. shape -blur) (aget "type")) "layer-blur")) + (t/is (= (-> (. shape -blur) (aget "value")) 10)) + (t/is (= (-> (. shape -blur) (aget "hidden")) false)) + (let [id (-> (. shape -blur) (aget "id") uuid/uuid)] + (t/is (= (get-in @store (get-shape-path :blur)) {:id id :type :layer-blur :value 10 :hidden false})))) - (t/testing " - exports" - (set! (.-exports shape) #js [#js {:type "pdf" :scale 2 :suffix "test"}]) - (t/is (= (-> (. shape -exports) (aget 0) (aget "type")) "pdf")) - (t/is (= (-> (. shape -exports) (aget 0) (aget "scale")) 2)) - (t/is (= (-> (. shape -exports) (aget 0) (aget "suffix")) "test")) - (t/is (= (get-in @store (get-shape-path :exports)) [{:type :pdf :scale 2 :suffix "test" :skip-children false}])) + (t/testing " - exports" + (set! (.-exports shape) #js [#js {:type "pdf" :scale 2 :suffix "test"}]) + (t/is (= (-> (. shape -exports) (aget 0) (aget "type")) "pdf")) + (t/is (= (-> (. shape -exports) (aget 0) (aget "scale")) 2)) + (t/is (= (-> (. shape -exports) (aget 0) (aget "suffix")) "test")) + (t/is (= (get-in @store (get-shape-path :exports)) [{:type :pdf :scale 2 :suffix "test" :skip-children false}])) - (set! (.-exports shape) #js [#js {:type 10 :scale 2 :suffix "test"}]) - (t/is (= (get-in @store (get-shape-path :exports)) [{:type :pdf :scale 2 :suffix "test" :skip-children false}]))) + (set! (.-exports shape) #js [#js {:type 10 :scale 2 :suffix "test"}]) + (t/is (= (get-in @store (get-shape-path :exports)) [{:type :pdf :scale 2 :suffix "test" :skip-children false}]))) - (t/testing " - flipX" - (set! (.-flipX shape) true) - (t/is (= (.-flipX shape) true)) - (t/is (= (get-in @store (get-shape-path :flip-x)) true))) + (t/testing " - flipX" + (set! (.-flipX shape) true) + (t/is (= (.-flipX shape) true)) + (t/is (= (get-in @store (get-shape-path :flip-x)) true))) - (t/testing " - flipY" - (set! (.-flipY shape) true) - (t/is (= (.-flipY shape) true)) - (t/is (= (get-in @store (get-shape-path :flip-y)) true))) + (t/testing " - flipY" + (set! (.-flipY shape) true) + (t/is (= (.-flipY shape) true)) + (t/is (= (get-in @store (get-shape-path :flip-y)) true))) - (t/testing " - rotation" - (set! (.-rotation shape) 45) - (t/is (= (.-rotation shape) 45)) - (t/is (= (get-in @store (get-shape-path :rotation)) 45)) + (t/testing " - rotation" + (set! (.-rotation shape) 45) + (t/is (= (.-rotation shape) 45)) + (t/is (= (get-in @store (get-shape-path :rotation)) 45)) - (set! (.-rotation shape) 0) - (t/is (= (.-rotation shape) 0)) - (t/is (= (get-in @store (get-shape-path :rotation)) 0))) + (set! (.-rotation shape) 0) + (t/is (= (.-rotation shape) 0)) + (t/is (= (get-in @store (get-shape-path :rotation)) 0))) - (t/testing " - fills" - (set! (.-fills shape) #js [#js {:fillColor 100}]) - (t/is (= (get-in @store (get-shape-path :fills)) [{:fill-color "#B1B2B5" :fill-opacity 1}])) - (t/is (= (-> (. shape -fills) (aget 0) (aget "fillColor")) "#B1B2B5")) + (t/testing " - fills" + (set! (.-fills shape) #js [#js {:fillColor 100}]) + (t/is (= (get-in @store (get-shape-path :fills)) [{:fill-color "#B1B2B5" :fill-opacity 1}])) + (t/is (= (-> (. shape -fills) (aget 0) (aget "fillColor")) "#B1B2B5")) - (set! (.-fills shape) #js [#js {:fillColor "#fabada" :fillOpacity 1}]) - (t/is (= (get-in @store (get-shape-path :fills)) [{:fill-color "#fabada" :fill-opacity 1}])) - (t/is (= (-> (. shape -fills) (aget 0) (aget "fillColor")) "#fabada")) - (t/is (= (-> (. shape -fills) (aget 0) (aget "fillOpacity")) 1))) + (set! (.-fills shape) #js [#js {:fillColor "#fabada" :fillOpacity 1}]) + (t/is (= (get-in @store (get-shape-path :fills)) [{:fill-color "#fabada" :fill-opacity 1}])) + (t/is (= (-> (. shape -fills) (aget 0) (aget "fillColor")) "#fabada")) + (t/is (= (-> (. shape -fills) (aget 0) (aget "fillOpacity")) 1))) - (t/testing " - strokes" - (set! (.-strokes shape) #js [#js {:strokeColor "#fabada" :strokeOpacity 1 :strokeWidth 5}]) - (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/testing " - strokes" + (set! (.-strokes shape) #js [#js {:strokeColor "#fabada" :strokeOpacity 1 :strokeWidth 5}]) + (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/testing "Relative properties" - (let [board (.createBoard context)] - (set! (.-x board) 100) - (set! (.-y board) 200) - (t/is (= (.-x board) 100)) - (t/is (= (.-y board) 200)) - (.appendChild board shape) + (t/testing "Relative properties" + (let [board (.createBoard context)] + (set! (.-x board) 100) + (set! (.-y board) 200) + (t/is (= (.-x board) 100)) + (t/is (= (.-y board) 200)) + (.appendChild board shape) - (t/testing " - boardX" - (set! (.-boardX ^js shape) 10) - (t/is (m/close? (.-boardX ^js shape) 10)) - (t/is (m/close? (.-x shape) 110)) - (t/is (m/close? (get-in @store (get-shape-path :x)) 110))) + (t/testing " - boardX" + (set! (.-boardX ^js shape) 10) + (t/is (m/close? (.-boardX ^js shape) 10)) + (t/is (m/close? (.-x shape) 110)) + (t/is (m/close? (get-in @store (get-shape-path :x)) 110))) - (t/testing " - boardY" - (set! (.-boardY ^js shape) 20) - (t/is (m/close? (.-boardY ^js shape) 20)) - (t/is (m/close? (.-y shape) 220)) - (t/is (m/close? (get-in @store (get-shape-path :y)) 220))) + (t/testing " - boardY" + (set! (.-boardY ^js shape) 20) + (t/is (m/close? (.-boardY ^js shape) 20)) + (t/is (m/close? (.-y shape) 220)) + (t/is (m/close? (get-in @store (get-shape-path :y)) 220))) - (t/testing " - parentX" - (set! (.-parentX ^js shape) 30) - (t/is (m/close? (.-parentX ^js shape) 30)) - (t/is (m/close? (.-x shape) 130)) - (t/is (m/close? (get-in @store (get-shape-path :x)) 130))) + (t/testing " - parentX" + (set! (.-parentX ^js shape) 30) + (t/is (m/close? (.-parentX ^js shape) 30)) + (t/is (m/close? (.-x shape) 130)) + (t/is (m/close? (get-in @store (get-shape-path :x)) 130))) - (t/testing " - parentY" - (set! (.-parentY ^js shape) 40) - (t/is (m/close? (.-parentY ^js shape) 40)) - (t/is (m/close? (.-y shape) 240)) - (t/is (m/close? (get-in @store (get-shape-path :y)) 240))))) + (t/testing " - parentY" + (set! (.-parentY ^js shape) 40) + (t/is (m/close? (.-parentY ^js shape) 40)) + (t/is (m/close? (.-y shape) 240)) + (t/is (m/close? (get-in @store (get-shape-path :y)) 240))))) - (t/testing "Clone") - (t/testing "Remove"))) + (t/testing "Clone") + (t/testing "Remove") + + (t/testing "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)))))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index cc899c9ac8..fe5f5efdac 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -30,10 +30,6 @@ (.exit js/process 0) (.exit js/process 1))) -;; FIXME: workaround, wasn is temporarily disabled for unit tests -(set! app.main.features/global-enabled-features - (disj app.main.features/global-enabled-features "render-wasm/v1")) - (defn init [] (t/run-tests diff --git a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs index e468e420bb..0af65155bf 100644 --- a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs +++ b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs @@ -17,11 +17,15 @@ [cuerdas.core :as str] [frontend-tests.helpers.pages :as thp] [frontend-tests.helpers.state :as ths] + [frontend-tests.helpers.wasm :as thw] [frontend-tests.tokens.helpers.state :as tohs] [frontend-tests.tokens.helpers.tokens :as toht])) (t/use-fixtures :each - {:before thp/reset-idmap!}) + {:before (fn [] + (thp/reset-idmap!) + (thw/setup-wasm-mocks!)) + :after thw/teardown-wasm-mocks!}) (defn setup-file [] (cthf/sample-file :file-1 :page-label :page-1)) @@ -273,7 +277,9 @@ (t/is (= (:height (:applied-tokens rect-1')) (:name token-target')))) (t/testing "shapes width and height got updated" (t/is (= (:width rect-1') 100)) - (t/is (= (:height rect-1') 100)))))))))) + (t/is (= (:height rect-1') 100))) + (t/testing "WASM mocks were exercised" + (t/is (pos? (thw/call-count :propagate-modifiers))))))))))) (t/deftest test-apply-padding (t/testing "applies padding token to shapes with layout" @@ -346,7 +352,9 @@ (t/is (= (:height (:applied-tokens rect-1')) (:name token-target')))) (t/testing "shapes width and height got updated" (t/is (= (:width rect-1') 100)) - (t/is (= (:height rect-1') 100)))))))))) + (t/is (= (:height rect-1') 100))) + (t/testing "WASM mocks were exercised" + (t/is (pos? (thw/call-count :propagate-modifiers))))))))))) (t/deftest test-apply-opacity (t/testing "applies opacity token and updates the shapes opacity" @@ -431,7 +439,9 @@ rect-1' (cths/get-shape file' :rect-1)] (t/is (some? (:applied-tokens rect-1'))) (t/is (= (:rotation (:applied-tokens rect-1')) (:name token-target'))) - (t/is (= (:rotation rect-1') 120))))))))) + (t/is (= (:rotation rect-1') 120)) + (t/testing "WASM mocks were exercised" + (t/is (pos? (thw/call-count :propagate-modifiers))))))))))) (t/deftest test-apply-stroke-width (t/testing "applies stroke-width token and updates the shapes with stroke" @@ -540,7 +550,10 @@ (:styles))] (t/is (some? (:applied-tokens text-1'))) (t/is (= (:font-size (:applied-tokens text-1')) (:name token-target'))) - (t/is (= (:font-size style-text-blocks) "24"))))))))) + (t/is (= (:font-size style-text-blocks) "24")) + (t/testing "WASM text mocks were exercised" + (t/is (pos? (thw/call-count :set-shape-text-content))) + (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) (t/deftest test-apply-line-height (t/testing "applies line-height token and updates the text line-height" @@ -575,7 +588,10 @@ (:styles))] (t/is (some? (:applied-tokens text-1'))) (t/is (= (:line-height (:applied-tokens text-1')) (:name token-target'))) - (t/is (= (:line-height style-text-blocks) 1.5))))))))) + (t/is (= (:line-height style-text-blocks) 1.5)) + (t/testing "WASM text mocks were exercised" + (t/is (pos? (thw/call-count :set-shape-text-content))) + (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) (t/deftest test-apply-letter-spacing (t/testing "applies letter-spacing token and updates the text letter-spacing" @@ -610,7 +626,10 @@ (:styles))] (t/is (some? (:applied-tokens text-1'))) (t/is (= (:letter-spacing (:applied-tokens text-1')) (:name token-target'))) - (t/is (= (:letter-spacing style-text-blocks) "2"))))))))) + (t/is (= (:letter-spacing style-text-blocks) "2")) + (t/testing "WASM text mocks were exercised" + (t/is (pos? (thw/call-count :set-shape-text-content))) + (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) (t/deftest test-apply-font-family (t/testing "applies font-family token and updates the text font-family" @@ -645,7 +664,10 @@ (:styles))] (t/is (some? (:applied-tokens text-1'))) (t/is (= (:font-family (:applied-tokens text-1')) (:name token-target'))) - (t/is (= (:font-family style-text-blocks) (:font-id txt/default-text-attrs)))))))))) + (t/is (= (:font-family style-text-blocks) (:font-id txt/default-text-attrs))) + (t/testing "WASM text mocks were exercised" + (t/is (pos? (thw/call-count :set-shape-text-content))) + (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) (t/deftest test-apply-text-case (t/testing "applies text-case token and updates the text transform" @@ -750,7 +772,10 @@ (:styles))] (t/is (some? (:applied-tokens text-1'))) (t/is (= (:font-weight (:applied-tokens text-1')) (:name token-target'))) - (t/is (= (:font-weight style-text-blocks) "400"))))))))) + (t/is (= (:font-weight style-text-blocks) "400")) + (t/testing "WASM text mocks were exercised" + (t/is (pos? (thw/call-count :set-shape-text-content))) + (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) (t/deftest test-toggle-token-none (t/testing "should apply token to all selected items, where no item has the token applied" @@ -973,7 +998,10 @@ (t/is (= (:font-family style-text-blocks) "sourcesanspro")) (t/is (= (:letter-spacing style-text-blocks) "2")) (t/is (= (:text-transform style-text-blocks) "uppercase")) - (t/is (= (:text-decoration style-text-blocks) "underline"))))))))) + (t/is (= (:text-decoration style-text-blocks) "underline")) + (t/testing "WASM text mocks were exercised" + (t/is (pos? (thw/call-count :set-shape-text-content))) + (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) (t/deftest test-apply-reference-typography-token (t/testing "applies typography (composite) tokens with references" @@ -1018,7 +1046,10 @@ (t/is (= (:typography (:applied-tokens text-1')) "typography")) (t/is (= (:font-size style-text-blocks) "100")) - (t/is (= (:font-family style-text-blocks) "Arial"))))))))) + (t/is (= (:font-family style-text-blocks) "Arial")) + (t/testing "WASM text mocks were exercised" + (t/is (pos? (thw/call-count :set-shape-text-content))) + (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) (t/deftest test-unapply-atomic-tokens-on-composite-apply (t/testing "unapplies atomic typography tokens when applying composite token" @@ -1172,4 +1203,7 @@ (t/is (nil? (:typography-ref-id paragraph-3))) (t/is (nil? (:typography-ref-file paragraph-3))) (t/is (nil? (:typography-ref-id text-node-3))) - (t/is (nil? (:typography-ref-file text-node-3)))))))))) + (t/is (nil? (:typography-ref-file text-node-3))) + (t/testing "WASM text mocks were exercised" + (t/is (pos? (thw/call-count :set-shape-text-content))) + (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) diff --git a/mcp/packages/plugin/index.html b/mcp/packages/plugin/index.html index b2c08b5dae..fa573c1d0e 100644 --- a/mcp/packages/plugin/index.html +++ b/mcp/packages/plugin/index.html @@ -3,12 +3,83 @@ - Penpot plugin example + Penpot MCP Plugin - +
+
+ + Not connected +
-
Not connected
+ + + +
+ + + Execution status + + +
+ Current task +
+ + --- +
+ +
+ Executed code + +
+ +
+
+
diff --git a/mcp/packages/plugin/public/icon.jpg b/mcp/packages/plugin/public/icon.jpg new file mode 100644 index 0000000000..9df8dd26a4 Binary files /dev/null and b/mcp/packages/plugin/public/icon.jpg differ diff --git a/mcp/packages/plugin/public/manifest.json b/mcp/packages/plugin/public/manifest.json index e2a769c7f8..aa97095b30 100644 --- a/mcp/packages/plugin/public/manifest.json +++ b/mcp/packages/plugin/public/manifest.json @@ -1,6 +1,7 @@ { "name": "Penpot MCP Plugin", "code": "plugin.js", + "icon": "icon.jpg", "version": 2, "description": "This plugin enables interaction with the Penpot MCP server", "permissions": ["content:read", "content:write", "library:read", "library:write", "comment:read", "comment:write"] diff --git a/mcp/packages/plugin/src/main.ts b/mcp/packages/plugin/src/main.ts index da87fa025f..8aad137ec3 100644 --- a/mcp/packages/plugin/src/main.ts +++ b/mcp/packages/plugin/src/main.ts @@ -6,21 +6,33 @@ document.body.dataset.theme = searchParams.get("theme") ?? "light"; // WebSocket connection management let ws: WebSocket | null = null; -const statusElement = document.getElementById("connection-status"); + +const statusPill = document.getElementById("connection-status") as HTMLElement; +const statusText = document.getElementById("status-text") as HTMLElement; +const currentTaskEl = document.getElementById("current-task") as HTMLElement; +const executedCodeEl = document.getElementById("executed-code") as HTMLTextAreaElement; +const copyCodeBtn = document.getElementById("copy-code-btn") as HTMLButtonElement; +const connectBtn = document.getElementById("connect-btn") as HTMLButtonElement; +const disconnectBtn = document.getElementById("disconnect-btn") as HTMLButtonElement; /** - * Updates the connection status display element. + * Updates the status pill and button visibility based on connection state. * - * @param status - the base status text to display - * @param isConnectedState - whether the connection is in a connected state (affects color) - * @param message - optional additional message to append to the status + * @param code - the connection state code ("idle" | "connecting" | "connected" | "disconnected" | "error") + * @param label - human-readable label to display inside the pill */ -function updateConnectionStatus(code: string, status: string, isConnectedState: boolean, message?: string): void { - if (statusElement) { - const displayText = message ? `${status}: ${message}` : status; - statusElement.textContent = displayText; - statusElement.style.color = isConnectedState ? "var(--accent-primary)" : "var(--error-700)"; +function updateConnectionStatus(code: string, label: string): void { + if (statusPill) { + statusPill.dataset.status = code; } + if (statusText) { + statusText.textContent = label; + } + + const isConnected = code === "connected"; + if (connectBtn) connectBtn.hidden = isConnected; + if (disconnectBtn) disconnectBtn.hidden = !isConnected; + parent.postMessage( { type: "update-connection-status", @@ -30,6 +42,34 @@ function updateConnectionStatus(code: string, status: string, isConnectedState: ); } +/** + * Updates the "Current task" display with the currently executing task name. + * + * @param taskName - the task name to display, or null to reset to "---" + */ +function updateCurrentTask(taskName: string | null): void { + if (currentTaskEl) { + currentTaskEl.textContent = taskName ?? "---"; + } + if (taskName === null) { + updateExecutedCode(null); + } +} + +/** + * Updates the executed code textarea with the last code run during task execution. + * + * @param code - the code string to display, or null to clear + */ +function updateExecutedCode(code: string | null): void { + if (executedCodeEl) { + executedCodeEl.value = code ?? ""; + } + if (copyCodeBtn) { + copyCodeBtn.disabled = !code; + } +} + /** * Sends a task response back to the MCP server via WebSocket. * @@ -49,7 +89,7 @@ function sendTaskResponse(response: any): void { */ function connectToMcpServer(baseUrl?: string, token?: string): void { if (ws?.readyState === WebSocket.OPEN) { - updateConnectionStatus("connected", "Already connected", true); + updateConnectionStatus("connected", "Connected"); return; } @@ -62,17 +102,22 @@ function connectToMcpServer(baseUrl?: string, token?: string): void { } ws = new WebSocket(wsUrl); - updateConnectionStatus("connecting", "Connecting...", false); + updateConnectionStatus("connecting", "Connecting..."); ws.onopen = () => { console.log("Connected to MCP server"); - updateConnectionStatus("connected", "Connected to MCP server", true); + updateConnectionStatus("connected", "Connected"); }; ws.onmessage = (event) => { try { console.log("Received from MCP server:", event.data); const request = JSON.parse(event.data); + // Track the current task received from the MCP server + if (request.task) { + updateCurrentTask(request.task); + updateExecutedCode(request.params?.code ?? null); + } // Forward the task request to the plugin for execution parent.postMessage(request, "*"); } catch (error) { @@ -84,8 +129,9 @@ function connectToMcpServer(baseUrl?: string, token?: string): void { // If we've send the error update we don't send the disconnect as well if (!wsError) { console.log("Disconnected from MCP server"); - const message = event.reason || undefined; - updateConnectionStatus("disconnected", "Disconnected", false, message); + const label = event.reason ? `Disconnected: ${event.reason}` : "Disconnected"; + updateConnectionStatus("disconnected", label); + updateCurrentTask(null); } ws = null; }; @@ -94,19 +140,34 @@ function connectToMcpServer(baseUrl?: string, token?: string): void { console.error("WebSocket error:", error); wsError = error; // note: WebSocket error events typically don't contain detailed error messages - updateConnectionStatus("error", "Connection error", false); + updateConnectionStatus("error", "Connection error"); }; } catch (error) { console.error("Failed to connect to MCP server:", error); - const message = error instanceof Error ? error.message : undefined; - updateConnectionStatus("error", "Connection failed", false, message); + const reason = error instanceof Error ? error.message : undefined; + const label = reason ? `Connection failed: ${reason}` : "Connection failed"; + updateConnectionStatus("error", label); } } -document.querySelector("[data-handler='connect-mcp']")?.addEventListener("click", () => { +copyCodeBtn?.addEventListener("click", () => { + const code = executedCodeEl?.value; + if (!code) return; + + navigator.clipboard.writeText(code).then(() => { + copyCodeBtn.classList.add("copied"); + setTimeout(() => copyCodeBtn.classList.remove("copied"), 1500); + }); +}); + +connectBtn?.addEventListener("click", () => { connectToMcpServer(); }); +disconnectBtn?.addEventListener("click", () => { + ws?.close(); +}); + // Listen plugin.ts messages window.addEventListener("message", (event) => { if (event.data.type === "start-server") { diff --git a/mcp/packages/plugin/src/plugin.ts b/mcp/packages/plugin/src/plugin.ts index e113f7adc3..e2b5bee38e 100644 --- a/mcp/packages/plugin/src/plugin.ts +++ b/mcp/packages/plugin/src/plugin.ts @@ -10,8 +10,8 @@ const taskHandlers: TaskHandler[] = [new ExecuteCodeTaskHandler()]; // Open the plugin UI (main.ts) penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}`, { - width: 158, - height: 200, + width: 236, + height: 210, hidden: !!mcp, } as any); diff --git a/mcp/packages/plugin/src/style.css b/mcp/packages/plugin/src/style.css index 030f2204e9..7061657b33 100644 --- a/mcp/packages/plugin/src/style.css +++ b/mcp/packages/plugin/src/style.css @@ -1,10 +1,178 @@ @import "@penpot/plugin-styles/styles.css"; body { - line-height: 1.5; - padding: 10px; + margin: 0; + padding: 0; } -p { - margin-block-end: 0.75rem; +.plugin-container { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-16) var(--spacing-8); + box-sizing: border-box; +} + +/* ── Status pill ─────────────────────────────────────────────────── */ + +.status-pill { + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-16); + border-radius: var(--spacing-8); + border: 1px solid var(--background-quaternary); + color: var(--foreground-secondary); + width: 100%; + box-sizing: border-box; +} + +.status-pill[data-status="connected"] { + border-color: var(--accent-primary); + color: var(--accent-primary); +} + +.status-pill[data-status="disconnected"], +.status-pill[data-status="error"] { + border-color: var(--error-500); + color: var(--error-500); +} + +.status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background-color: currentColor; + flex-shrink: 0; +} + +/* ── Collapsible section ─────────────────────────────────────────── */ + +.collapsible-section { + border: 1px solid var(--background-quaternary); + border-radius: var(--spacing-8); + overflow: hidden; +} + +.collapsible-header { + display: flex; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-12); + cursor: pointer; + color: var(--foreground-secondary); + list-style: none; + user-select: none; +} + +.collapsible-header::-webkit-details-marker { + display: none; +} + +.collapsible-arrow { + flex-shrink: 0; + transition: transform 0.2s ease; +} + +details[open] > .collapsible-header .collapsible-arrow { + transform: rotate(90deg); +} + +.collapsible-body { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-4) var(--spacing-12) var(--spacing-12); + border-top: 1px solid var(--background-quaternary); +} + +/* ── Tool section ────────────────────────────────────────────────── */ + +.tool-label { + color: var(--foreground-secondary); +} + +.tool-display { + display: flex; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-12); + border-radius: var(--spacing-8); + background-color: var(--background-tertiary); + color: var(--foreground-secondary); + min-height: 32px; + box-sizing: border-box; +} + +.tool-icon { + flex-shrink: 0; + opacity: 0.7; +} + +/* ── Code section ────────────────────────────────────────────────── */ + +.code-section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: var(--spacing-8); +} + +.code-textarea { + width: 100%; + height: 100px; + resize: vertical; + padding: var(--spacing-8) var(--spacing-12); + border-radius: var(--spacing-8); + border: 1px solid var(--background-quaternary); + background-color: var(--background-tertiary); + color: var(--foreground-secondary); + font-family: monospace; + font-size: 11px; + line-height: 1.5; + box-sizing: border-box; + outline: none; +} + +.copy-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--background-quaternary); + border-radius: var(--spacing-4); + background-color: transparent; + color: var(--foreground-secondary); + cursor: pointer; + flex-shrink: 0; + transition: + background-color 0.15s ease, + color 0.15s ease; +} + +.copy-btn:hover:not(:disabled) { + background-color: var(--background-tertiary); + color: var(--foreground-primary); +} + +.copy-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.copy-btn.copied { + color: var(--accent-primary); + border-color: var(--accent-primary); +} + +/* ── Action buttons ──────────────────────────────────────────────── */ + +#connect-btn, +#disconnect-btn { + width: 100%; + margin-top: var(--spacing-4); } diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 9eaaee0e85..707523f626 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -435,10 +435,10 @@ impl RenderState { shape.frame_clip_layer_blur() } - /// Renders background blur effect directly to the Current surface. + /// Renders background blur effect directly to the given target surface. /// Must be called BEFORE any save_layer for the shape's own opacity/blend, /// so that the backdrop blur is independent of the shape's visual properties. - fn render_background_blur(&mut self, shape: &Shape) { + fn render_background_blur(&mut self, shape: &Shape, target_surface: SurfaceId) { if self.options.is_fast_mode() { return; } @@ -458,21 +458,22 @@ impl RenderState { let scaled_sigma = radius_to_sigma(blur.value * scale); // Cap sigma so the blur kernel (≈3σ) stays within the tile margin. // This prevents visible seams at tile boundaries when zoomed in. - let margin = self.surfaces.margins().width as f32; - let max_sigma = margin / 3.0; - let capped_sigma = scaled_sigma.min(max_sigma); - - let blur_filter = match skia::image_filters::blur( - (capped_sigma, capped_sigma), - skia::TileMode::Clamp, - None, - None, - ) { - Some(filter) => filter, - None => return, + // During export there's no tiling, so skip the cap. + let sigma = if self.export_context.is_some() { + scaled_sigma + } else { + let margin = self.surfaces.margins().width as f32; + let max_sigma = margin / 3.0; + scaled_sigma.min(max_sigma) }; - let snapshot = self.surfaces.snapshot(SurfaceId::Current); + let blur_filter = + match skia::image_filters::blur((sigma, sigma), skia::TileMode::Clamp, None, None) { + Some(filter) => filter, + None => return, + }; + + let target_surface_snapshot = self.surfaces.snapshot(target_surface); let translation = self .surfaces .get_render_context_translation(self.render_area, scale); @@ -482,10 +483,10 @@ impl RenderState { matrix.post_translate(center); matrix.pre_translate(-center); - let canvas = self.surfaces.canvas(SurfaceId::Current); + let canvas = self.surfaces.canvas(target_surface); canvas.save(); - // Current has no render context transform (identity canvas). + // Current/Export have no render context transform (identity canvas). // Apply scale + translate + shape transform so the clip maps // from shape-local coords to device pixels correctly. canvas.scale((scale, scale)); @@ -528,7 +529,7 @@ impl RenderState { let mut paint = skia::Paint::default(); paint.set_image_filter(blur_filter); paint.set_blend_mode(skia::BlendMode::Src); - canvas.draw_image(&snapshot, (0, 0), Some(&paint)); + canvas.draw_image(&target_surface_snapshot, (0, 0), Some(&paint)); canvas.restore(); } @@ -1521,6 +1522,11 @@ impl RenderState { ) -> Result<(Vec, i32, i32)> { let target_surface = SurfaceId::Export; + // Reset focus mode so all shapes in the export tree are rendered. + // Without this, leftover focus_mode state from the workspace could + // cause shapes (and their background blur) to be skipped. + self.focus_mode.clear(); + self.surfaces .canvas(target_surface) .clear(skia::Color::TRANSPARENT); @@ -1533,6 +1539,8 @@ impl RenderState { extrect.offset((margins.width as f32 / scale, margins.height as f32 / scale)); self.surfaces.resize_export_surface(scale, extrect); + self.render_area = extrect; + self.render_area_with_margins = extrect; self.surfaces.update_render_context(extrect, scale); self.pending_nodes.push(NodeRenderState { @@ -1546,6 +1554,9 @@ impl RenderState { self.render_shape_tree_partial_uncached(tree, timestamp, false, true)?; } + // Clear export context so get_scale() returns to workspace zoom. + self.export_context = None; + self.surfaces .flush_and_submit(&mut self.gpu_state, target_surface); @@ -2334,13 +2345,7 @@ impl RenderState { // Render background blur BEFORE save_layer so it modifies // the backdrop independently of the shape's opacity. if !node_render_state.is_root() && self.focus_mode.is_active() { - self.render_background_blur(element); - } - - // Render background blur BEFORE save_layer so it modifies - // the backdrop independently of the shape's opacity. - if !node_render_state.is_root() && self.focus_mode.is_active() { - self.render_background_blur(element); + self.render_background_blur(element, target_surface); } self.render_shape_enter(element, mask, target_surface); @@ -2910,6 +2915,10 @@ impl RenderState { } pub fn get_scale(&self) -> f32 { + // During export, use the export scale instead of the workspace zoom. + if let Some((_, export_scale)) = self.export_context { + return export_scale; + } self.viewbox.zoom() * self.options.dpr() } diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 9239b38eb4..7b6ba8a65e 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -168,8 +168,16 @@ impl State { } } - if let Some(shape_to_delete) = self.shapes.get_mut(&id) { - shape_to_delete.set_deleted(true); + if let Some(shape_to_delete) = self.shapes.get(&id) { + let to_delete = shape_to_delete.all_children(&self.shapes, true, true); + for shape_id in to_delete { + if let Some(shape_to_delete) = self.shapes.get_mut(&shape_id) { + shape_to_delete.set_deleted(true); + } + if self.render_state.show_grid == Some(shape_id) { + self.render_state.show_grid = None; + } + } } } } diff --git a/render-wasm/src/wasm/paths/bools.rs b/render-wasm/src/wasm/paths/bools.rs index c19791dc95..0e6636e2f2 100644 --- a/render-wasm/src/wasm/paths/bools.rs +++ b/render-wasm/src/wasm/paths/bools.rs @@ -18,7 +18,7 @@ pub enum RawBoolType { Union = 0, Difference = 1, Intersection = 2, - Exclusion = 3, + Exclude = 3, } impl From for RawBoolType { @@ -33,7 +33,7 @@ impl From for BoolType { RawBoolType::Union => BoolType::Union, RawBoolType::Difference => BoolType::Difference, RawBoolType::Intersection => BoolType::Intersection, - RawBoolType::Exclusion => BoolType::Exclusion, + RawBoolType::Exclude => BoolType::Exclusion, } } }