diff --git a/common/src/app/common/types/component.cljc b/common/src/app/common/types/component.cljc index 46337ca813..37a090217b 100644 --- a/common/src/app/common/types/component.cljc +++ b/common/src/app/common/types/component.cljc @@ -249,7 +249,7 @@ (defn is-variant-container? "Check if this shape is a variant container" [shape] - (:is-variant-container shape)) + (boolean (:is-variant-container shape))) (defn set-touched-group [touched group] diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 8716b1a26b..8304dc9f1f 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -772,44 +772,46 @@ #{:up :down :bottom :top}) (defn vertical-order-selected - [loc] - (dm/assert! - "expected valid location" - (contains? valid-vertical-locations loc)) - (ptk/reify ::vertical-order-selected - ptk/WatchEvent - (watch [it state _] - (let [page-id (:current-page-id state) - objects (dsh/lookup-page-objects state page-id) - selected-ids (dsh/lookup-selected state) - selected-shapes (map (d/getf objects) selected-ids) - undo-id (js/Symbol) + ([loc] + (vertical-order-selected loc nil)) + ([loc ids] + (dm/assert! + "expected valid location" + (contains? valid-vertical-locations loc)) + (ptk/reify ::vertical-order-selected + ptk/WatchEvent + (watch [it state _] + (let [page-id (:current-page-id state) + objects (dsh/lookup-page-objects state page-id) + selected-ids (or ids (dsh/lookup-selected state)) + selected-shapes (map (d/getf objects) selected-ids) + undo-id (js/Symbol) - move-shape - (fn [changes shape] - (let [parent (get objects (:parent-id shape)) - sibling-ids (:shapes parent) - current-index (d/index-of sibling-ids (:id shape)) - index-in-selection (d/index-of selected-ids (:id shape)) - new-index (case loc - :top (count sibling-ids) - :down (max 0 (- current-index 1)) - :up (min (count sibling-ids) (+ (inc current-index) 1)) - :bottom index-in-selection)] - (pcb/change-parent changes - (:id parent) - [shape] - new-index))) + move-shape + (fn [changes shape] + (let [parent (get objects (:parent-id shape)) + sibling-ids (:shapes parent) + current-index (d/index-of sibling-ids (:id shape)) + index-in-selection (d/index-of selected-ids (:id shape)) + new-index (case loc + :top (count sibling-ids) + :down (max 0 (- current-index 1)) + :up (min (count sibling-ids) (+ (inc current-index) 1)) + :bottom index-in-selection)] + (pcb/change-parent changes + (:id parent) + [shape] + new-index))) - changes (reduce move-shape - (-> (pcb/empty-changes it page-id) - (pcb/with-objects objects)) - selected-shapes)] + changes (reduce move-shape + (-> (pcb/empty-changes it page-id) + (pcb/with-objects objects)) + selected-shapes)] - (rx/of (dwu/start-undo-transaction undo-id) - (dch/commit-changes changes) - (ptk/data-event :layout/update {:ids selected-ids}) - (dwu/commit-undo-transaction undo-id)))))) + (rx/of (dwu/start-undo-transaction undo-id) + (dch/commit-changes changes) + (ptk/data-event :layout/update {:ids selected-ids}) + (dwu/commit-undo-transaction undo-id))))))) (defn set-shape-index [file-id page-id id new-index] diff --git a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs index d8be9688ae..5ccf4c2a31 100644 --- a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs +++ b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs @@ -343,20 +343,23 @@ (dch/commit-changes changes)))))) (defn duplicate-token-set - [id] - (ptk/reify ::duplicate-token-set - ptk/WatchEvent - (watch [it state _] - (let [data (dsh/lookup-file-data state) - tokens-lib (get data :tokens-lib) - suffix (tr "workspace.tokens.duplicate-suffix")] + ([id] + (duplicate-token-set id nil)) + ([id {:keys [id-ref]}] + (ptk/reify ::duplicate-token-set + ptk/WatchEvent + (watch [it state _] + (let [data (dsh/lookup-file-data state) + tokens-lib (get data :tokens-lib) + suffix (tr "workspace.tokens.duplicate-suffix")] - (when-let [token-set (ctob/duplicate-set id tokens-lib {:suffix suffix})] - (let [changes (-> (pcb/empty-changes it) - (pcb/with-library-data data) - (pcb/set-token-set (ctob/get-id token-set) token-set))] - (rx/of (set-selected-token-set-id (ctob/get-id token-set)) - (dch/commit-changes changes)))))))) + (when-let [token-set (ctob/duplicate-set id tokens-lib {:suffix suffix})] + (when id-ref (reset! id-ref (ctob/get-id token-set))) + (let [changes (-> (pcb/empty-changes it) + (pcb/with-library-data data) + (pcb/set-token-set (ctob/get-id token-set) token-set))] + (rx/of (set-selected-token-set-id (ctob/get-id token-set)) + (dch/commit-changes changes))))))))) (defn set-enabled-token-set [name enabled?] diff --git a/frontend/src/app/plugins/api.cljs b/frontend/src/app/plugins/api.cljs index a9b1e4d323..3d3796a14e 100644 --- a/frontend/src/app/plugins/api.cljs +++ b/frontend/src/app/plugins/api.cljs @@ -317,6 +317,11 @@ (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) (u/not-valid plugin-id :group-shapes shapes) + ;; A group cannot be created from no shapes; per the documented contract + ;; return null instead of a proxy pointing at a shape that never exists. + (zero? (alength shapes)) + nil + (some #(not (u/page-active? (obj/get % "$page"))) shapes) (u/not-valid plugin-id :group "Cannot modify a page that is not currently active") @@ -664,8 +669,13 @@ (u/not-valid plugin-id :flatten-shapes "Not valid shapes") :else - (let [ids (into #{} (map #(obj/get % "$id")) shapes)] - (st/emit! (dw/convert-selected-to-path ids))))) + ;; convert-selected-to-path converts the shapes in place (keeping their + ;; ids), so return proxies for the same ids, now resolving as paths. + (let [file-id (:current-file-id @st/state) + page-id (:current-page-id @st/state) + ids (mapv #(obj/get % "$id") shapes)] + (st/emit! (dw/convert-selected-to-path (into #{} ids))) + (apply array (map #(shape/shape-proxy plugin-id file-id page-id %) ids))))) :createVariantFromComponents (fn [shapes] diff --git a/frontend/src/app/plugins/comments.cljs b/frontend/src/app/plugins/comments.cljs index 629f445c40..71e8a0311b 100644 --- a/frontend/src/app/plugins/comments.cljs +++ b/frontend/src/app/plugins/comments.cljs @@ -9,7 +9,6 @@ [app.common.geom.point :as gpt] [app.common.schema :as sm] [app.main.data.comments :as dc] - [app.main.data.helpers :as dsh] [app.main.data.workspace.comments :as dwc] [app.main.repo :as rp] [app.main.store :as st] @@ -203,13 +202,12 @@ :remove (fn [] - (let [profile (:profile @st/state) - owner (dsh/lookup-profile @st/state (:owner-id data))] + (let [profile (:profile @st/state)] (cond (not (r/check-permission plugin-id "comment:write")) (u/not-valid plugin-id :remove "Plugin doesn't have 'comment:write' permission") - (not= (:id profile) owner) + (not= (:id profile) (:owner-id data)) (u/not-valid plugin-id :remove "Cannot change content from another user's comments") :else diff --git a/frontend/src/app/plugins/file.cljs b/frontend/src/app/plugins/file.cljs index 69e794d2d0..ac6ef089e5 100644 --- a/frontend/src/app/plugins/file.cljs +++ b/frontend/src/app/plugins/file.cljs @@ -64,7 +64,7 @@ (user/user-proxy plugin-id user-data)))} :createdAt - {:get #(.toJSDate ^js (:created-at @data))} + {:get #(:created-at @data)} :isAutosave {:get #(= "system" (:created-by @data))} @@ -136,6 +136,9 @@ :name {:get #(-> (u/locate-file id) :name)} + :revn + {:get #(-> (u/locate-file id) :revn)} + :pages {:this true :get #(.getPages ^js %)} diff --git a/frontend/src/app/plugins/fonts.cljs b/frontend/src/app/plugins/fonts.cljs index 306db90698..2ae009ce9d 100644 --- a/frontend/src/app/plugins/fonts.cljs +++ b/frontend/src/app/plugins/fonts.cljs @@ -64,13 +64,13 @@ (u/not-valid plugin-id :applyToText "Cannot modify a page that is not currently active") :else - (let [id (obj/get text "$id") + (let [text-id (obj/get text "$id") values {:font-id id :font-family family :font-style (d/nilv (obj/get variant "fontStyle") (:style default-variant)) :font-variant-id (d/nilv (obj/get variant "fontVariantId") (:id default-variant)) :font-weight (d/nilv (obj/get variant "fontWeight") (:weight default-variant))}] - (st/emit! (dwt/update-attrs id values))))) + (st/emit! (dwt/update-attrs text-id values))))) :applyToRange (fn [range variant] @@ -85,15 +85,15 @@ (u/not-valid plugin-id :applyToRange "Cannot modify a page that is not currently active") :else - (let [id (obj/get range "$id") - start (obj/get range "start") - end (obj/get range "end") + (let [range-id (obj/get range "$id") + start (obj/get range "$start") + end (obj/get range "$end") values {:font-id id :font-family family :font-style (d/nilv (obj/get variant "fontStyle") (:style default-variant)) :font-variant-id (d/nilv (obj/get variant "fontVariantId") (:id default-variant)) :font-weight (d/nilv (obj/get variant "fontWeight") (:weight default-variant))}] - (st/emit! (dwt/update-text-range id start end values))))))))) + (st/emit! (dwt/update-text-range range-id start end values))))))))) (defn fonts-subcontext [plugin-id] diff --git a/frontend/src/app/plugins/format.cljs b/frontend/src/app/plugins/format.cljs index 36137f1307..e920793434 100644 --- a/frontend/src/app/plugins/format.cljs +++ b/frontend/src/app/plugins/format.cljs @@ -47,6 +47,7 @@ :frame "board" :rect "rectangle" :circle "ellipse" + :bool "boolean" (d/name type))) ;;export type Bounds = { @@ -146,7 +147,7 @@ [[color attrs]] (let [shapes-info (apply array (map format-shape-info attrs)) color (format-color color)] - (obj/set! color "shapeInfo" shapes-info) + (obj/set! color "shapesInfo" shapes-info) color)) diff --git a/frontend/src/app/plugins/grid.cljs b/frontend/src/app/plugins/grid.cljs index 5582b80d0c..6964a034dd 100644 --- a/frontend/src/app/plugins/grid.cljs +++ b/frontend/src/app/plugins/grid.cljs @@ -301,11 +301,15 @@ :addRowAtIndex (fn [index type value] - (let [type (keyword type)] + (let [type (keyword type) + num-rows (-> (u/locate-shape file-id page-id id) :layout-grid-rows count)] (cond (not (sm/valid-safe-int? index)) (u/not-valid plugin-id :addRowAtIndex-index index) + (or (< index 0) (> index num-rows)) + (u/not-valid plugin-id :addRowAtIndex-index index) + (not (contains? ctl/grid-track-types type)) (u/not-valid plugin-id :addRowAtIndex-type type) @@ -344,64 +348,80 @@ :addColumnAtIndex (fn [index type value] - (cond - (not (sm/valid-safe-int? index)) - (u/not-valid plugin-id :addColumnAtIndex-index index) + (let [type (keyword type) + num-columns (-> (u/locate-shape file-id page-id id) :layout-grid-columns count)] + (cond + (not (sm/valid-safe-int? index)) + (u/not-valid plugin-id :addColumnAtIndex-index index) - (not (contains? ctl/grid-track-types type)) - (u/not-valid plugin-id :addColumnAtIndex-type type) + (or (< index 0) (> index num-columns)) + (u/not-valid plugin-id :addColumnAtIndex-index index) - (and (or (= :percent type) (= :flex type) (= :fixed type)) - (not (sm/valid-safe-number? value))) - (u/not-valid plugin-id :addColumnAtIndex-value value) + (not (contains? ctl/grid-track-types type)) + (u/not-valid plugin-id :addColumnAtIndex-type type) - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :addColumnAtIndex "Plugin doesn't have 'content:write' permission") + (and (or (= :percent type) (= :flex type) (= :fixed type)) + (not (sm/valid-safe-number? value))) + (u/not-valid plugin-id :addColumnAtIndex-value value) - (not (u/page-active? page-id)) - (u/not-valid plugin-id :addColumnAtIndex "Cannot modify a page that is not currently active") + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :addColumnAtIndex "Plugin doesn't have 'content:write' permission") - :else - (let [type (keyword type)] + (not (u/page-active? page-id)) + (u/not-valid plugin-id :addColumnAtIndex "Cannot modify a page that is not currently active") + + :else (st/emit! (dwsl/add-layout-track #{id} :column {:type type :value value} index))))) :removeRow (fn [index] - (cond - (not (sm/valid-safe-int? index)) - (u/not-valid plugin-id :removeRow index) + (let [num-rows (-> (u/locate-shape file-id page-id id) :layout-grid-rows count)] + (cond + (not (sm/valid-safe-int? index)) + (u/not-valid plugin-id :removeRow index) - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :removeRow "Plugin doesn't have 'content:write' permission") + (or (< index 0) (>= index num-rows)) + (u/not-valid plugin-id :removeRow index) - (not (u/page-active? page-id)) - (u/not-valid plugin-id :removeRow "Cannot modify a page that is not currently active") + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :removeRow "Plugin doesn't have 'content:write' permission") - :else - (st/emit! (dwsl/remove-layout-track #{id} :row index)))) + (not (u/page-active? page-id)) + (u/not-valid plugin-id :removeRow "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwsl/remove-layout-track #{id} :row index))))) :removeColumn (fn [index] - (cond - (not (sm/valid-safe-int? index)) - (u/not-valid plugin-id :removeColumn index) + (let [num-columns (-> (u/locate-shape file-id page-id id) :layout-grid-columns count)] + (cond + (not (sm/valid-safe-int? index)) + (u/not-valid plugin-id :removeColumn index) - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :removeColumn "Plugin doesn't have 'content:write' permission") + (or (< index 0) (>= index num-columns)) + (u/not-valid plugin-id :removeColumn index) - (not (u/page-active? page-id)) - (u/not-valid plugin-id :removeColumn "Cannot modify a page that is not currently active") + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :removeColumn "Plugin doesn't have 'content:write' permission") - :else - (st/emit! (dwsl/remove-layout-track #{id} :column index)))) + (not (u/page-active? page-id)) + (u/not-valid plugin-id :removeColumn "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwsl/remove-layout-track #{id} :column index))))) :setColumn (fn [index type value] - (let [type (keyword type)] + (let [type (keyword type) + num-columns (-> (u/locate-shape file-id page-id id) :layout-grid-columns count)] (cond (not (sm/valid-safe-int? index)) (u/not-valid plugin-id :setColumn-index index) + (or (< index 0) (>= index num-columns)) + (u/not-valid plugin-id :setColumn-index index) + (not (contains? ctl/grid-track-types type)) (u/not-valid plugin-id :setColumn-type type) @@ -420,11 +440,15 @@ :setRow (fn [index type value] - (let [type (keyword type)] + (let [type (keyword type) + num-rows (-> (u/locate-shape file-id page-id id) :layout-grid-rows count)] (cond (not (sm/valid-safe-int? index)) (u/not-valid plugin-id :setRow-index index) + (or (< index 0) (>= index num-rows)) + (u/not-valid plugin-id :setRow-index index) + (not (contains? ctl/grid-track-types type)) (u/not-valid plugin-id :setRow-type type) diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs index 2e5b5c434c..dccce39b97 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -51,6 +51,7 @@ :$file {:enumerable false :get (constantly file-id)} :id {:get (fn [] (dm/str id))} + :libraryId {:get (fn [] (dm/str file-id))} :fileId {:get #(dm/str file-id)} :name @@ -101,7 +102,8 @@ :else (let [color (-> (u/proxy->library-color self) - (assoc :color value))] + (assoc :color value) + (dissoc :gradient :image))] (st/emit! (dwl/update-color-data color file-id)))))} :opacity @@ -136,7 +138,8 @@ :else (let [color (-> (u/proxy->library-color self) - (assoc :gradient value))] + (assoc :gradient value) + (dissoc :color :image))] (st/emit! (dwl/update-color-data color file-id))))))} :image @@ -154,7 +157,8 @@ :else (let [color (-> (u/proxy->library-color self) - (assoc :image value))] + (assoc :image value) + (dissoc :color :gradient))] (st/emit! (dwl/update-color-data color file-id))))))} :remove @@ -295,6 +299,7 @@ :$id {:enumerable false :get (constantly id)} :$file {:enumerable false :get (constantly file-id)} :id {:get (fn [] (dm/str id))} + :libraryId {:get (fn [] (dm/str file-id))} :name {:this true @@ -484,6 +489,27 @@ (assoc :text-transform value))] (st/emit! (dwl/update-typography typo file-id)))))} + :setFont + (fn [font variant] + (cond + (not (obj/type-of? font "FontProxy")) + (u/not-valid plugin-id :setFont font) + + (not (r/check-permission plugin-id "library:write")) + (u/not-valid plugin-id :setFont "Plugin doesn't have 'library:write' permission") + + :else + ;; When a variant is given read the variant-specific fields from it; + ;; otherwise the FontProxy exposes the font's default variant fields. + (let [source (if (obj/type-of? variant "FontVariantProxy") variant font) + typo (-> (u/locate-library-typography file-id id) + (assoc :font-id (obj/get font "fontId") + :font-family (obj/get font "fontFamily") + :font-variant-id (obj/get source "fontVariantId") + :font-style (obj/get source "fontStyle") + :font-weight (obj/get source "fontWeight")))] + (st/emit! (dwl/update-typography typo file-id))))) + :remove (fn [] (cond @@ -539,8 +565,8 @@ :else (let [shape-id (obj/get range "$id") - start (obj/get range "start") - end (obj/get range "end") + start (obj/get range "$start") + end (obj/get range "$end") typography (u/locate-library-typography file-id id) attrs (-> typography (assoc :typography-ref-file file-id) @@ -718,6 +744,7 @@ :$id {:enumerable false :get (constantly id)} :$file {:enumerable false :get (constantly file-id)} :id {:get (fn [] (dm/str id))} + :libraryId {:get (fn [] (dm/str file-id))} :name {:this true diff --git a/frontend/src/app/plugins/local_storage.cljs b/frontend/src/app/plugins/local_storage.cljs index 4a9a4967a8..1b24d520ed 100644 --- a/frontend/src/app/plugins/local_storage.cljs +++ b/frontend/src/app/plugins/local_storage.cljs @@ -60,7 +60,7 @@ (u/not-valid plugin-id :removeItem "The key must be a string") :else - (.getItem ^js local-storage (prefix-key plugin-id key)))) + (.removeItem ^js local-storage (prefix-key plugin-id key)))) :getKeys (fn [] diff --git a/frontend/src/app/plugins/page.cljs b/frontend/src/app/plugins/page.cljs index 27755844ad..9ab02e3b6c 100644 --- a/frontend/src/app/plugins/page.cljs +++ b/frontend/src/app/plugins/page.cljs @@ -412,8 +412,7 @@ (js/Promise. (fn [resolve] (let [thread-id (obj/get thread "$id")] - (js/Promise. - (st/emit! (dc/delete-comment-thread-on-workspace {:id thread-id} #(resolve))))))))) + (st/emit! (dc/delete-comment-thread-on-workspace {:id thread-id} #(resolve)))))))) :findCommentThreads (fn [criteria] diff --git a/frontend/src/app/plugins/parser.cljs b/frontend/src/app/plugins/parser.cljs index 3d6b481b50..0c3c2af32a 100644 --- a/frontend/src/app/plugins/parser.cljs +++ b/frontend/src/app/plugins/parser.cljs @@ -343,10 +343,10 @@ (when (some? guide) (case (obj/get guide "type") "column" - parse-frame-guide-column + (parse-frame-guide-column guide) "row" - parse-frame-guide-row + (parse-frame-guide-row guide) "square" (parse-frame-guide-square guide)))) @@ -489,7 +489,7 @@ :destination (-> (obj/get action "destination") (obj/get "$id")) :relative-to (-> (obj/get action "relativeTo") (obj/get "$id")) :overlay-pos-type (-> (obj/get action "position") parse-keyword) - :overlay-position (-> (obj/get action "manualPositionLocation") parse-point) + :overlay-position (-> (obj/get action "manualPositionLocation") parse-point (d/nilv (gpt/point 0 0))) :close-click-outside (obj/get action "closeWhenClickOutside") :background-overlay (obj/get action "addBackgroundOverlay") :animation (-> (obj/get action "animation") parse-animation)} diff --git a/frontend/src/app/plugins/public_utils.cljs b/frontend/src/app/plugins/public_utils.cljs index 1f7c92f8cd..3a303fe888 100644 --- a/frontend/src/app/plugins/public_utils.cljs +++ b/frontend/src/app/plugins/public_utils.cljs @@ -14,10 +14,15 @@ [app.plugins.utils :as u])) (defn ^:export centerShapes - [plugin-id shapes] + [shapes] (cond (not (every? shape/shape-proxy? shapes)) - (u/not-valid plugin-id :centerShapes shapes) + (u/not-valid nil :centerShapes shapes) + + ;; The documented contract returns null for an empty array; without this + ;; guard `shapes->rect` yields a non-rect and `rect->center` asserts. + (empty? shapes) + nil :else (let [shapes (->> shapes (map u/proxy->shape))] diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index 882f8eca05..cccc32c625 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -101,7 +101,7 @@ :else (st/emit! (dwi/update-interaction - {:id shape-id} + (u/locate-shape file-id page-id shape-id) index #(assoc % :event-type value) {:page-id page-id})))))} @@ -117,7 +117,7 @@ :else (st/emit! (dwi/update-interaction - {:id shape-id} + (u/locate-shape file-id page-id shape-id) index #(assoc % :delay value) {:page-id page-id}))))} @@ -137,7 +137,7 @@ :else (st/emit! (dwi/update-interaction - {:id shape-id} + (u/locate-shape file-id page-id shape-id) index #(d/patch-object % params) {:page-id page-id})))))} @@ -592,7 +592,7 @@ :else (st/emit! (dwsh/update-shapes [id] #(assoc % :blur value)))))))} - :background-blur + :backgroundBlur {:this true :get #(-> % u/proxy->shape :background-blur format/format-blur) :set @@ -1249,6 +1249,11 @@ :else (st/emit! (dwg/unmask-group #{id}))))) + :isMask + (fn [] + (let [shape (u/locate-shape file-id page-id id)] + (boolean (cfh/mask-shape? shape)))) + ;; Only for path and bool shapes :toD (fn [] @@ -1315,19 +1320,19 @@ :bringForward (fn [] - (st/emit! (dw/vertical-order-selected :up))) + (st/emit! (dw/vertical-order-selected :up [id]))) :sendBackward (fn [] - (st/emit! (dw/vertical-order-selected :down))) + (st/emit! (dw/vertical-order-selected :down [id]))) :bringToFront (fn [] - (st/emit! (dw/vertical-order-selected :top))) + (st/emit! (dw/vertical-order-selected :top [id]))) :sendToBack (fn [] - (st/emit! (dw/vertical-order-selected :bottom))) + (st/emit! (dw/vertical-order-selected :bottom [id]))) ;; COMPONENTS :isComponentInstance @@ -1402,6 +1407,28 @@ :else (st/emit! (dwl/detach-component id)))) + :swapComponent + (fn [component] + (let [shape (u/locate-shape file-id page-id id)] + (cond + (not (u/page-active? page-id)) + (u/not-valid plugin-id :swapComponent "Cannot modify a page that is not currently active") + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :swapComponent "Plugin doesn't have 'content:write' permission") + + (not (obj/type-of? component "LibraryComponentProxy")) + (u/not-valid plugin-id :swapComponent "Component not valid") + + (not (ctk/in-component-copy? shape)) + (u/not-valid plugin-id :swapComponent "The shape is not a component copy instance") + + :else + (st/emit! (dwl/component-swap shape + (obj/get component "$file") + (obj/get component "$id") + true))))) + ;; Export :export (fn [value] @@ -1536,7 +1563,7 @@ (rg/ruler-guide-proxy plugin-id file-id page-id ruler-id))))) :removeRulerGuide - (fn [_ value] + (fn [value] (cond (not (rg/ruler-guide-proxy? value)) (u/not-valid plugin-id :removeRulerGuide "Guide not provided") @@ -1618,7 +1645,7 @@ :else (let [ids - (into #{id} (keep uuid/parse*) id) + (into #{id} (keep uuid/parse*) ids) valid? (every? @@ -1740,7 +1767,7 @@ (let [id (obj/get self "$id") value (parser/parse-frame-guides value)] (cond - (not (sm/validate [:vector ::ctg/grid] value)) + (not (sm/validate [:vector ctg/schema:grid] value)) (u/not-valid plugin-id :guides value) (not (r/check-permission plugin-id "content:write")) diff --git a/frontend/src/app/plugins/text.cljs b/frontend/src/app/plugins/text.cljs index 26e13b1039..b584625399 100644 --- a/frontend/src/app/plugins/text.cljs +++ b/frontend/src/app/plugins/text.cljs @@ -78,7 +78,7 @@ taking? (or taking? (and (<= from start) (< start to))) text (subs text (max 0 (- start acc)) (- end acc)) result (cond-> result - (and taking? (d/not-empty? text)) + (and taking? (seq text)) (conj (assoc node-style :text text))) continue? (or (> from end) (>= end to))] (recur (when continue? (rest styles)) taking? to result)) @@ -95,10 +95,11 @@ :$id {:enumerable false :get (constantly id)} :$file {:enumerable false :get (constantly file-id)} :$page {:enumerable false :get (constantly page-id)} + :$start {:enumerable false :get (constantly start)} + :$end {:enumerable false :get (constantly end)} :shape - {:this true - :get #(-> % u/proxy->shape)} + {:get (fn [] (format/shape-proxy plugin-id file-id page-id id))} :characters {:this true diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs index e18ef5f5b5..a9c27c9649 100644 --- a/frontend/src/app/plugins/tokens.cljs +++ b/frontend/src/app/plugins/tokens.cljs @@ -96,13 +96,83 @@ :expand-with-children false}) (se/add-event plugin-id)))))) +(defn- typography-resolved-value->js + "Converts a resolved typography composite (a Clojure map keyed by the + tokenscript field names) into the plugin's `TokenTypographyValue[]` shape: a + JS array with a single object using the public camelCase member names." + [m] + (when (map? m) + #js [#js {"fontFamilies" (clj->js (:font-family m)) + "fontSizes" (:font-size m) + "fontWeights" (some-> (:font-weight m) str) + "letterSpacing" (:letter-spacing m) + "lineHeight" (:line-height m) + "textCase" (:text-case m) + "textDecoration" (:text-decoration m)}])) + +(defn- shadow-key->camel + "Renames a shadow composite field name (kebab string) to its public camelCase + member name. The shadow schema is closed; offset-x/offset-y are its only + multi-word fields, so the rest (blur, spread, color, inset) pass through." + [k] + (case k + "offset-x" "offsetX" + "offset-y" "offsetY" + k)) + +(defn- shadow-entry->js + "Converts one resolved shadow entry (a JS Map of field name -> tokenscript + symbol) into a plain JS object using the public member names and the + unit-converted values." + [^js m] + (let [out #js {}] + (.forEach m (fn [sym k] + (obj/set! out (shadow-key->camel k) + (ts/tokenscript-symbols->penpot-unit sym)))) + out)) + +(defn- shadow-resolved-value->js + "Converts a resolved shadow composite (a sequence of shadow entries) into the + plugin's `TokenShadowValue[]` shape." + [entries] + (when (some? entries) + (into-array (map shadow-entry->js entries)))) + +(defn- font-families-resolved-value->js + "Converts a resolved fontFamilies value (a tokenscript list symbol) into the + documented `string[]` shape rather than leaking the raw tokenscript structure." + [resolved-value] + (let [v (ts/tokenscript-symbols->penpot-unit resolved-value)] + (cond + (nil? v) nil + (sequential? v) (clj->js v) + :else #js [v]))) + (defn- get-resolved-value [token tokens-tree] (let [resolved-tokens (ts/resolve-tokens tokens-tree) - resolved-value (-> resolved-tokens - (dm/get-in [(:name token) :resolved-value]) - (ts/tokenscript-symbols->penpot-unit))] - resolved-value)) + resolved-value (dm/get-in resolved-tokens [(:name token) :resolved-value])] + (cond + (= :font-family (:type token)) + ;; A fontFamilies token resolves to a list of families; expose it as the + ;; documented `string[]` rather than the raw tokenscript list symbol. + (font-families-resolved-value->js resolved-value) + + (= :typography (:type token)) + ;; A typography token resolves to a composite; expose it as the documented + ;; `TokenTypographyValue[]` rather than the raw tokenscript structure. + (typography-resolved-value->js + (ts/tokenscript-symbols->penpot-unit resolved-value)) + + (= :shadow (:type token)) + ;; A shadow token resolves to a list of composites whose entries the + ;; tokenscript unit conversion leaves as raw symbols; expose them as the + ;; documented `TokenShadowValue[]`. + (shadow-resolved-value->js + (ts/tokenscript-symbols->penpot-unit resolved-value)) + + :else + (ts/tokenscript-symbols->penpot-unit resolved-value)))) (defn token-proxy? [p] (obj/type-of? p "TokenProxy")) @@ -150,11 +220,21 @@ (fn [_] (let [token (u/locate-token file-id set-id id)] (json/->js (:value token)))) - :schema (let [token (u/locate-token file-id set-id id)] - (cfo/make-token-value-schema (:type token))) + :schema (let [token (u/locate-token file-id set-id id) + base (cfo/make-token-value-schema (:type token))] + ;; plugin-types declares the fontFamilies value as + ;; `string | string[]`, but the core schema only accepts a + ;; vector/ref; also accept a plain string (normalized in :set). + (if (= :font-family (:type token)) + [:or :string base] + base)) :set (fn [_ value] - (st/emit! (dwtl/update-token set-id id {:value value})))} + (let [token (u/locate-token file-id set-id id) + value (cond-> value + (= :font-family (:type token)) + (ctob/convert-dtcg-font-family))] + (st/emit! (dwtl/update-token set-id id {:value value}))))} :resolvedValue {:this true @@ -361,7 +441,10 @@ :duplicate (fn [] - (st/emit! (dwtl/duplicate-token-set id))) + (let [id-ref (atom nil)] + (st/emit! (dwtl/duplicate-token-set id {:id-ref id-ref})) + (when (some? @id-ref) + (token-set-proxy plugin-id file-id @id-ref)))) :remove (fn [] @@ -460,7 +543,7 @@ ;; Guard against nil to prevent `enable-set` from conj'ing nil ;; into the theme's :sets — which would send `:sets #{nil}` to the ;; backend and crash the workspace. - (let [set-name (obj/get token-set :name) + (let [set-name (obj/get token-set "name") theme (u/locate-token-theme file-id id)] (when (and (some? set-name) (some? theme)) (st/emit! (dwtl/update-token-theme id (ctob/enable-set theme set-name))))))} @@ -470,7 +553,7 @@ :schema [:tuple [:fn token-set-proxy?]] :fn (fn [token-set] ;; Same nil guard as addSet — see comment above. - (let [set-name (obj/get token-set :name) + (let [set-name (obj/get token-set "name") theme (u/locate-token-theme file-id id)] (when (and (some? set-name) (some? theme)) (st/emit! (dwtl/update-token-theme id (ctob/disable-set theme set-name))))))} diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index f2a344fc25..744f0b1830 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -1074,66 +1074,75 @@ (defn set-grid-layout-rows [entries] - (let [size (mem/get-alloc-size entries GRID-LAYOUT-ROW-U8-SIZE) - offset (mem/alloc size) - dview (mem/get-data-view)] + ;; Only allocate when there are entries; an empty list would alloc 0 bytes. + ;; The wasm side reads an empty buffer as zero rows. + (when (seq entries) + (let [size (mem/get-alloc-size entries GRID-LAYOUT-ROW-U8-SIZE) + offset (mem/alloc size) + dview (mem/get-data-view)] - (reduce (fn [offset {:keys [type value]}] - (-> offset - (mem/write-u8 dview (sr/translate-grid-track-type type)) - (+ 3) ;; padding - (mem/write-f32 dview value) - (mem/assert-written offset GRID-LAYOUT-ROW-U8-SIZE))) + (reduce (fn [offset {:keys [type value]}] + (-> offset + (mem/write-u8 dview (sr/translate-grid-track-type type)) + (+ 3) ;; padding + (mem/write-f32 dview value) + (mem/assert-written offset GRID-LAYOUT-ROW-U8-SIZE))) - offset - entries) + offset + entries))) - (h/call wasm/internal-module "_set_grid_rows"))) + (h/call wasm/internal-module "_set_grid_rows")) (defn set-grid-layout-columns [entries] - (let [size (mem/get-alloc-size entries GRID-LAYOUT-COLUMN-U8-SIZE) - offset (mem/alloc size) - dview (mem/get-data-view)] + ;; Only allocate when there are entries; an empty list would alloc 0 bytes. + ;; The wasm side reads an empty buffer as zero columns. + (when (seq entries) + (let [size (mem/get-alloc-size entries GRID-LAYOUT-COLUMN-U8-SIZE) + offset (mem/alloc size) + dview (mem/get-data-view)] - (reduce (fn [offset {:keys [type value]}] - (-> offset - (mem/write-u8 dview (sr/translate-grid-track-type type)) - (+ 3) ;; padding - (mem/write-f32 dview value) - (mem/assert-written offset GRID-LAYOUT-COLUMN-U8-SIZE))) - offset - entries) + (reduce (fn [offset {:keys [type value]}] + (-> offset + (mem/write-u8 dview (sr/translate-grid-track-type type)) + (+ 3) ;; padding + (mem/write-f32 dview value) + (mem/assert-written offset GRID-LAYOUT-COLUMN-U8-SIZE))) + offset + entries))) - (h/call wasm/internal-module "_set_grid_columns"))) + (h/call wasm/internal-module "_set_grid_columns")) (defn set-grid-layout-cells [cells] - (let [size (mem/get-alloc-size cells GRID-LAYOUT-CELL-U8-SIZE) - offset (mem/alloc size) - dview (mem/get-data-view)] + ;; Only allocate when there are cells; an empty collection would alloc 0 + ;; bytes. The wasm side reads an empty buffer as zero cells. + (when (seq cells) + (let [size (mem/get-alloc-size cells GRID-LAYOUT-CELL-U8-SIZE) + offset (mem/alloc size) + dview (mem/get-data-view)] - (reduce-kv (fn [offset _ cell] - (let [shape-id (-> (get cell :shapes) first)] - (-> offset - (mem/write-i32 dview (get cell :row)) - (mem/write-i32 dview (get cell :row-span)) - (mem/write-i32 dview (get cell :column)) - (mem/write-i32 dview (get cell :column-span)) + (reduce-kv (fn [offset _ cell] + (let [shape-id (-> (get cell :shapes) first)] + (-> offset + (mem/write-i32 dview (get cell :row)) + (mem/write-i32 dview (get cell :row-span)) + (mem/write-i32 dview (get cell :column)) + (mem/write-i32 dview (get cell :column-span)) - (mem/write-u8 dview (sr/translate-align-self (get cell :align-self))) - (mem/write-u8 dview (sr/translate-justify-self (get cell :justify-self))) + (mem/write-u8 dview (sr/translate-align-self (get cell :align-self))) + (mem/write-u8 dview (sr/translate-justify-self (get cell :justify-self))) - ;; padding - (+ 2) + ;; padding + (+ 2) - (mem/write-uuid dview (d/nilv shape-id uuid/zero)) - (mem/assert-written offset GRID-LAYOUT-CELL-U8-SIZE)))) + (mem/write-uuid dview (d/nilv shape-id uuid/zero)) + (mem/assert-written offset GRID-LAYOUT-CELL-U8-SIZE)))) - offset - cells) + offset + cells))) - (h/call wasm/internal-module "_set_grid_cells"))) + (h/call wasm/internal-module "_set_grid_cells")) (defn set-grid-layout [shape] diff --git a/frontend/test/frontend_tests/helpers/mock.cljc b/frontend/test/frontend_tests/helpers/mock.cljc index fce14876a3..bed04f506a 100644 --- a/frontend/test/frontend_tests/helpers/mock.cljc +++ b/frontend/test/frontend_tests/helpers/mock.cljc @@ -126,6 +126,39 @@ [_ms] (rx/of :immediate)) + ;; Static-dispatch-safe stubs + ;; ═══════════════════════════════════════════════════════════════ + ;; + ;; The `:esm` test build compiles calls to a *multi-arity* var as + ;; `f.cljs$core$IFn$_invoke$arity$N(...)`. A plain single-arity `fn` + ;; (including `identity`) does not expose that property, so using one + ;; to redefine such a var throws "arity$N is not a function". Multi-arity + ;; fns do expose the property, hence the helpers below. + + (defn noop + "Multi-arity no-op. Use to stub static-dispatched multi-arity vars + such as `st/emit!` (replacing `identity`, which is single-arity)." + ([] nil) + ([_] nil) + ([_ _] nil) + ([_ _ _] nil) + ([_ _ _ _] nil) + ([_ _ _ _ & _] nil)) + + (defn stub + "Wraps `f` in a multi-arity fn (arities 0-6) delegating to `f`, so the + result exposes `cljs$core$IFn$_invoke$arity$N`. Required when replacing + a multi-arity var in a `with-redefs`/`set!` mock with a capturing fn." + [f] + (fn + ([] (f)) + ([a] (f a)) + ([a b] (f a b)) + ([a b c] (f a b c)) + ([a b c d] (f a b c d)) + ([a b c d e] (f a b c d e)) + ([a b c d e g] (f a b c d e g)))) + ;; Lifecycle ;; ═══════════════════════════════════════════════════════════════ diff --git a/frontend/test/frontend_tests/plugins/comments_test.cljs b/frontend/test/frontend_tests/plugins/comments_test.cljs new file mode 100644 index 0000000000..ee19153a73 --- /dev/null +++ b/frontend/test/frontend_tests/plugins/comments_test.cljs @@ -0,0 +1,60 @@ +;; 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 Sucursal en España SL + +(ns frontend-tests.plugins.comments-test + (:require + [app.main.data.comments :as dc] + [app.main.store :as st] + [app.plugins.comments :as comments] + [app.plugins.page :as page] + [app.plugins.register :as r] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock])) + +(def ^:private plugin-id "00000000-0000-0000-0000-000000000000") + +(t/deftest comment-thread-remove-allows-the-owner + (let [owner-id (random-uuid) + file-id (random-uuid) + page-id (random-uuid) + thread-id (random-uuid) + emitted (atom nil) + thread (comments/comment-thread-proxy + plugin-id + file-id + page-id + {:id thread-id :owner-id owner-id})] + (set! st/state (atom {:profile {:id owner-id}})) + (with-redefs [r/check-permission (constantly true) + dc/delete-comment-thread-on-workspace + (mock/stub (fn [params callback] + (callback) + [:delete-thread params])) + st/emit! (mock/stub (fn [event] (reset! emitted event)))] + (let [result (.remove thread)] + (t/is (instance? js/Promise result)) + (t/is (= [:delete-thread {:id thread-id}] @emitted)))))) + +(t/deftest page-remove-comment-thread-emits-delete-event + (let [file-id (random-uuid) + page-id (random-uuid) + thread-id (random-uuid) + emitted (atom nil) + page (page/page-proxy plugin-id file-id page-id) + thread (comments/comment-thread-proxy + plugin-id + file-id + page-id + {:id thread-id :owner-id (random-uuid)})] + (with-redefs [r/check-permission (constantly true) + dc/delete-comment-thread-on-workspace + (mock/stub (fn [params callback] + (callback) + [:delete-thread params])) + st/emit! (mock/stub (fn [event] (reset! emitted event)))] + (let [result (.removeCommentThread page thread)] + (t/is (instance? js/Promise result)) + (t/is (= [:delete-thread {:id thread-id}] @emitted)))))) diff --git a/frontend/test/frontend_tests/plugins/file_test.cljs b/frontend/test/frontend_tests/plugins/file_test.cljs new file mode 100644 index 0000000000..8d7c6b0d12 --- /dev/null +++ b/frontend/test/frontend_tests/plugins/file_test.cljs @@ -0,0 +1,21 @@ +;; 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 Sucursal en España SL + +(ns frontend-tests.plugins.file-test + (:require + [app.plugins.file :as file] + [cljs.test :as t :include-macros true])) + +(t/deftest file-version-created-at-returns-stored-date + (let [created-at (js/Date.) + version (file/file-version-proxy + "00000000-0000-0000-0000-000000000000" + (random-uuid) + {} + {:id (random-uuid) + :label "Version" + :created-at created-at})] + (t/is (identical? created-at (.-createdAt version))))) diff --git a/frontend/test/frontend_tests/plugins/format_test.cljs b/frontend/test/frontend_tests/plugins/format_test.cljs index 2bc525b224..fd52f1f3fe 100644 --- a/frontend/test/frontend_tests/plugins/format_test.cljs +++ b/frontend/test/frontend_tests/plugins/format_test.cljs @@ -38,3 +38,17 @@ (format/format-frame-guides nil) (format/format-tracks nil) (format/format-path-content nil))) + +(t/deftest test-format-color-result-uses-shapes-info-key + (let [shape-id (random-uuid) + result (format/format-color-result + [{:color "#fabada"} + [{:prop :fill :shape-id shape-id :index 0}]]) + info (aget result "shapesInfo")] + (t/is (array? info)) + (t/is (nil? (aget result "shapesColors"))) + (t/is (= "fill" (aget (aget info 0) "property"))) + (t/is (= (str shape-id) (aget (aget info 0) "shapeId"))))) + +(t/deftest test-shape-type-reports-boolean + (t/is (= "boolean" (format/shape-type :bool)))) diff --git a/frontend/test/frontend_tests/plugins/grid_test.cljs b/frontend/test/frontend_tests/plugins/grid_test.cljs new file mode 100644 index 0000000000..035153ccde --- /dev/null +++ b/frontend/test/frontend_tests/plugins/grid_test.cljs @@ -0,0 +1,49 @@ +;; 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 Sucursal en España SL + +(ns frontend-tests.plugins.grid-test + (:require + [app.common.test-helpers.files :as cthf] + [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.wasm :as thw] + [potok.v2.core :as ptk])) + +(def ^:private plugin-id "00000000-0000-0000-0000-000000000000") + +(defn- setup-grid [] + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)) + _ (set! st/state store) + _ (set! st/stream (ptk/input-stream store)) + context (api/create-context plugin-id) + board (.createBoard ^js context) + grid (.addGridLayout ^js board)] + {:store store :context context :board board :grid grid})) + +(t/deftest add-column-at-index-accepts-fixed-track-type + (thw/with-wasm-mocks* + (fn [] + (let [{:keys [^js grid]} (setup-grid)] + (.addColumn grid "flex" 1) + (.addColumnAtIndex grid 0 "fixed" 100) + (t/is (= "fixed" (aget (aget (.-columns grid) 0) "type"))) + (t/is (= 100 (aget (aget (.-columns grid) 0) "value"))))))) + +(t/deftest grid-track-methods-reject-out-of-range-indices + (thw/with-wasm-mocks* + (fn [] + (let [{:keys [store ^js grid]} (setup-grid)] + (swap! store assoc-in [:plugins :flags plugin-id :throw-validation-errors] true) + (.addRow grid "flex" 1) + (.addColumn grid "flex" 1) + (t/is (thrown? js/Error (.addRowAtIndex grid -1 "fixed" 10))) + (t/is (thrown? js/Error (.addColumnAtIndex grid 2 "fixed" 10))) + (t/is (thrown? js/Error (.setRow grid 1 "fixed" 10))) + (t/is (thrown? js/Error (.setColumn grid 1 "fixed" 10))) + (t/is (thrown? js/Error (.removeRow grid 1))) + (t/is (thrown? js/Error (.removeColumn grid 1))))))) diff --git a/frontend/test/frontend_tests/plugins/library_test.cljs b/frontend/test/frontend_tests/plugins/library_test.cljs new file mode 100644 index 0000000000..47d5869b1a --- /dev/null +++ b/frontend/test/frontend_tests/plugins/library_test.cljs @@ -0,0 +1,95 @@ +;; 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 Sucursal en España SL + +(ns frontend-tests.plugins.library-test + (:require + [app.main.data.workspace.libraries :as dwl] + [app.main.data.workspace.texts :as dwt] + [app.main.store :as st] + [app.plugins.library :as library] + [app.plugins.register :as r] + [app.plugins.text :as text] + [app.plugins.utils :as u] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock])) + +(def ^:private plugin-id "00000000-0000-0000-0000-000000000000") + +(t/deftest library-asset-proxies-expose-library-id + (let [file-id (random-uuid) + id (random-uuid)] + (t/is (= (str file-id) (.-libraryId (library/lib-color-proxy plugin-id file-id id)))) + (t/is (= (str file-id) (.-libraryId (library/lib-typography-proxy plugin-id file-id id)))) + (t/is (= (str file-id) (.-libraryId (library/lib-component-proxy plugin-id file-id id)))))) + +(t/deftest typography-apply-to-text-range-uses-hidden-range-bounds + (let [file-id (random-uuid) + page-id (random-uuid) + shape-id (random-uuid) + typography-id (random-uuid) + typography (library/lib-typography-proxy plugin-id file-id typography-id) + text-range (text/text-range-proxy plugin-id file-id page-id shape-id 2 5) + captured (atom nil)] + (with-redefs [r/check-permission (constantly true) + u/page-active? (constantly true) + u/locate-library-typography + (constantly {:id typography-id + :name "Body" + :font-size "14"}) + dwt/update-text-range + (fn [shape-id start end attrs] + (reset! captured {:shape-id shape-id + :start start + :end end + :attrs attrs}) + :update-text-range) + st/emit! mock/noop] + (.applyToTextRange typography text-range) + (t/is (= shape-id (:shape-id @captured))) + (t/is (= 2 (:start @captured))) + (t/is (= 5 (:end @captured))) + (t/is (= file-id (get-in @captured [:attrs :typography-ref-file]))) + (t/is (= typography-id (get-in @captured [:attrs :typography-ref-id])))))) + +(t/deftest library-color-gradient-and-image-clear-exclusive-representations + (let [file-id (random-uuid) + color-id (random-uuid) + proxy (library/lib-color-proxy plugin-id file-id color-id) + captured (atom nil) + base {:id color-id + :name "Brand" + :color "#fabada" + :opacity 1 + :gradient {:type :linear} + :image {:id (random-uuid) :width 1 :height 1}}] + (with-redefs [r/check-permission (constantly true) + u/proxy->library-color (constantly base) + dwl/update-color-data (fn [color file-id] + (reset! captured {:color color :file-id file-id}) + :update-color-data) + st/emit! mock/noop] + (set! (.-gradient proxy) + #js {:type "linear" + :startX 0 + :startY 0 + :endX 1 + :endY 1 + :width 1 + :stops #js [#js {:color "#000000" + :opacity 1 + :offset 0}]}) + (t/is (contains? (:color @captured) :gradient)) + (t/is (not (contains? (:color @captured) :color))) + (t/is (not (contains? (:color @captured) :image))) + + (set! (.-image proxy) + #js {:id (str (random-uuid)) + :width 10 + :height 20 + :mtype "image/png"}) + (t/is (contains? (:color @captured) :image)) + (t/is (not (contains? (:color @captured) :color))) + (t/is (not (contains? (:color @captured) :gradient)))))) diff --git a/frontend/test/frontend_tests/plugins/local_storage_test.cljs b/frontend/test/frontend_tests/plugins/local_storage_test.cljs new file mode 100644 index 0000000000..f1f5117ed2 --- /dev/null +++ b/frontend/test/frontend_tests/plugins/local_storage_test.cljs @@ -0,0 +1,28 @@ +;; 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 Sucursal en España SL + +(ns frontend-tests.plugins.local-storage-test + (:require + [app.plugins.local-storage :as storage] + [app.plugins.register :as r] + [cljs.test :as t :include-macros true])) + +(t/deftest remove-item-removes-the-prefixed-key + (let [data (atom {}) + fake #js {} + plugin-id "plugin-a"] + (set! (.-getItem fake) (fn [key] (get @data key))) + (set! (.-setItem fake) (fn [key value] (swap! data assoc key value))) + (set! (.-removeItem fake) (fn [key] (swap! data dissoc key))) + (set! (.-keys fake) (fn [] (to-array (keys @data)))) + (with-redefs [r/check-permission (constantly true) + storage/local-storage fake] + (let [proxy (storage/local-storage-proxy plugin-id)] + (.setItem proxy "key" "value") + (t/is (= "value" (.getItem proxy "key"))) + (.removeItem proxy "key") + (t/is (nil? (.getItem proxy "key"))) + (t/is (empty? @data)))))) diff --git a/frontend/test/frontend_tests/plugins/parser_test.cljs b/frontend/test/frontend_tests/plugins/parser_test.cljs index 4b257b2023..44abb8ebbd 100644 --- a/frontend/test/frontend_tests/plugins/parser_test.cljs +++ b/frontend/test/frontend_tests/plugins/parser_test.cljs @@ -31,3 +31,33 @@ (t/is (gpt/point? result)) (t/is (= 0 (:x result))) (t/is (= 0 (:y result)))))) + +(t/deftest test-parse-overlay-action-defaults-manual-position + (let [destination #js {"$id" (random-uuid)} + action (parser/parse-action + #js {:type "open-overlay" + :destination destination + :position "center"})] + (t/is (= :open-overlay (:action-type action))) + (t/is (= :center (:overlay-pos-type action))) + (t/is (gpt/point? (:overlay-position action))) + (t/is (= 0 (:x (:overlay-position action)))) + (t/is (= 0 (:y (:overlay-position action)))))) + +(t/deftest test-parse-frame-guide-calls-guide-parser + (let [column (parser/parse-frame-guide + #js {:type "column" + :display true + :params #js {:type "stretch" + :size 12}}) + row (parser/parse-frame-guide + #js {:type "row" + :display false + :params #js {:type "center" + :margin 4}})] + (t/is (= :column (:type column))) + (t/is (= true (:display column))) + (t/is (= :stretch (get-in column [:params :type]))) + (t/is (= :row (:type row))) + (t/is (= false (:display row))) + (t/is (= :center (get-in row [:params :type]))))) diff --git a/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs b/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs new file mode 100644 index 0000000000..bf9e77f325 --- /dev/null +++ b/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs @@ -0,0 +1,202 @@ +;; 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 Sucursal en España SL + +(ns frontend-tests.plugins.shape-bugfixes-test + (:require + [app.common.data :as d] + [app.common.test-helpers.files :as cthf] + [app.common.types.component :as ctk] + [app.common.uuid :as uuid] + [app.main.data.workspace :as dw] + [app.main.data.workspace.variants :as dwv] + [app.main.store :as st] + [app.plugins.api :as api] + [app.plugins.public-utils :as public-utils] + [app.plugins.shape :as shape] + [app.plugins.utils :as u] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock] + [frontend-tests.helpers.state :as ths] + [frontend-tests.helpers.wasm :as thw])) + +(def ^:private plugin-id "00000000-0000-0000-0000-000000000000") + +;; --------------------------------------------------------------------------- +;; Helpers +;; --------------------------------------------------------------------------- + +(defn- child-shapes + "Ordered child shape ids of `board`, read back from the live store + (the observable result of a z-order operation)." + [store ^js context ^js board] + (let [file-id (aget (. context -currentFile) "$id") + page-id (aget (. context -currentPage) "$id") + board-id (aget board "$id")] + (get-in @store [:files file-id :data :pages-index page-id + :objects board-id :shapes]))) + +(defn- page-guides + "The guides map of the current page, read back from the live store." + [store ^js context] + (let [file-id (aget (. context -currentFile) "$id") + page-id (aget (. context -currentPage) "$id")] + (get-in @store [:files file-id :data :pages-index page-id :guides]))) + +;; --------------------------------------------------------------------------- +;; Tests +;; --------------------------------------------------------------------------- + +(t/deftest trigger-setter-updates-the-interaction-event-type + ;; Regression: the `trigger` setter must update the interaction of the + ;; located shape. Asserting on the observable interaction (read back through + ;; the proxy from the live store) covers that without coupling to which + ;; internal action gets emitted. + (thw/with-wasm-mocks* + (fn [] + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)) + ^js context (api/create-context plugin-id) + _ (set! st/state store) + ^js board (.createBoard context)] + (.addInteraction board "click" #js {:type "open-url" :url "https://example.com"}) + (let [^js interaction (aget (.-interactions board) 0)] + (t/is (= "click" (.-trigger interaction)) + "the interaction starts with the click trigger") + (set! (.-trigger interaction) "mouse-over") + (t/is (= "mouse-over" (.-trigger interaction)) + "the trigger setter updates the interaction event-type")))))) + +(t/deftest center-shapes-empty-input-returns-nil + (t/is (nil? (public-utils/centerShapes #js [])))) + +(t/deftest background-blur-reads-background-blur-key + (let [file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + blur-id (uuid/next) + proxy (shape/shape-proxy plugin-id file-id page-id shape-id)] + (with-redefs [u/proxy->shape (constantly {:background-blur {:id blur-id + :value 12 + :hidden false}})] + (let [blur (.-backgroundBlur proxy)] + (t/is (= (str blur-id) (aget blur "id"))) + (t/is (= 12 (aget blur "value"))))))) + +(t/deftest flatten-returns-proxies-for-converted-shapes + ;; `convert-selected-to-path` runs the WASM boolean/path pipeline, so this + ;; test stays at the proxy boundary: it verifies `flatten` forwards the + ;; selected ids to the conversion and wraps the result back into proxies. + (let [file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + input (shape/shape-proxy plugin-id file-id page-id shape-id) + emitted (atom nil) + context (api/create-context plugin-id)] + (set! st/state (atom {:current-file-id file-id + :current-page-id page-id})) + (with-redefs [dw/convert-selected-to-path + (mock/stub (fn [ids] + (reset! emitted ids) + :convert-selected-to-path)) + st/emit! mock/noop + shape/shape-proxy + (mock/stub (fn [_plugin file page id] + #js {"$file" file "$page" page "$id" id}))] + (let [result (.flatten context #js [input])] + (t/is (= #{shape-id} @emitted)) + (t/is (array? result)) + (t/is (= shape-id (aget result 0 "$id"))) + (t/is (= file-id (aget result 0 "$file"))) + (t/is (= page-id (aget result 0 "$page"))))))) + +(t/deftest z-order-methods-reorder-the-shape-within-its-parent + ;; Asserts the observable child order in the parent after each z-order + ;; method, instead of merely checking which location keyword was emitted. + ;; The assertions are independent of the parent's `:shapes` ordering + ;; convention: a reorder is verified by relative movement and extremes. + (thw/with-wasm-mocks* + (fn [] + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)) + ^js context (api/create-context plugin-id) + _ (set! st/state store) + ^js board (.createBoard context) + children (mapv (fn [_] (.createRectangle context)) (range 4)) + ids (mapv #(aget % "$id") children) + order #(child-shapes store context board)] + (doseq [^js c children] (.appendChild board c)) + + ;; Operate on a shape that is currently interior (so both a forward + ;; and a backward step are observable). + (let [mid-id (nth (order) 1) + ^js mid (nth children (d/index-of ids mid-id))] + + (t/testing "bringForward and sendBackward move in opposite directions" + (let [i0 (d/index-of (order) mid-id) + _ (.bringForward mid) + i1 (d/index-of (order) mid-id) + _ (.sendBackward mid) + i2 (d/index-of (order) mid-id)] + (t/is (not= i0 i1) "bringForward changes the order") + (t/is (not= i1 i2) "sendBackward changes the order") + (t/is (= (pos? (- i1 i0)) (neg? (- i2 i1))) + "the two steps move the shape in opposite directions"))) + + (t/testing "bringToFront and sendToBack move to opposite extremes" + (let [n (count (order)) + _ (.bringToFront mid) + p1 (d/index-of (order) mid-id) + _ (.sendToBack mid) + p2 (d/index-of (order) mid-id)] + (t/is (contains? #{0 (dec n)} p1) "bringToFront moves to an extreme") + (t/is (contains? #{0 (dec n)} p2) "sendToBack moves to an extreme") + (t/is (not= p1 p2) "front and back are opposite extremes")))))))) + +(t/deftest is-variant-container-predicate-returns-boolean + (t/is (false? (ctk/is-variant-container? {}))) + (t/is (true? (ctk/is-variant-container? {:is-variant-container true})))) + +(t/deftest combine-as-variants-uses-the-passed-component-ids + ;; `combine-as-variants` needs real main components and the variant pipeline, + ;; so this stays at the proxy boundary and verifies the component ids that + ;; the head proxy collects from its argument before delegating. + (let [file-id (uuid/next) + page-id (uuid/next) + head-id (uuid/next) + other-id (uuid/next) + proxy (shape/shape-proxy plugin-id file-id page-id head-id) + captured (atom nil)] + (with-redefs [u/locate-shape (fn [_file _page id] {:id id :component-id id}) + u/locate-library-component (constantly {:id (uuid/next)}) + ctk/is-variant? (constantly false) + dwv/combine-as-variants + (fn [ids opts] + (reset! captured {:ids ids :opts opts}) + ;; return value flows through `se/add-event` (which + ;; calls `with-meta`), so it must support metadata + {:event :combine-as-variants}) + st/emit! mock/noop + shape/shape-proxy (mock/stub (fn [& _] #js {}))] + (.combineAsVariants proxy #js [(str other-id)]) + (t/is (= #{head-id other-id} (:ids @captured)))))) + +(t/deftest remove-ruler-guide-deletes-the-guide-from-the-page + ;; Adds a real ruler guide through the API and asserts it is gone from the + ;; page guides after removeRulerGuide, rather than checking the removal call. + (thw/with-wasm-mocks* + (fn [] + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)) + ^js context (api/create-context plugin-id) + _ (set! st/state store) + ^js board (.createBoard context) + ^js guide (.addRulerGuide board "horizontal" 10)] + (t/is (= 1 (count (page-guides store context))) + "addRulerGuide stores one guide on the page") + (.removeRulerGuide board guide) + (t/is (empty? (page-guides store context)) + "removeRulerGuide deletes the guide from the page"))))) + +(t/deftest group-empty-input-returns-nil + (let [context (api/create-context plugin-id)] + (t/is (nil? (.group context #js []))))) diff --git a/frontend/test/frontend_tests/plugins/text_test.cljs b/frontend/test/frontend_tests/plugins/text_test.cljs new file mode 100644 index 0000000000..9845e0c706 --- /dev/null +++ b/frontend/test/frontend_tests/plugins/text_test.cljs @@ -0,0 +1,87 @@ +;; 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.plugins.text-test + (:require + [app.main.data.workspace.texts :as dwt] + [app.main.store :as st] + [app.plugins.fonts :as fonts] + [app.plugins.format :as format] + [app.plugins.register :as r] + [app.plugins.shape :as shape] + [app.plugins.text :as plugins.text] + [app.plugins.utils :as u] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock])) + +(def ^:private plugin-id "00000000-0000-0000-0000-000000000000") + +(t/deftest font-apply-to-text-uses-font-id-not-shape-id + (let [file-id (random-uuid) + page-id (random-uuid) + shape-id (random-uuid) + font (fonts/font-proxy + plugin-id + {:id "font-id" + :family "Inter" + :name "Inter" + :variants [{:id "regular" + :name "Regular" + :weight "400" + :style "normal"}]}) + text (shape/shape-proxy plugin-id file-id page-id shape-id) + captured (atom nil)] + (with-redefs [r/check-permission (constantly true) + u/page-active? (constantly true) + dwt/update-attrs + (fn [id attrs] + (reset! captured {:id id :attrs attrs}) + :update-attrs) + st/emit! mock/noop] + (.applyToText font text nil) + (t/is (= shape-id (:id @captured))) + (t/is (= "font-id" (get-in @captured [:attrs :font-id])))))) + +(t/deftest font-apply-to-range-uses-hidden-range-bounds + (let [file-id (random-uuid) + page-id (random-uuid) + shape-id (random-uuid) + font (fonts/font-proxy + plugin-id + {:id "font-id" + :family "Inter" + :name "Inter" + :variants [{:id "regular" + :name "Regular" + :weight "400" + :style "normal"}]}) + range (plugins.text/text-range-proxy plugin-id file-id page-id shape-id 1 4) + captured (atom nil)] + (with-redefs [r/check-permission (constantly true) + u/page-active? (constantly true) + dwt/update-text-range + (fn [id start end attrs] + (reset! captured {:id id + :start start + :end end + :attrs attrs}) + :update-text-range) + st/emit! mock/noop] + (.applyToRange font range nil) + (t/is (= shape-id (:id @captured))) + (t/is (= 1 (:start @captured))) + (t/is (= 4 (:end @captured))) + (t/is (= "font-id" (get-in @captured [:attrs :font-id])))))) + +(t/deftest text-range-shape-returns-a-shape-proxy + (let [file-id (random-uuid) + page-id (random-uuid) + shape-id (random-uuid) + range (plugins.text/text-range-proxy plugin-id file-id page-id shape-id 0 3)] + (with-redefs [format/shape-proxy shape/shape-proxy] + (let [text-shape (.-shape range)] + (t/is (shape/shape-proxy? text-shape)) + (t/is (= shape-id (aget text-shape "$id"))))))) diff --git a/frontend/test/frontend_tests/plugins/tokens_test.cljs b/frontend/test/frontend_tests/plugins/tokens_test.cljs index c45789b6ca..f95ba1d811 100644 --- a/frontend/test/frontend_tests/plugins/tokens_test.cljs +++ b/frontend/test/frontend_tests/plugins/tokens_test.cljs @@ -12,15 +12,20 @@ [app.common.test-helpers.tokens :as ctht] [app.common.types.tokens-lib :as ctob] [app.main.data.tokenscript :as ts] + [app.main.data.workspace.tokens.library-edit :as dwtl] [app.main.store :as st] [app.plugins.api :as api] [app.plugins.tokens :as ptok] + [app.plugins.utils :as u] [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock] [frontend-tests.helpers.state :as ths] [potok.v2.core :as ptk])) (t/use-fixtures :each {:before cthi/reset-idmap!}) +(def ^:private get-resolved-value @#'ptok/get-resolved-value) + ;; Regression coverage for issue #9162. ;; ;; Plugin code calling `shape.applyToken(token, ["fill"])` or @@ -226,3 +231,110 @@ {:keys [errors resolved-value]} (get resolved (:name token))] (t/is (nil? resolved-value)) (t/is (seq errors)))) + +(t/deftest token-set-duplicate-returns-the-duplicated-set + (let [file-id (cthi/new-id! :file) + set-id (cthi/new-id! :set) + dup-id (cthi/new-id! :dup) + proxy (ptok/token-set-proxy "plugin-id" file-id set-id)] + (with-redefs [dwtl/duplicate-token-set + (mock/stub (fn [id {:keys [id-ref]}] + (t/is (= set-id id)) + (reset! id-ref dup-id) + :duplicate-token-set)) + st/emit! mock/noop] + (let [dup (.duplicate proxy)] + (t/is (ptok/token-set-proxy? dup)) + (t/is (= (str dup-id) (.-id dup))))))) + +(t/deftest theme-add-set-and-remove-set-use-the-set-name + (let [file-id (cthi/new-id! :file) + theme-id (cthi/new-id! :theme) + set-id (cthi/new-id! :set) + set (ptok/token-set-proxy "plugin-id" file-id set-id "Primitives") + theme (ptok/token-theme-proxy "plugin-id" file-id theme-id) + captured (atom [])] + (with-redefs [u/locate-token-theme + (fn [_file _theme] + (ctob/make-token-theme :id theme-id + :name "Theme" + :sets #{"Primitives"})) + dwtl/update-token-theme + (fn [id theme] + (swap! captured conj {:id id :theme theme}) + :update-token-theme) + st/emit! identity] + (.addSet theme set) + (.removeSet theme set) + (t/is (= [theme-id theme-id] (mapv :id @captured))) + (t/is (contains? (-> @captured first :theme :sets) "Primitives")) + (t/is (not (contains? (-> @captured second :theme :sets) "Primitives")))))) + +(t/deftest font-family-token-value-accepts-a-string + (let [file-id (cthi/new-id! :file) + set-id (cthi/new-id! :set) + token-id (cthi/new-id! :token) + captured (atom nil)] + (with-redefs [u/locate-token (constantly {:id token-id + :name "font.primary" + :type :font-family + :value ["Inter"]}) + dwtl/update-token (mock/stub (fn [set-id token-id attrs] + (reset! captured {:set-id set-id + :token-id token-id + :attrs attrs}) + :update-token)) + st/emit! mock/noop] + (let [token (ptok/token-proxy "plugin-id" file-id set-id token-id)] + (set! (.-value token) "Inter, Arial") + (t/is (= set-id (:set-id @captured))) + (t/is (= token-id (:token-id @captured))) + (t/is (= ["Inter" "Arial"] (get-in @captured [:attrs :value]))))))) + +(t/deftest typography-token-resolved-value-is-plugin-array-shape + (let [token (ctob/make-token + {:name "type.body" + :type :typography + :value {:font-family ["Inter" "Arial"] + :font-size "16px" + :font-weight "600" + :line-height "20px" + :letter-spacing "1" + :text-case "uppercase" + :text-decoration "underline"}}) + result (get-resolved-value token {(:name token) token}) + entry (aget result 0)] + (t/is (array? result)) + (t/is (= ["Inter" "Arial"] (vec (aget entry "fontFamilies")))) + (t/is (= 16 (aget entry "fontSizes"))) + (t/is (= "600" (aget entry "fontWeights"))) + (t/is (= 20 (aget entry "lineHeight"))) + (t/is (= "uppercase" (aget entry "textCase"))) + (t/is (= "underline" (aget entry "textDecoration"))))) + +(t/deftest shadow-token-resolved-value-is-plugin-array-shape + (let [token (ctob/make-token + {:name "shadow.card" + :type :shadow + :value [{:offset-x "1px" + :offset-y "2px" + :blur "3px" + :spread "4px" + :color "#000000" + :inset false}]}) + result (get-resolved-value token {(:name token) token}) + entry (aget result 0)] + (t/is (array? result)) + (t/is (= 1 (aget entry "offsetX"))) + (t/is (= 2 (aget entry "offsetY"))) + (t/is (= 3 (aget entry "blur"))) + (t/is (= 4 (aget entry "spread"))))) + +(t/deftest font-family-token-resolved-value-is-string-array + (let [token (ctob/make-token + {:name "font.primary" + :type :font-family + :value ["Inter" "Arial"]}) + result (get-resolved-value token {(:name token) token})] + (t/is (array? result)) + (t/is (= ["Inter" "Arial"] (vec result))))) diff --git a/frontend/test/frontend_tests/render_wasm/process_objects_test.cljs b/frontend/test/frontend_tests/render_wasm/process_objects_test.cljs index e20aa2e1bc..740029a3ca 100644 --- a/frontend/test/frontend_tests/render_wasm/process_objects_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/process_objects_test.cljs @@ -16,6 +16,8 @@ in :fetching) and are permanently stuck with fallback-font layout metrics." (:require [app.render-wasm.api :as wasm.api] + [app.render-wasm.mem :as mem] + [app.render-wasm.wasm :as wasm] [beicon.v2.core :as rx] [cljs.test :as t :include-macros true])) @@ -108,3 +110,21 @@ ;; process-pending fires update-text-layouts, it covers shape-b too. (t/is (= 2 (count (:shapes @captured))) "Both shapes are in process-pending so font-load covers all of them"))) + +(t/deftest empty-grid-tracks-do-not-allocate-zero-bytes + (let [calls (atom []) + ;; `h/call` is a macro that resolves the wasm function off the module + ;; via `unchecked-get`, so it cannot be redefined. Mock the module + ;; itself with recording stubs and let the real macro expansion run. + module #js {"_set_grid_rows" (fn [& _] (swap! calls conj [:call "_set_grid_rows"]) nil) + "_set_grid_columns" (fn [& _] (swap! calls conj [:call "_set_grid_columns"]) nil)}] + (with-redefs [mem/alloc (fn [size] + (swap! calls conj [:alloc size]) + 0) + wasm/internal-module module] + (wasm.api/set-grid-layout-rows []) + (wasm.api/set-grid-layout-columns [])) + (t/is (not-any? #(= :alloc (first %)) @calls)) + (t/is (= [[:call "_set_grid_rows"] + [:call "_set_grid_columns"]] + @calls)))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 4dc39abc18..e9777a6012 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -27,12 +27,19 @@ [frontend-tests.logic.groups-test] [frontend-tests.logic.pasting-in-containers-test] [frontend-tests.main-errors-test] + [frontend-tests.plugins.comments-test] [frontend-tests.plugins.context-shapes-test] + [frontend-tests.plugins.file-test] [frontend-tests.plugins.format-test] + [frontend-tests.plugins.grid-test] [frontend-tests.plugins.interactions-test] + [frontend-tests.plugins.library-test] + [frontend-tests.plugins.local-storage-test] [frontend-tests.plugins.page-active-validation-test] [frontend-tests.plugins.page-test] [frontend-tests.plugins.parser-test] + [frontend-tests.plugins.shape-bugfixes-test] + [frontend-tests.plugins.text-test] [frontend-tests.plugins.tokens-test] [frontend-tests.plugins.utils-test] [frontend-tests.render-wasm.process-objects-test] @@ -65,7 +72,8 @@ (.exit js/process 1))) (def test-namespaces - ['frontend-tests.basic-shapes-test + ['frontend-tests.plugins.text-test + 'frontend-tests.basic-shapes-test 'frontend-tests.code-gen-style-test 'frontend-tests.copy-as-svg-test 'frontend-tests.data.nitrate-test @@ -89,11 +97,20 @@ 'frontend-tests.logic.groups-test 'frontend-tests.logic.pasting-in-containers-test 'frontend-tests.plugins.context-shapes-test + 'frontend-tests.plugins.comments-test + 'frontend-tests.plugins.file-test + 'frontend-tests.plugins.format-test + 'frontend-tests.plugins.grid-test + 'frontend-tests.plugins.interactions-test + 'frontend-tests.plugins.library-test + 'frontend-tests.plugins.local-storage-test 'frontend-tests.plugins.page-active-validation-test 'frontend-tests.plugins.interactions-test 'frontend-tests.plugins.format-test 'frontend-tests.plugins.page-test 'frontend-tests.plugins.parser-test + 'frontend-tests.plugins.shape-bugfixes-test + 'frontend-tests.plugins.text-test 'frontend-tests.plugins.tokens-test 'frontend-tests.plugins.utils-test 'frontend-tests.svg-fills-test diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 64163dbd42..c3138b0659 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -1,22 +1,36 @@ ## 1.5.0 (Unreleased) +### 💣 Breaking changes & Deprecations + - **plugins-runtime**: changes outside the current page now raise a validation error when the target belongs to a page that is not currently active, instead of silently operating on the active page. -- **plugins-runtime**: Fix inverted validation that rejected valid values (and accepted invalid ones) on text range `align`, `direction`, `textDecoration`, `letterSpacing` and on layout child `zIndex`. -- **plugins-runtime**: Array-typed properties (e.g. `page.flows`, `shape.exports`, `shape.shadows`, layout `rows`/`columns`, ruler guides, path `commands`) now always return an array, returning an empty array instead of `null` when there are no items +- **plugin-types**: Change return type of `combineAsVariants` +- **plugin-types:** Deprecate the legacy `Image` shape interface — image shapes exist only for backward compatibility with old files; new images are embedded in a `Fill` via its `fillImage` (an `ImageData`). +- We've solved several inconsistencies accross the API, if you relied on an undocumented property or method be aware that might have changed. + +### 🚀 Features + - **plugins-runtime**: Added `version` field that returns the current version - **plugins-runtime**: Added optional parameter `throwOnError` to `penpot.ui.sendMessage` (default false, backwards-compatible) - **plugin-types**: Added a flags subcontexts with the flag `naturalChildrenOrdering` +- **plugin-types**: Added flag `throwValidationErrors` to enable exceptions on validation - **plugin-types**: `penpot.openPage()` now returns `Promise` and should be awaited before performing operations on the new page -- **plugin-types**: Fix penpot.openPage() to navigate in same tab by default - **plugin-types:** Change `LibraryComponent.isVariant()` return type to type guard `this is LibraryVariantComponent` - **plugin-types**: Added `createVariantFromComponents` -- **plugin-types**: Change return type of `combineAsVariants` - **plugin-types**: Added `textBounds` property for text shapes -- **plugin-types**: Added flag `throwValidationErrors` to enable exceptions on validation - **plugin-types**: Fix missing `webp` export format in `Export.type` - **plugin-types**: Added `fixedWhenScrolling` property for shapes - **plugin-runtime:** `addToken` now resolves references against all token sets, allowing references to tokens in inactive sets - **plugin-types:** `TokenCatalog.addSet` now accepts an optional `active` flag to create an already-active set (sets are inactive by default) +- **plugin-runtime:** A `fontFamilies` token's `resolvedValue` now returns the documented `string[]` (the resolved family list) instead of leaking the raw tokenscript list symbol + +### 🩹 Fixes + +- **plugins-runtime**: Fix inverted validation that rejected valid values (and accepted invalid ones) on text range `align`, `direction`, `textDecoration`, `letterSpacing` and on layout child `zIndex`. +- **plugins-runtime**: Array-typed properties (e.g. `page.flows`, `shape.exports`, `shape.shadows`, layout `rows`/`columns`, ruler guides, path `commands`) now always return an array, returning an empty array instead of `null` when there are no items +- **plugin-types**: Fix penpot.openPage() to navigate in same tab by default +- **plugin-types**: Rename `LibraryTypography.fontFamilies` to `fontFamily` to match the runtime (it holds a single font family, not an array) +- **plugin-runtime:** Setting a `LibraryColor`'s `gradient` or `image` now clears the other color representations (solid/gradient/image are mutually exclusive), so the result is a valid color instead of being rejected with "expected valid color" +- **plugin-types:** Mark members that have no runtime setter as `readonly`, fixing a mismatch where they were typed as writable: font metadata (`Font.*`, `FontVariant.*`, `FontsContext.all`), the `Ellipse`/`Image`/`SvgRaw` `type` discriminants (now consistent with the other shapes), `File.name`/`pages`/`revn`, `Page.root`, `TokenTheme.activeSets`, `Variants.properties`, `ImageData.*`, the board guide value objects (`GuideColumn`/`GuideRow`/`GuideSquare` and their params — `board.guides` returns a formatted snapshot, so reconfiguring means reassigning the whole array), the `Point` and `Bounds` value objects, the `Penpot.ui`/`Penpot.utils` subcontexts, the derived `Boolean` path data (`d`/`content`/`commands` are computed from the operands; `Boolean` is not editable like a `Path`), and the `EventsMap` event entries (a type-only event→callback map, never assigned). Members that do expose a setter stay writable: `Board.children`, `Path.d`/`content`/`commands` and `FileVersion.label`. ## 1.4.2 (2026-01-21) diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index fc53fd1b33..e83fffe473 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -6,7 +6,7 @@ export interface Penpot extends Omit< Context, 'addListener' | 'removeListener' > { - ui: { + readonly ui: { /** * Opens the plugin UI. It is possible to develop a plugin without interface (see Palette color example) but if you need, the way to open this UI is using `penpot.ui.open`. * There is a minimum and maximum size for this modal and a default size but it's possible to customize it anyway with the options parameter. @@ -84,7 +84,7 @@ export interface Penpot extends Omit< /** * Provides access to utility functions and context-specific operations. */ - utils: ContextUtils; + readonly utils: ContextUtils; /** * Closes the plugin. When this method is called the UI will be closed. * @@ -390,17 +390,17 @@ export interface Boolean extends ShapeBase { * The content of the boolean shape, defined as the path string. * @deprecated Use either `d` or `commands`. */ - content: string; + readonly content: string; /** * The content of the boolean shape, defined as the path string. */ - d: string; + readonly d: string; /** * The content of the boolean shape, defined as an array of path commands. */ - commands: Array; + readonly commands: Array; /** * The fills applied to the shape. @@ -455,19 +455,19 @@ export type Bounds = { /** * Top-left x position of the rectangular area defined */ - x: number; + readonly x: number; /** * Top-left y position of the rectangular area defined */ - y: number; + readonly y: number; /** * Width of the represented area */ - width: number; + readonly width: number; /** * Height of the represented area */ - height: number; + readonly height: number; }; /** @@ -1517,7 +1517,7 @@ export interface Ellipse extends ShapeBase { /** * The type of the shape, which is always 'ellipse' for ellipse shapes. */ - type: 'ellipse'; + readonly type: 'ellipse'; /** * The fills applied to the shape. @@ -1540,37 +1540,37 @@ export interface EventsMap { /** * The `pagechange` event is triggered when the active page in the project is changed. */ - pagechange: Page; + readonly pagechange: Page; /** * The `filechange` event is triggered when a different file is opened. * The callback will receive the new file. */ - filechange: File; + readonly filechange: File; /** * The `selectionchange` event is triggered when the selection of elements changes. * This event passes a list of identifiers of the selected elements. */ - selectionchange: string[]; + readonly selectionchange: string[]; /** * The `themechange` event is triggered when the application theme is changed. */ - themechange: Theme; + readonly themechange: Theme; /** * The `finish` event is triggered when the current file is closed. * The callback will receive the id of the closed file. */ - finish: string; + readonly finish: string; /** * This event will trigger whenever the shape in the props change. It's mandatory to send * with the props an object like `{ shapeId: '' }` */ - shapechange: Shape; + readonly shapechange: Shape; /** * The `contentsave` event will trigger after the file content is saved in the backend. */ - contentsave: void; + readonly contentsave: void; } /** @@ -1609,17 +1609,17 @@ export interface File extends PluginData { /** * The `name` for the file */ - name: string; + readonly name: string; /** * The `revn` will change for every document update */ - revn: number; + readonly revn: number; /** * List all the pages for the current file */ - pages: Page[]; + readonly pages: Page[]; /** * Export the current file to an archive. @@ -1819,37 +1819,37 @@ export interface Font { /** * This property holds the human-readable name of the font. */ - name: string; + readonly name: string; /** * The unique identifier of the font. */ - fontId: string; + readonly fontId: string; /** * The font family of the font. */ - fontFamily: string; + readonly fontFamily: string; /** * The default font style of the font. */ - fontStyle?: 'normal' | 'italic' | null; + readonly fontStyle?: 'normal' | 'italic' | null; /** * The default font variant ID of the font. */ - fontVariantId: string; + readonly fontVariantId: string; /** * The default font weight of the font. */ - fontWeight: string; + readonly fontWeight: string; /** * An array of font variants available for the font. */ - variants: FontVariant[]; + readonly variants: FontVariant[]; /** * Applies the font styles to a text shape. @@ -1884,22 +1884,22 @@ export interface FontVariant { /** * The name of the font variant. */ - name: string; + readonly name: string; /** * The unique identifier of the font variant. */ - fontVariantId: string; + readonly fontVariantId: string; /** * The font weight of the font variant. */ - fontWeight: string; + readonly fontWeight: string; /** * The font style of the font variant. */ - fontStyle: 'normal' | 'italic'; + readonly fontStyle: 'normal' | 'italic'; } /** @@ -1910,7 +1910,7 @@ export interface FontsContext { /** * An array containing all available fonts. */ - all: Font[]; + readonly all: Font[]; /** * Finds a font by its unique identifier. @@ -2208,15 +2208,15 @@ export interface GuideColumn { /** * The type of the guide, which is always 'column' for column guides. */ - type: 'column'; + readonly type: 'column'; /** * Specifies whether the column guide is displayed. */ - display: boolean; + readonly display: boolean; /** * The parameters defining the appearance and layout of the column guides. */ - params: GuideColumnParams; + readonly params: GuideColumnParams; } /** @@ -2227,7 +2227,7 @@ export interface GuideColumnParams { /** * The color configuration for the column guides. */ - color: { color: string; opacity: number }; + readonly color: { color: string; opacity: number }; /** * The optional alignment type of the column guides. * - 'stretch': Columns stretch to fit the available space. @@ -2235,23 +2235,23 @@ export interface GuideColumnParams { * - 'center': Columns align to the center. * - 'right': Columns align to the right. */ - type?: 'stretch' | 'left' | 'center' | 'right'; + readonly type?: 'stretch' | 'left' | 'center' | 'right'; /** * The optional size of each column. */ - size?: number; + readonly size?: number; /** * The optional margin between the columns and the board edges. */ - margin?: number; + readonly margin?: number; /** * The optional length of each item within the columns. */ - itemLength?: number; + readonly itemLength?: number; /** * The optional gutter width between columns. */ - gutter?: number; + readonly gutter?: number; } /** @@ -2262,16 +2262,16 @@ export interface GuideRow { /** * The type of the guide, which is always 'row' for row guides. */ - type: 'row'; + readonly type: 'row'; /** * Specifies whether the row guide is displayed. */ - display: boolean; + readonly display: boolean; /** * The parameters defining the appearance and layout of the row guides. * Note: This reuses the same parameter structure as column guides. */ - params: GuideColumnParams; + readonly params: GuideColumnParams; } /** @@ -2282,15 +2282,15 @@ export interface GuideSquare { /** * The type of the guide, which is always 'square' for square guides. */ - type: 'square'; + readonly type: 'square'; /** * Specifies whether the square guide is displayed. */ - display: boolean; + readonly display: boolean; /** * The parameters defining the appearance and layout of the square guides. */ - params: GuideSquareParams; + readonly params: GuideSquareParams; } /** @@ -2301,11 +2301,11 @@ export interface GuideSquareParams { /** * The color configuration for the square guides. */ - color: { color: string; opacity: number }; + readonly color: { color: string; opacity: number }; /** * The optional size of each square guide. */ - size?: number; + readonly size?: number; } /** @@ -2334,12 +2334,14 @@ export interface HistoryContext { /** * Represents an image shape in Penpot. * This interface extends `ShapeBase` and includes properties specific to image shapes. + * @deprecated Image shapes exist only for backward compatibility with old files. + * New images are embedded in a `Fill` via its `fillImage` (an `ImageData`). */ export interface Image extends ShapeBase { /** * The type of the shape, which is always 'image' for image shapes. */ - type: 'image'; + readonly type: 'image'; /** * The fills applied to the shape. @@ -2355,28 +2357,28 @@ export type ImageData = { /** * The optional name of the image. */ - name?: string; + readonly name?: string; /** * The width of the image. */ - width: number; + readonly width: number; /** * The height of the image. */ - height: number; + readonly height: number; /** * The optional media type of the image (e.g., 'image/png', 'image/jpeg'). */ - mtype?: string; + readonly mtype?: string; /** * The unique identifier for the image. */ - id: string; + readonly id: string; /** * Whether to keep the aspect ratio of the image when resizing. * Defaults to false if omitted. */ - keepAspectRatio?: boolean; + readonly keepAspectRatio?: boolean; /** * Returns the image data as a byte array. @@ -2870,9 +2872,9 @@ export interface LibraryTypography extends LibraryElement { fontId: string; /** - * The font families of the typography element. + * The font family of the typography element. */ - fontFamilies: string; + fontFamily: string; /** * The unique identifier of the font variant used in the typography element. @@ -3097,7 +3099,7 @@ export interface Page extends PluginData { * The root shape of the current page. Will be the parent shape of all the shapes inside the document. * Requires `content:read` permission. */ - root: Shape; + readonly root: Shape; /** * Retrieves a shape by its unique identifier. @@ -3437,7 +3439,7 @@ export interface PluginData { /** * Point represents a point in 2D space, typically with x and y coordinates. */ -export type Point = { x: number; y: number }; +export type Point = { readonly x: number; readonly y: number }; /** * It takes back to the last board shown. @@ -4126,7 +4128,7 @@ export interface SvgRaw extends ShapeBase { /** * The type of the shape, which is always 'svg-raw' for raw SVG shapes. */ - type: 'svg-raw'; + readonly type: 'svg-raw'; } /** @@ -5297,7 +5299,7 @@ export interface TokenTheme { /** * The sets that will be activated if this theme is activated. */ - activeSets: TokenSet[]; + readonly activeSets: TokenSet[]; /** * Adds a set to the list of the theme. @@ -5563,7 +5565,7 @@ export interface Variants { /** * A list with the names of the properties of the Variant */ - properties: string[]; + readonly properties: string[]; /** * A list of all the values of a property across all the VariantComponents of this Variant diff --git a/render-wasm/src/wasm/layouts/grid.rs b/render-wasm/src/wasm/layouts/grid.rs index 15284250c2..573b99a5da 100644 --- a/render-wasm/src/wasm/layouts/grid.rs +++ b/render-wasm/src/wasm/layouts/grid.rs @@ -174,7 +174,7 @@ pub extern "C" fn set_grid_layout_data( #[no_mangle] #[wasm_error] pub extern "C" fn set_grid_columns() -> Result<()> { - let bytes = mem::bytes(); + let bytes = mem::bytes_or_empty(); let entries: Vec = bytes .chunks(size_of::()) @@ -197,7 +197,7 @@ pub extern "C" fn set_grid_columns() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn set_grid_rows() -> Result<()> { - let bytes = mem::bytes(); + let bytes = mem::bytes_or_empty(); let entries: Vec = bytes .chunks(size_of::()) @@ -220,7 +220,7 @@ pub extern "C" fn set_grid_rows() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn set_grid_cells() -> Result<()> { - let bytes = mem::bytes(); + let bytes = mem::bytes_or_empty(); let cells: Vec = bytes .chunks(size_of::())