🐛 Fix problems with plugins API (#10412)

*  Adds static dispatch safe stubs in tests

* 🐛 Fix shapesColors metadata key to match ColorShapeInfo

* 🐛 Fix CommentThread.remove rejecting the owner's own threads

* 🐛 Fix page.removeCommentThread throwing on a spurious Promise

*  Implement ShapeBase.swapComponent in the plugin API

*  Expose File.revn in the plugin API

* 🐛 Fix FileVersion.createdAt calling Luxon method on a js/Date

* 🐛 Fix plugin font/typography application to text and ranges

* 🐛 Default plugin overlay interaction position for non-manual types

* 🐛 Fix plugin interaction setters passing an id-only shape

* 🐛 Fix grid addColumnAtIndex rejecting valid track types

* 🐛 Expose libraryId on library color/typography/component proxies

*  Implement LibraryTypography.setFont in the plugin API

* 🐛 Fix typography.applyToTextRange reading unexposed range bounds

* 🐛 Fix utils.geometry.center argument mismatch

* 🐛 Fix localStorage.removeItem calling getItem

* 🐛 Fix shape backgroundBlur proxy key casing

* 🐛 Report boolean shape type as 'boolean' in the plugin API

* 🐛 Return the resulting paths from plugin flatten

* 🐛 Make plugin z-order methods act on the target shape

* 🐛 Make is-variant-container? return a boolean

*  Implement Group.isMask in the plugin API

* 🐛 Return a shape proxy from TextRange.shape

* 🐛 Return the duplicated set from TokenSet.duplicate

* 🐛 Fix theme addSet/removeSet reading set name with a keyword

* 🐛 Accept string fontFamilies token value in the plugin API

* 🐛 Fix combineAsVariants ignoring the passed component ids

* 🐛 Fix board removeRulerGuide ignoring its argument

* 🐛 Fix board guides setter schema and parser

* 🐛 Avoid 0-byte allocation when syncing empty grid tracks

* 🐛 Validate grid track indices in the plugin API

* 🐛 Return null for empty input in group() and centerShapes()

* 🐛 Return TokenTypographyValue[] from a typography token's resolvedValue

* 🐛 Return TokenShadowValue[] from a shadow token's resolvedValue

* 🐛 Return string[] from a fontFamilies token's resolvedValue

* 🐛 Clear mutually-exclusive reps when setting LibraryColor gradient/image

* 🐛 Add readonly tags to types, deprecate Image type

* 📚 Update plugins changelog
This commit is contained in:
Alonso Torres 2026-06-29 17:32:15 +02:00 committed by GitHub
parent 99638fa60c
commit f993f203bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 1226 additions and 250 deletions

View File

@ -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]

View File

@ -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]

View File

@ -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?]

View File

@ -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]

View File

@ -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

View File

@ -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 %)}

View File

@ -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]

View File

@ -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))

View File

@ -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)

View File

@ -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

View File

@ -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 []

View File

@ -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]

View File

@ -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)}

View File

@ -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))]

View File

@ -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"))

View File

@ -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

View File

@ -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))))))}

View File

@ -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]

View File

@ -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
;; ═══════════════════════════════════════════════════════════════

View File

@ -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))))))

View File

@ -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)))))

View File

@ -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))))

View File

@ -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)))))))

View File

@ -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))))))

View File

@ -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))))))

View File

@ -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])))))

View File

@ -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 [])))))

View File

@ -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")))))))

View File

@ -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)))))

View File

@ -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))))

View File

@ -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

View File

@ -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<void>` 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)

View File

@ -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<PathCommand>;
readonly commands: Array<PathCommand>;
/**
* 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: '<id>' }`
*/
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

View File

@ -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<GridTrack> = bytes
.chunks(size_of::<RawGridTrack>())
@ -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<GridTrack> = bytes
.chunks(size_of::<RawGridTrack>())
@ -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<RawGridCell> = bytes
.chunks(size_of::<RawGridCell>())