Compare commits

...
52 changed files with 1993 additions and 411 deletions

No files matched your search

+105 -1
View File
@@ -35,6 +35,7 @@
[app.common.types.shape.text :as ctst]
[app.common.types.text :as types.text]
[app.common.types.tokens-lib :as ctob]
[app.common.types.variant :as ctv]
[app.common.uuid :as uuid]
[clojure.set :as set]
[cuerdas.core :as str]))
@@ -1998,6 +1999,108 @@
(update :pages-index d/update-vals update-container)
(d/update-when :components d/update-vals update-container))))
(defmethod migrate-data "0027-normalize-constrained-values"
;; Existing files can contain values outside the limits now shared by the UI
;; and file schemas. Normalize them before checking the migrated file.
[data _]
(letfn [(clamp-minimum [value minimum]
(if (number? value)
(max value minimum)
value))
(positive-or-default [value default]
(if (and (number? value) (not (pos? value)))
default
value))
(clamp-attrs [value attrs]
(reduce #(d/update-when %1 %2 clamp-minimum 0) value attrs))
(repair-vector [value repair-item]
(if (vector? value)
(mapv repair-item value)
value))
(repair-grid-params [params type]
(cond
(= type :square)
(d/update-when params :size clamp-minimum 0.01)
(#{:row :column} type)
(d/update-when params :size clamp-minimum 1)
:else
params))
(repair-grid [grid]
(d/update-when grid :params repair-grid-params (:type grid)))
(repair-default-grids [grids]
(-> grids
(d/update-when :square repair-grid-params :square)
(d/update-when :row repair-grid-params :row)
(d/update-when :column repair-grid-params :column)))
(repair-grid-track [track]
(d/update-when track :value clamp-minimum 0))
(repair-export [export]
(d/update-when export :scale positive-or-default 1))
(repair-stroke [stroke]
(clamp-attrs stroke [:stroke-width
:stroke-width-top
:stroke-width-right
:stroke-width-bottom
:stroke-width-left]))
(repair-shadow [shadow]
(d/update-when shadow :blur clamp-minimum 0))
(repair-blur [blur]
(d/update-when blur :value clamp-minimum 0))
(repair-shape [shape]
(-> shape
(clamp-attrs [:r1 :r2 :r3 :r4
:layout-item-min-w :layout-item-max-w
:layout-item-min-h :layout-item-max-h])
(d/update-when :layout-gap clamp-attrs [:row-gap :column-gap])
(d/update-when :layout-padding clamp-attrs [:p1 :p2 :p3 :p4])
(d/update-when :layout-grid-rows repair-vector repair-grid-track)
(d/update-when :layout-grid-columns repair-vector repair-grid-track)
(d/update-when :strokes repair-vector repair-stroke)
(d/update-when :shadow repair-vector repair-shadow)
(d/update-when :blur repair-blur)
(d/update-when :background-blur repair-blur)
(d/update-when :exports repair-vector repair-export)
(d/update-when :grids repair-vector repair-grid)))
(truncate-property-text [value]
(if (and (string? value)
(> (count value) ctv/property-max-length))
(subs value 0 ctv/property-max-length)
value))
(repair-variant-property [property]
(-> property
(d/update-when :name truncate-property-text)
(d/update-when :value truncate-property-text)))
(repair-container [container]
(-> container
(d/update-when :objects d/update-vals repair-shape)
(d/update-when :variant-properties repair-vector repair-variant-property)))
(repair-page [page]
(-> page
(repair-container)
(d/update-when :default-grids repair-default-grids)))]
(-> data
(update :pages-index d/update-vals repair-page)
(d/update-when :components d/update-vals repair-container))))
(def available-migrations
(into (d/ordered-set)
["legacy-2"
@@ -2081,4 +2184,5 @@
"0023-repair-token-themes-with-inexistent-sets"
"0024b-fix-stroke-cap-placement"
"0025-repair-empty-text-content"
"0026-fix-svg-raw-shapes-uuids"]))
"0026-fix-svg-raw-shapes-uuids"
"0027-normalize-constrained-values"]))
+17 -10
View File
@@ -76,15 +76,20 @@
::sm/text]) ;; Leave references or formulas to be checked by the resolver
(def schema:token-value-typography-map
[:map
[:font-family {:optional true} schema:token-value-font-family]
[:font-size {:optional true} schema:token-value-numeric]
[:font-weight {:optional true} schema:token-value-font-weight]
[:line-height {:optional true} schema:token-value-percent]
[:letter-spacing {:optional true} schema:token-value-generic]
[:paragraph-spacing {:optional true} schema:token-value-generic]
[:text-decoration {:optional true} schema:token-value-generic]
[:text-case {:optional true} schema:token-value-generic]])
[:and
[:map
[:font-family {:optional true} schema:token-value-font-family]
[:font-size {:optional true} schema:token-value-numeric]
[:font-weight {:optional true} schema:token-value-font-weight]
[:line-height {:optional true} schema:token-value-percent]
[:letter-spacing {:optional true} schema:token-value-generic]
[:paragraph-spacing {:optional true} schema:token-value-generic]
[:text-decoration {:optional true} schema:token-value-generic]
[:text-case {:optional true} schema:token-value-generic]]
[:fn (fn [value]
(and (seq value)
(or (not (contains? value :line-height))
(contains? value :font-size))))]])
(def schema:token-value-typography
[:or
@@ -92,7 +97,7 @@
schema:token-value-composite-ref])
(def schema:token-value-shadow-vector
[:vector
[:vector {:min 1}
[:map
[:offset-x :string]
[:offset-y :string]
@@ -276,6 +281,7 @@
[tokens-lib set-id]
[:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
[:fn #(not (str/blank? (ctob/normalize-set-name %)))]
[:fn {:error/fn #(tr "errors.token-set-already-exists")}
(fn [name]
(or (nil? tokens-lib)
@@ -316,6 +322,7 @@
[tokens-lib group theme-id]
[:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
[:fn #(not (str/blank? %))]
[:fn {:error/fn #(tr "errors.token-theme-already-exists" (str group "/" (:value %)))}
(fn [name]
(or (nil? tokens-lib)
+14
View File
@@ -868,6 +868,14 @@
(register! ::safe-number [::number {:gen/gen (sg/small-double)
:max max-safe-int
:min min-safe-int}])
(register! ::non-negative-safe-number
[:and {:gen/gen (sg/small-double :min 0)}
::safe-number
[:fn #(not (neg? %))]])
(register! ::positive-safe-number
[:and {:gen/gen (sg/small-double :min 0.01)}
::safe-number
[:fn pos?]])
(defn parse-boolean
[v]
@@ -1093,6 +1101,12 @@
(def valid-safe-number?
(lazy-validator ::safe-number))
(def valid-non-negative-safe-number?
(lazy-validator ::non-negative-safe-number))
(def valid-positive-safe-number?
(lazy-validator ::positive-safe-number))
(def valid-safe-int?
(lazy-validator ::safe-int))
+13 -3
View File
@@ -7,6 +7,7 @@
(ns app.common.types.grid
(:require
[app.common.schema :as sm]
[app.common.schema.generators :as sg]
[app.common.types.color :as clr]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -18,18 +19,28 @@
[:color clr/schema:hex-color]
[:opacity ::sm/safe-number]])
(def schema:grid-count
[:and {:gen/gen (sg/small-double :min 1)}
::sm/safe-number
[:fn #(<= 1 %)]])
(def schema:square-size
[:and {:gen/gen (sg/small-double :min 0.01)}
::sm/safe-number
[:fn #(<= 0.01 %)]])
(def schema:column-params
[:map {:title "ColumnGridParams"}
[:color schema:grid-color]
[:type {:optional true} [::sm/one-of #{:stretch :left :center :right}]]
[:size {:optional true} [:maybe ::sm/safe-number]]
[:size {:optional true} [:maybe schema:grid-count]]
[:margin {:optional true} [:maybe ::sm/safe-number]]
[:item-length {:optional true} [:maybe ::sm/safe-number]]
[:gutter {:optional true} [:maybe ::sm/safe-number]]])
(def schema:square-params
[:map {:title "SquareGridParams"}
[:size {:optional true} [:maybe ::sm/safe-number]]
[:size {:optional true} [:maybe schema:square-size]]
[:color schema:grid-color]])
(def schema:grid
@@ -78,4 +89,3 @@
{:square default-square-params
:column default-layout-params
:row default-layout-params})
+20 -1
View File
@@ -14,7 +14,8 @@
[app.common.types.grid :as ctg]
[app.common.types.plugins :as ctpg]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]))
[app.common.uuid :as uuid]
[cuerdas.core :as str]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SCHEMAS
@@ -73,6 +74,24 @@
(def check-page
(sm/check-fn schema:page))
(defn normalize-page-name
[name]
(some-> name str/trim))
(defn valid-page-name?
[name]
(let [name (normalize-page-name name)]
(and (string? name) (not (str/blank? name)))))
(defn valid-flow-starting-frame?
[page frame-id flow-id]
(let [frame (get-in page [:objects frame-id])]
(and (= :frame (:type frame))
(not-any? (fn [[id flow]]
(and (not= id flow-id)
(= frame-id (:starting-frame flow))))
(:flows page)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; INIT & HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+9 -9
View File
@@ -138,13 +138,13 @@
[:stroke-opacity {:optional true} ::sm/safe-number]
[:stroke-style {:optional true}
[::sm/one-of #{:solid :dotted :dashed :mixed}]]
[:stroke-width {:optional true} ::sm/safe-number]
[:stroke-width {:optional true} ::sm/non-negative-safe-number]
;; wasm-render only, backwards compatible
[:stroke-per-side {:optional true} :boolean]
[:stroke-width-top {:optional true} ::sm/safe-number]
[:stroke-width-right {:optional true} ::sm/safe-number]
[:stroke-width-bottom {:optional true} ::sm/safe-number]
[:stroke-width-left {:optional true} ::sm/safe-number]
[:stroke-width-top {:optional true} ::sm/non-negative-safe-number]
[:stroke-width-right {:optional true} ::sm/non-negative-safe-number]
[:stroke-width-bottom {:optional true} ::sm/non-negative-safe-number]
[:stroke-width-left {:optional true} ::sm/non-negative-safe-number]
[:stroke-dash {:optional true} ::sm/safe-number]
[:stroke-gap {:optional true} ::sm/safe-number]
[:stroke-alignment {:optional true}
@@ -211,10 +211,10 @@
[:constraints-v {:optional true}
[::sm/one-of vertical-constraint-types]]
[:fixed-scroll {:optional true} :boolean]
[:r1 {:optional true} ::sm/safe-number]
[:r2 {:optional true} ::sm/safe-number]
[:r3 {:optional true} ::sm/safe-number]
[:r4 {:optional true} ::sm/safe-number]
[:r1 {:optional true} ::sm/non-negative-safe-number]
[:r2 {:optional true} ::sm/non-negative-safe-number]
[:r3 {:optional true} ::sm/non-negative-safe-number]
[:r4 {:optional true} ::sm/non-negative-safe-number]
[:opacity {:optional true} ::sm/safe-number]
[:grids {:optional true}
[:vector {:gen/max 2} ctg/schema:grid]]
@@ -12,5 +12,5 @@
[:map {:title "BackgroundBlur"}
[:id ::sm/uuid]
[:type [:enum :background-blur]]
[:value ::sm/safe-number]
[:hidden :boolean]])
[:value ::sm/non-negative-safe-number]
[:hidden :boolean]])
+1 -1
View File
@@ -12,5 +12,5 @@
[:map {:title "Blur"}
[:id ::sm/uuid]
[:type [:enum :layer-blur]]
[:value ::sm/safe-number]
[:value ::sm/non-negative-safe-number]
[:hidden :boolean]])
@@ -13,5 +13,5 @@
(def schema:export
[:map {:title "ShapeExport"}
[:type [::sm/one-of types]]
[:scale ::sm/safe-number]
[:scale ::sm/positive-safe-number]
[:suffix :string]])
@@ -10,7 +10,9 @@
[app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt]
[app.common.schema :as sm]
[app.common.schema.generators :as sg]))
[app.common.schema.generators :as sg]
[app.common.uri :as uri]
[cuerdas.core :as str]))
;; WARNING: options are not deleted when changing event or action
;; type, so it can be restored if the user changes it back later.
@@ -216,15 +218,17 @@
(declare calc-overlay-pos-initial)
(declare allowed-animation?)
(defn valid-event-type-for-shape?
[shape event-type]
(and (contains? event-types event-type)
(or (not= event-type :after-delay)
(cfh/frame-shape? shape))))
(defn set-event-type
[interaction event-type shape]
(assert (check-interaction interaction))
(assert (contains? event-types event-type)
"should be a valid event type")
(assert (or (not= event-type :after-delay)
(cfh/frame-shape? shape))
"the `:after-delay` event type incompatible with not frame shapes")
(assert (valid-event-type-for-shape? shape event-type)
"event type incompatible with shape")
(if (= (:event-type interaction) event-type)
interaction
@@ -290,12 +294,19 @@
(defn set-delay
[interaction delay]
(assert (check-interaction interaction))
(assert (sm/check-safe-int delay))
(assert (and (sm/check-safe-int delay) (not (neg? delay))))
(assert (has-delay interaction)
"expected compatible interaction event type")
(assoc interaction :delay delay))
(defn valid-delay?
[interaction]
(or (not (has-delay interaction))
(let [delay (:delay interaction)]
(and (sm/valid-safe-int? delay)
(not (neg? delay))))))
;; FIXME: rename to proper name, very confusing one because it does
;; not checks if interaction has distination, it checks if it can have
;; one.
@@ -325,6 +336,31 @@
(assoc :overlay-pos-type :center
:overlay-position (gpt/point 0 0))))
(defn valid-destination?
[objects shape destination]
(or (nil? destination)
(let [target (get objects destination)]
(and (cfh/frame-shape? target)
(not= destination (:id shape))
(not= destination (:frame-id shape))))))
(defn normalize-url
[value]
(when (string? value)
(let [value (str/trim value)
explicit-scheme? (re-find #"(?i)^[a-z][a-z0-9+.-]*:" value)]
(when (or (not explicit-scheme?)
(re-find #"(?i)^https?://" value))
(let [value (if explicit-scheme? value (str "http://" value))]
(try
(let [parsed (uri/uri value)]
(when (and (not (re-find #"\s" value))
(contains? #{"http" "https"} (:scheme parsed))
(seq (:host parsed)))
value))
(catch #?(:clj Exception :cljs :default) _
nil)))))))
(defn has-preserve-scroll
[interaction]
(= (:action-type interaction) :navigate))
+12 -12
View File
@@ -25,7 +25,7 @@
;; :layout-justify-content ;; :start :center :end :space-between :space-around :space-evenly
;; :layout-wrap-type ;; :wrap, :nowrap
;; :layout-padding-type ;; :simple, :multiple
;; :layout-padding ;; {:p1 num :p2 num :p3 num :p4 num} number could be negative
;; :layout-padding ;; {:p1 num :p2 num :p3 num :p4 num}
;; layout-grid-rows ;; vector of grid-track
;; layout-grid-columns ;; vector of grid-track
@@ -103,7 +103,7 @@
(def ^:private schema:grid-track
[:map {:title "GridTrack"}
[:type [::sm/one-of grid-track-types]]
[:value {:optional true} [:maybe ::sm/safe-number]]])
[:value {:optional true} [:maybe ::sm/non-negative-safe-number]]])
(def schema:layout-attrs
[:map {:title "LayoutAttrs"}
@@ -111,17 +111,17 @@
[:layout-flex-dir {:optional true} [::sm/one-of flex-direction-types]]
[:layout-gap {:optional true}
[:map
[:row-gap {:optional true} ::sm/safe-number]
[:column-gap {:optional true} ::sm/safe-number]]]
[:row-gap {:optional true} ::sm/non-negative-safe-number]
[:column-gap {:optional true} ::sm/non-negative-safe-number]]]
[:layout-gap-type {:optional true} [::sm/one-of gap-types]]
[:layout-wrap-type {:optional true} [::sm/one-of wrap-types]]
[:layout-padding-type {:optional true} [::sm/one-of padding-type]]
[:layout-padding {:optional true}
[:map
[:p1 ::sm/safe-number]
[:p2 ::sm/safe-number]
[:p3 ::sm/safe-number]
[:p4 ::sm/safe-number]]]
[:p1 ::sm/non-negative-safe-number]
[:p2 ::sm/non-negative-safe-number]
[:p3 ::sm/non-negative-safe-number]
[:p4 ::sm/non-negative-safe-number]]]
[:layout-justify-content {:optional true} [::sm/one-of justify-content-types]]
[:layout-justify-items {:optional true} [::sm/one-of justify-items-types]]
[:layout-align-content {:optional true} [::sm/one-of align-content-types]]
@@ -163,10 +163,10 @@
[:m2 {:optional true} ::sm/safe-number]
[:m3 {:optional true} ::sm/safe-number]
[:m4 {:optional true} ::sm/safe-number]]]
[:layout-item-max-h {:optional true} ::sm/safe-number]
[:layout-item-min-h {:optional true} ::sm/safe-number]
[:layout-item-max-w {:optional true} ::sm/safe-number]
[:layout-item-min-w {:optional true} ::sm/safe-number]
[:layout-item-max-h {:optional true} ::sm/non-negative-safe-number]
[:layout-item-min-h {:optional true} ::sm/non-negative-safe-number]
[:layout-item-max-w {:optional true} ::sm/non-negative-safe-number]
[:layout-item-min-w {:optional true} ::sm/non-negative-safe-number]
[:layout-item-h-sizing {:optional true} [::sm/one-of item-h-sizing-types]]
[:layout-item-v-sizing {:optional true} [::sm/one-of item-v-sizing-types]]
[:layout-item-align-self {:optional true} [::sm/one-of item-align-self-types]]
@@ -25,7 +25,7 @@
[:style [::sm/one-of styles]]
[:offset-x ::sm/safe-number]
[:offset-y ::sm/safe-number]
[:blur ::sm/safe-number]
[:blur ::sm/non-negative-safe-number]
[:spread ::sm/safe-number]
[:hidden :boolean]
[:color schema:color]])
@@ -35,4 +35,3 @@
(def valid-shadow?
(sm/validator schema:shadow))
+34
View File
@@ -56,6 +56,40 @@
(def text-transform-attrs
[:text-transform])
(def font-size-min 3)
(def font-size-max 1000)
(def spacing-min -200)
(def spacing-max 200)
(def text-transform-values
#{"uppercase" "capitalize" "lowercase" "none" "unset"})
(def ^:private numeric-text-re
#"^-?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)$")
(defn- valid-numeric-text-in-range?
[value min-value max-value]
(and (string? value)
(re-matches numeric-text-re value)
(let [value (d/parse-double value)]
(and (some? value)
(<= min-value value max-value)))))
(defn valid-font-size?
[value]
(valid-numeric-text-in-range? value font-size-min font-size-max))
(defn valid-line-height?
[value]
(valid-numeric-text-in-range? value spacing-min spacing-max))
(defn valid-letter-spacing?
[value]
(valid-numeric-text-in-range? value spacing-min spacing-max))
(defn valid-text-transform?
[value]
(contains? text-transform-values value))
(def text-fills
[:fills])
+7 -3
View File
@@ -122,9 +122,13 @@
(def composite-dtcg-token-type->token-type
"Same as above, in the opposite direction."
(assoc dtcg-token-type->token-type
"lineHeights" :line-height
"lineHeight" :line-height))
(let [mapping (assoc dtcg-token-type->token-type
"lineHeights" :line-height
"lineHeight" :line-height)]
(into mapping
(map (fn [[key value]]
[(keyword (str/kebab key)) value]))
mapping)))
(def token-types
(into #{} (keys token-type->dtcg-token-type)))
+27 -5
View File
@@ -16,10 +16,12 @@
;; SCHEMA
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def property-max-length 60)
(def schema:variant-property
[:map
[:name :string]
[:value :string]])
[:name [:string {:max property-max-length}]]
[:value [:string {:max property-max-length}]]])
(def schema:variant-component
"A component that is part of a variant set"
@@ -47,9 +49,29 @@
(def property-prefix "Property ")
(def property-regex (re-pattern (str property-prefix "(\\d+)")))
(def property-max-length 60)
(def value-prefix "Value ")
(defn normalize-property-text
[value]
(some-> value str/trim))
(defn valid-property-name?
[value]
(let [value (normalize-property-text value)]
(and (string? value)
(not (str/blank? value))
(<= (count value) property-max-length))))
(defn valid-property-value?
[value]
(let [value (normalize-property-text value)]
(and (string? value)
(<= (count value) property-max-length))))
(defn can-remove-property?
[properties]
(> (count properties) 1))
(defn properties-to-name
"Transform the properties into a name, with the values separated by comma"
[properties]
@@ -124,8 +146,8 @@
(mapv #(str/split % "=" 2))
(every? #(and (= 2 (count %))
(not (str/blank? (first %)))
(< (count (first %)) property-max-length)
(< (count (second %)) property-max-length)))))
(<= (count (first %)) property-max-length)
(<= (count (second %)) property-max-length)))))
(defn find-properties-to-remove
"Compares two property maps to find which properties should be removed"
@@ -9,6 +9,7 @@
[app.common.data :as d]
[app.common.files.migrations :as cfm]
[app.common.types.file :as ctf]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
[clojure.test :as t]))
@@ -73,3 +74,135 @@
(let [shape (get-in data' [:pages-index page-id :objects shape-id])]
(t/is (nil? (:stroke-cap-start shape)) "top-level cap removed even with no strokes")
(t/is (nil? (:stroke-cap-end shape)) "top-level cap removed even with no strokes"))))
(t/deftest migration-0027-normalizes-constrained-shape-values
(let [file-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
shape (-> (cts/setup-shape {:id shape-id :type :frame})
(assoc :r1 -1
:r2 -2
:r3 -3
:r4 -4
:layout-gap {:row-gap -5 :column-gap -6}
:layout-padding {:p1 -7 :p2 -8 :p3 -9 :p4 -10}
:layout-grid-rows [{:type :fixed :value -11}]
:layout-grid-columns [{:type :percent :value -12}]
:layout-item-min-w -13
:layout-item-max-w -14
:layout-item-min-h -15
:layout-item-max-h -16
:strokes [{:stroke-color "#000000"
:stroke-width -17
:stroke-width-top -18
:stroke-width-right -19
:stroke-width-bottom -20
:stroke-width-left -21}]
:shadow [{:id nil
:style :drop-shadow
:offset-x 0
:offset-y 0
:blur -22
:spread 0
:hidden false
:color {:color "#000000" :opacity 1}}]
:blur {:id (uuid/next)
:type :layer-blur
:value -23
:hidden false}
:background-blur {:id (uuid/next)
:type :background-blur
:value -24
:hidden false}
:exports [{:type :png :scale 0 :suffix ""}]
:grids [{:type :square
:display true
:params {:size 0
:color {:color "#000000" :opacity 1}}}
{:type :column
:display true
:params {:size -25
:color {:color "#000000" :opacity 1}}}]))
data (-> (ctf/make-file-data file-id page-id)
(assoc-in [:pages-index page-id :objects shape-id] shape))]
(t/is (thrown? #?(:clj Exception :cljs js/Error)
(ctf/check-file-data data))
"new schemas reject legacy negative values")
(let [data' (cfm/migrate-data data "0027-normalize-constrained-values")
shape' (get-in data' [:pages-index page-id :objects shape-id])]
(t/is (= data' (ctf/check-file-data data')) "migrated file data passes the schema")
(t/is (every? zero? (map #(get shape' %) [:r1 :r2 :r3 :r4])) "corner radii clamped")
(t/is (= {:row-gap 0 :column-gap 0} (:layout-gap shape')) "layout gaps clamped")
(t/is (= {:p1 0 :p2 0 :p3 0 :p4 0} (:layout-padding shape')) "layout padding clamped")
(t/is (= [0] (mapv :value (:layout-grid-rows shape'))) "row tracks clamped")
(t/is (= [0] (mapv :value (:layout-grid-columns shape'))) "column tracks clamped")
(t/is (every? zero?
(map #(get shape' %)
[:layout-item-min-w :layout-item-max-w
:layout-item-min-h :layout-item-max-h]))
"layout item bounds clamped")
(t/is (every? zero?
(map (first (:strokes shape'))
[:stroke-width :stroke-width-top :stroke-width-right
:stroke-width-bottom :stroke-width-left]))
"stroke widths clamped")
(t/is (zero? (get-in shape' [:shadow 0 :blur])) "shadow blur clamped")
(t/is (zero? (get-in shape' [:blur :value])) "layer blur clamped")
(t/is (zero? (get-in shape' [:background-blur :value])) "background blur clamped")
(t/is (= 1 (get-in shape' [:exports 0 :scale])) "export scale reset to default")
(t/is (= 0.01 (get-in shape' [:grids 0 :params :size])) "square grid size clamped")
(t/is (= 1 (get-in shape' [:grids 1 :params :size])) "column grid count clamped"))))
(t/deftest migration-0027-normalizes-page-grids-and-variant-properties
(let [file-id (uuid/next)
page-id (uuid/next)
component-id (uuid/next)
long-name (apply str (repeat 61 "n"))
long-value (apply str (repeat 61 "v"))
data (-> (ctf/make-file-data file-id page-id)
(assoc-in [:pages-index page-id :default-grids]
{:square {:size -1
:color {:color "#000000" :opacity 1}}
:row {:size 0.5
:color {:color "#000000" :opacity 1}}
:column {:size -2
:color {:color "#000000" :opacity 1}}})
(assoc-in [:components component-id]
{:id component-id
:name "Variant"
:variant-properties [{:name long-name
:value long-value}]}))
data' (cfm/migrate-data data "0027-normalize-constrained-values")]
(t/is (= 0.01 (get-in data' [:pages-index page-id :default-grids :square :size]))
"default square grid size clamped")
(t/is (= 1 (get-in data' [:pages-index page-id :default-grids :row :size]))
"default row grid count reset")
(t/is (= 1 (get-in data' [:pages-index page-id :default-grids :column :size]))
"default column grid count reset")
(t/is (= 60 (count (get-in data' [:components component-id :variant-properties 0 :name])))
"variant property name truncated")
(t/is (= 60 (count (get-in data' [:components component-id :variant-properties 0 :value])))
"variant property value truncated")
(t/is (= data' (cfm/migrate-data data' "0027-normalize-constrained-values"))
"migration is idempotent")))
(t/deftest migration-0027-runs-through-file-migration
(let [migration-id "0027-normalize-constrained-values"
shape-id (uuid/next)
file (ctf/make-file {:name "Legacy constrained values"})
page-id (first (get-in file [:data :pages]))
shape (-> (cts/setup-shape {:id shape-id :type :rect})
(assoc :r1 -1))
file (-> file
(assoc :migrations (disj cfm/available-migrations migration-id))
(assoc-in [:data :pages-index page-id :objects shape-id] shape))
file' (cfm/migrate-file file {})]
(t/is (cfm/need-migration? file) "new migration detected")
(t/is (not (cfm/need-migration? file')) "new migration recorded")
(t/is (contains? (:migrations file') migration-id) "migration id persisted")
(t/is (zero? (get-in file' [:data :pages-index page-id :objects shape-id :r1]))
"migration repaired file data before schema validation")))
+8 -1
View File
@@ -20,6 +20,7 @@
[app.main.repo :as rp]
[app.util.i18n :as i18n :refer [tr]]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[potok.v2.core :as ptk]))
(def ^:private schema:comment-thread
@@ -66,6 +67,13 @@
(def r-mentions #"@\[([^\]]*)\]\(([^\)]*)\)")
(defn valid-comment-content?
[content]
(when (string? content)
(let [content (str/trim content)]
(and (not (str/blank? content))
(not= content "\u200b")))))
(defn extract-mentions
"Retrieves the mentions in the content as an array of uuids"
[content]
@@ -704,4 +712,3 @@
(rx/map (fn [profiles]
#(update % :profiles merge (d/index-by :id profiles)))))))))
@@ -1042,6 +1042,17 @@
second)
0)))))
(defn component-swap-nesting-loop?
[objects shape library-data component-id]
(let [component (ctkl/get-component library-data component-id true)
page (ctf/get-component-page library-data component)
root (ctf/get-component-root library-data component)]
(and page
root
(cfh/components-nesting-loop?
(cfh/get-children-with-self (:objects page) (:id root))
(cfh/get-parents-with-self objects (:parent-id shape))))))
(defn component-swap
"Swaps a component with another one"
[shape file-id id-new-component keep-touched?]
@@ -733,6 +733,45 @@
(redirect-to-page page-id)
(combine current-page))))))
(defn valid-components-for-variants?
[state page-id ids]
(let [ids (distinct ids)
objects (dsh/lookup-page-objects state page-id)
data (dsh/lookup-file-data state)]
(and (= page-id (:current-page-id state))
(> (count ids) 1)
(every?
(fn [id]
(let [shape (get objects id)
component (ctkl/get-component data (:component-id shape) false)]
(and (ctc/main-instance? shape)
component
(not (ctc/is-variant? component)))))
ids))))
(defn valid-variant-switch?
[state shape pos val]
(let [libraries (dsh/lookup-libraries state)
component (ctf/get-component libraries
(:component-file shape)
(:component-id shape)
:include-deleted? false)
component-file-data (dm/get-in libraries [(:component-file shape) :data])
component-page (dsh/get-page component-file-data (:main-instance-page component))
component-page-objects (:objects component-page)
variant-components (when component
(cfv/find-variant-components component-file-data
component-page-objects
(:variant-id component)))]
(and (ctc/instance-head? shape)
(ctc/in-component-copy? shape)
(ctc/is-variant? component)
(nat-int? pos)
(< pos (count (:variant-properties component)))
(string? val)
(some #(= val (dm/get-in % [:variant-properties pos :value]))
variant-components))))
(defn combine-selected-as-variants
[options]
(ptk/reify ::combine-selected-as-variants
@@ -799,4 +838,3 @@
(with-meta (meta it))))))
(rx/of (dwu/commit-undo-transaction undo-id)
(dws/select-shapes ids)))))))
+1 -5
View File
@@ -146,11 +146,7 @@
(defn- blank-content?
[content]
(let [content (str/trim content)]
(or (str/blank? content)
(str/empty? content)
(and (= (count content) 1)
(= (first content) zero-width-space)))))
(not (dcm/valid-comment-content? content)))
;; Component that renders the component content
(mf/defc comment-content*
@@ -9,7 +9,6 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.files.variant :as cfv]
[app.common.path-names :as cpn]
[app.common.types.component :as ctk]
@@ -390,7 +389,7 @@
(mf/use-fn
(mf/deps component-ids)
(fn [pos value]
(let [value (d/nilv (str/trim value) "")]
(let [value (ctv/normalize-property-text (d/nilv value ""))]
(doseq [id component-ids]
(st/emit!
(ev/event {::ev/name "variant-edit-property-value" ::ev/origin "workspace:combo-design-tab"})
@@ -401,11 +400,11 @@
(mf/use-fn
(mf/deps variant-id)
(fn [event]
(let [value (str/trim (dom/get-target-val event))
(let [value (ctv/normalize-property-text (dom/get-target-val event))
pos (-> (dom/get-current-target event)
(dom/get-data "position")
int)]
(when (seq value)
(when (ctv/valid-property-name? value)
(st/emit!
(dwv/update-property-name variant-id pos value {:trigger "workspace:design-tab-variant"}))))))
@@ -743,17 +742,6 @@
(->> (concat groups components)
(sort-by :name)))
find-parent-components
(mf/use-fn
(mf/deps objects)
(fn [shape]
(->> (cfh/get-parents objects (:id shape))
(map :component-id)
(remove nil?))))
;; Get the ids of the components that are parents of the shapes, to avoid loops
parent-components (mapcat find-parent-components shapes)
libraries-options (map (fn [library] {:value (:id library)
:label (:name library)})
(vals libraries))
@@ -843,10 +831,9 @@
(let [data (dm/get-in libraries [current-library-id :data])
container (ctf/get-component-page data item)
root-shape (ctf/get-component-root data item)
components (->> (cfh/get-children-with-self (:objects container) (:id root-shape))
(keep :component-id)
set)
loop? (some #(contains? components %) parent-components)]
loop? (some #(dwl/component-swap-nesting-loop?
objects % data (:id item))
shapes)]
[:> component-swap-item* {:key (dm/str (:id item))
:item item
:loop loop?
@@ -1243,11 +1230,11 @@
(mf/use-fn
(mf/deps variant-id)
(fn [event]
(let [value (dom/get-target-val event)
(let [value (ctv/normalize-property-text (dom/get-target-val event))
pos (-> (dom/get-current-target event)
(dom/get-data "position")
int)]
(when (seq value)
(when (ctv/valid-property-name? value)
(st/emit!
(dwv/update-property-name variant-id pos value {:trigger "workspace:design-tab-component"}))))))
@@ -1258,7 +1245,7 @@
(let [pos (-> (dom/get-current-target event)
(dom/get-data "position")
int)]
(when (> (count properties) 1)
(when (ctv/can-remove-property? properties)
(st/emit!
(ev/event {::ev/name "variant-remove-property" ::ev/origin "workspace:button-design-tab"})
(dwv/remove-property variant-id pos))))))
@@ -245,17 +245,13 @@
(fn [event]
(let [target (dom/get-target event)
value (dom/get-value target)
has-prefix? (or (str/starts-with? value "http://")
(str/starts-with? value "https://"))
value (if has-prefix?
value
(str "http://" value))]
(when-not has-prefix?
(dom/set-value! target value))
(if (dom/valid? target)
normalized (ctsi/normalize-url value)]
(when (and normalized (not= normalized value))
(dom/set-value! target normalized))
(if normalized
(do
(dom/remove-class! target "error")
(update-interaction index #(ctsi/set-url % value)))
(update-interaction index #(ctsi/set-url % normalized)))
(dom/add-class! target "error")))))
change-overlay-pos-type
@@ -620,8 +620,8 @@
:options size-options
:type "number"
:placeholder (tr "settings.multiple")
:min 3
:max 1000
:min txt/font-size-min
:max txt/font-size-max
:on-change on-font-size-change
:on-blur on-blur}])]
@@ -669,8 +669,8 @@
:alt (tr "workspace.options.text-options.line-height")}
deprecated-icon/text-lineheight]
[:> deprecated-input/numeric-input*
{:min -200
:max 200
{:min txt/spacing-min
:max txt/spacing-max
:step 0.1
:default-value "1.2"
:class (stl/css :line-height-input)
@@ -688,8 +688,8 @@
:alt (tr "workspace.options.text-options.letter-spacing")}
deprecated-icon/text-letterspacing]
[:> deprecated-input/numeric-input*
{:min -200
:max 200
{:min txt/spacing-min
:max txt/spacing-max
:step 0.1
:default-value "0"
:class (stl/css :letter-spacing-input)
@@ -138,8 +138,8 @@
(mf/use-fn
(mf/deps id is-separator?)
(fn [event]
(let [new-name (str/trim (dom/get-target-val event))]
(if (str/empty? new-name)
(let [new-name (ctp/normalize-page-name (dom/get-target-val event))]
(if (not (ctp/valid-page-name? new-name))
(when is-separator?
(st/emit! (dw/delete-page id)))
(st/emit! (dw/rename-page id new-name))))
+10 -15
View File
@@ -14,7 +14,6 @@
[app.common.geom.point :as gpt]
[app.common.schema :as sm]
[app.common.types.color :as ctc]
[app.common.types.component :as ctk]
[app.common.types.shape :as cts]
[app.common.types.text :as txt]
[app.common.uuid :as uuid]
@@ -705,13 +704,15 @@
:createVariantFromComponents
(fn [shapes]
(cond
(or (not (seq shapes))
(or (not (array? shapes))
(not (seq shapes))
(not (every? u/is-main-component-proxy? shapes)))
(u/not-valid plugin-id :shapes shapes)
:else
(let [file-id (obj/get (first shapes) "$file")
page-id (obj/get (first shapes) "$page")
(let [state @st/state
file-id (:current-file-id state)
page-id (:current-page-id state)
;; Keep the input order: it determines the order of the
;; resulting variant components (see combine-as-variants)
ids (->> shapes
@@ -719,23 +720,17 @@
(distinct)
(vec))
;; Check that every component is:
;; - in the same page
;; - not already a variant
valid?
(every?
(fn [id]
(let [shape (u/locate-shape file-id page-id id)
component (u/locate-library-component file-id (:component-id shape))]
(not (ctk/is-variant? component))))
ids)]
valid? (and (every? #(and (= file-id (obj/get % "$file"))
(= page-id (obj/get % "$page")))
shapes)
(dwv/valid-components-for-variants? state page-id ids))]
(if valid?
(let [variant-id (uuid/next)]
(st/emit! (-> (dwv/combine-as-variants
ids
{:trigger "plugin:combine-as-variants" :variant-id variant-id})
(se/add-event plugin-id)))
(shape/shape-proxy plugin-id variant-id))
(shape/shape-proxy plugin-id file-id page-id variant-id))
(u/not-valid plugin-id :shapes "One of the components is not on the same page or is already a variant")))))
+2 -2
View File
@@ -61,7 +61,7 @@
(fn [content]
(let [profile (:profile @st/state)]
(cond
(or (not (string? content)) (empty? content))
(not (dc/valid-comment-content? content))
(u/not-valid plugin-id :content "Not valid")
(not= (:id profile) (:owner-id data))
@@ -188,7 +188,7 @@
(not (r/check-permission plugin-id "comment:write"))
(u/not-valid plugin-id :reply "Plugin doesn't have 'comment:write' permission")
(or (not (string? content)) (empty? content))
(not (dc/valid-comment-content? content))
(u/not-valid plugin-id :reply "Not valid")
:else
+12 -12
View File
@@ -165,7 +165,7 @@
:set
(fn [_ value]
(cond
(not (sm/valid-safe-number? value))
(not (sm/valid-non-negative-safe-number? value))
(u/not-valid plugin-id :rowGap value)
(not (r/check-permission plugin-id "content:write"))
@@ -183,7 +183,7 @@
:set
(fn [_ value]
(cond
(not (sm/valid-safe-number? value))
(not (sm/valid-non-negative-safe-number? value))
(u/not-valid plugin-id :columnGap value)
(not (r/check-permission plugin-id "content:write"))
@@ -201,7 +201,7 @@
:set
(fn [this value]
(cond
(not (sm/valid-safe-number? value))
(not (sm/valid-non-negative-safe-number? value))
(u/not-valid plugin-id :verticalPadding value)
(not (r/check-permission plugin-id "content:write"))
@@ -219,7 +219,7 @@
:set
(fn [this value]
(cond
(not (sm/valid-safe-number? value))
(not (sm/valid-non-negative-safe-number? value))
(u/not-valid plugin-id :horizontalPadding value)
(not (r/check-permission plugin-id "content:write"))
@@ -237,7 +237,7 @@
:set
(fn [this value]
(cond
(not (sm/valid-safe-number? value))
(not (sm/valid-non-negative-safe-number? value))
(u/not-valid plugin-id :topPadding value)
(not (r/check-permission plugin-id "content:write"))
@@ -255,7 +255,7 @@
:set
(fn [this value]
(cond
(not (sm/valid-safe-number? value))
(not (sm/valid-non-negative-safe-number? value))
(u/not-valid plugin-id :rightPadding value)
(not (r/check-permission plugin-id "content:write"))
@@ -273,7 +273,7 @@
:set
(fn [this value]
(cond
(not (sm/valid-safe-number? value))
(not (sm/valid-non-negative-safe-number? value))
(u/not-valid plugin-id :bottomPadding value)
(not (r/check-permission plugin-id "content:write"))
@@ -291,7 +291,7 @@
:set
(fn [this value]
(cond
(not (sm/valid-safe-number? value))
(not (sm/valid-non-negative-safe-number? value))
(u/not-valid plugin-id :leftPadding value)
(not (r/check-permission plugin-id "content:write"))
@@ -641,7 +641,7 @@
:set
(fn [_ value]
(cond
(not (sm/valid-safe-number? value))
(not (sm/valid-non-negative-safe-number? value))
(u/not-valid plugin-id :maxWidth value)
(not (r/check-permission plugin-id "content:write"))
@@ -659,7 +659,7 @@
:set
(fn [_ value]
(cond
(not (sm/valid-safe-number? value))
(not (sm/valid-non-negative-safe-number? value))
(u/not-valid plugin-id :minWidth value)
(not (r/check-permission plugin-id "content:write"))
@@ -677,7 +677,7 @@
:set
(fn [_ value]
(cond
(not (sm/valid-safe-number? value))
(not (sm/valid-non-negative-safe-number? value))
(u/not-valid plugin-id :maxHeight value)
(not (r/check-permission plugin-id "content:write"))
@@ -695,7 +695,7 @@
:set
(fn [_ value]
(cond
(not (sm/valid-safe-number? value))
(not (sm/valid-non-negative-safe-number? value))
(u/not-valid plugin-id :minHeight value)
(not (r/check-permission plugin-id "content:write"))
+14 -3
View File
@@ -21,8 +21,9 @@
(defn font-variant-proxy? [p]
(obj/type-of? p "FontVariantProxy"))
(defn font-variant-proxy [name id weight style]
(defn font-variant-proxy [font-id name id weight style]
(obj/reify {:name "FontVariantProxy"}
:$font-id {:enumerable false :get (constantly font-id)}
:name {:get (fn [] name)}
:fontVariantId {:get (fn [] id)}
:fontWeight {:get (fn [] weight)}
@@ -47,8 +48,8 @@
{:get
(fn []
(format/format-array
(fn [{:keys [id name style weight]}]
(font-variant-proxy name id weight style))
(fn [{variant-id :id :keys [name style weight]}]
(font-variant-proxy id name variant-id weight style))
variants))}
:applyToText
@@ -63,6 +64,11 @@
(not (u/page-active? (obj/get text "$page")))
(u/not-valid plugin-id :applyToText "Cannot modify a page that is not currently active")
(and (some? variant)
(or (not (font-variant-proxy? variant))
(not= id (obj/get variant "$font-id"))))
(u/not-valid plugin-id :applyToText variant)
:else
(let [text-id (obj/get text "$id")
values {:font-id id
@@ -84,6 +90,11 @@
(not (u/page-active? (obj/get range "$page")))
(u/not-valid plugin-id :applyToRange "Cannot modify a page that is not currently active")
(and (some? variant)
(or (not (font-variant-proxy? variant))
(not= id (obj/get variant "$font-id"))))
(u/not-valid plugin-id :applyToRange variant)
:else
(let [range-id (obj/get range "$id")
start (obj/get range "$start")
+15 -15
View File
@@ -190,7 +190,7 @@
:set
(fn [_ value]
(cond
(not (sm/valid-safe-number? value))
(not (sm/valid-non-negative-safe-number? value))
(u/not-valid plugin-id :rowGap value)
(not (r/check-permission plugin-id "content:write"))
@@ -208,7 +208,7 @@
:set
(fn [_ value]
(cond
(not (sm/valid-safe-number? value))
(not (sm/valid-non-negative-safe-number? value))
(u/not-valid plugin-id :columnGap value)
(not (r/check-permission plugin-id "content:write"))
@@ -226,7 +226,7 @@
:set
(fn [this value]
(cond
(not (sm/valid-safe-number? value))
(not (sm/valid-non-negative-safe-number? value))
(u/not-valid plugin-id :verticalPadding value)
(not (r/check-permission plugin-id "content:write"))
@@ -244,7 +244,7 @@
:set
(fn [this value]
(cond
(not (sm/valid-safe-number? value))
(not (sm/valid-non-negative-safe-number? value))
(u/not-valid plugin-id :horizontalPadding value)
(not (r/check-permission plugin-id "content:write"))
@@ -262,7 +262,7 @@
:set
(fn [this value]
(cond
(not (sm/valid-safe-number? value))
(not (sm/valid-non-negative-safe-number? value))
(u/not-valid plugin-id :topPadding value)
(not (r/check-permission plugin-id "content:write"))
@@ -280,7 +280,7 @@
:set
(fn [this value]
(cond
(not (sm/valid-safe-number? value))
(not (sm/valid-non-negative-safe-number? value))
(u/not-valid plugin-id :rightPadding value)
(not (r/check-permission plugin-id "content:write"))
@@ -298,7 +298,7 @@
:set
(fn [this value]
(cond
(not (sm/valid-safe-number? value))
(not (sm/valid-non-negative-safe-number? value))
(u/not-valid plugin-id :bottomPadding value)
(not (r/check-permission plugin-id "content:write"))
@@ -316,7 +316,7 @@
:set
(fn [this value]
(cond
(not (sm/valid-safe-number? value))
(not (sm/valid-non-negative-safe-number? value))
(u/not-valid plugin-id :leftPadding value)
(not (r/check-permission plugin-id "content:write"))
@@ -356,7 +356,7 @@
(u/not-valid plugin-id :addRow-type type)
(and (or (= :percent type) (= :flex type) (= :fixed type))
(not (sm/valid-safe-number? value)))
(not (sm/valid-non-negative-safe-number? value)))
(u/not-valid plugin-id :addRow-value value)
(not (r/check-permission plugin-id "content:write"))
@@ -383,7 +383,7 @@
(u/not-valid plugin-id :addRowAtIndex-type type)
(and (or (= :percent type) (= :flex type) (= :fixed type))
(not (sm/valid-safe-number? value)))
(not (sm/valid-non-negative-safe-number? value)))
(u/not-valid plugin-id :addRowAtIndex-value value)
(not (r/check-permission plugin-id "content:write"))
@@ -402,8 +402,8 @@
(not (contains? ctl/grid-track-types type))
(u/not-valid plugin-id :addColumn-type type)
(and (or (= :percent type) (= :flex type) (= :lex type))
(not (sm/valid-safe-number? value)))
(and (or (= :percent type) (= :flex type) (= :fixed type))
(not (sm/valid-non-negative-safe-number? value)))
(u/not-valid plugin-id :addColumn-value value)
(not (r/check-permission plugin-id "content:write"))
@@ -430,7 +430,7 @@
(u/not-valid plugin-id :addColumnAtIndex-type type)
(and (or (= :percent type) (= :flex type) (= :fixed type))
(not (sm/valid-safe-number? value)))
(not (sm/valid-non-negative-safe-number? value)))
(u/not-valid plugin-id :addColumnAtIndex-value value)
(not (r/check-permission plugin-id "content:write"))
@@ -495,7 +495,7 @@
(u/not-valid plugin-id :setColumn-type type)
(and (or (= :percent type) (= :flex type) (= :fixed type))
(not (sm/valid-safe-number? value)))
(not (sm/valid-non-negative-safe-number? value)))
(u/not-valid plugin-id :setColumn-value value)
(not (r/check-permission plugin-id "content:write"))
@@ -522,7 +522,7 @@
(u/not-valid plugin-id :setRow-type type)
(and (or (= :percent type) (= :flex type) (= :fixed type))
(not (sm/valid-safe-number? value)))
(not (sm/valid-non-negative-safe-number? value)))
(u/not-valid plugin-id :setRow-value value)
(not (r/check-permission plugin-id "content:write"))
+122 -80
View File
@@ -14,12 +14,15 @@
[app.common.types.color :as clr]
[app.common.types.component :as ctk]
[app.common.types.file :as ctf]
[app.common.types.text :as txt]
[app.common.types.typography :as ctt]
[app.common.types.variant :as ctv]
[app.common.uuid :as uuid]
[app.main.data.plugins :as dp]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.texts :as dwt]
[app.main.data.workspace.variants :as dwv]
[app.main.fonts :as fonts]
[app.main.repo :as rp]
[app.main.store :as st]
[app.plugins.format :as format]
@@ -32,6 +35,7 @@
[app.plugins.utils :as u]
[app.util.object :as obj]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[potok.v2.core :as ptk]))
(declare lib-color-proxy)
@@ -289,6 +293,20 @@
(defn lib-typography-proxy? [p]
(obj/type-of? p "LibraryTypographyProxy"))
(defn- font-data
[font variant]
{:font-id (:id font)
:font-family (:family font)
:font-variant-id (:id variant)
:font-style (:style variant)
:font-weight (:weight variant)})
(defn- variant-data
[variant]
{:font-variant-id (:id variant)
:font-style (:style variant)
:font-weight (:weight variant)})
(defn lib-typography-proxy
[plugin-id file-id id]
(assert (uuid? file-id))
@@ -341,136 +359,150 @@
:get #(-> % u/proxy->library-typography :font-id)
:set
(fn [self value]
(cond
(not (string? value))
(u/not-valid plugin-id :fontId value)
(let [font (when (string? value) (fonts/get-font-data value))
variant (fonts/get-default-variant font)]
(cond
(nil? font)
(u/not-valid plugin-id :fontId value)
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :fontId "Plugin doesn't have 'library:write' permission")
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :fontId "Plugin doesn't have 'library:write' permission")
:else
(let [typo (-> (u/proxy->library-typography self)
(assoc :font-id value))]
(st/emit! (dwl/update-typography typo file-id)))))}
:else
(let [typo (-> (u/proxy->library-typography self)
(merge (font-data font variant)))]
(st/emit! (dwl/update-typography typo file-id))))))}
:fontFamily
{:this true
:get #(-> % u/proxy->library-typography :font-family)
:set
(fn [self value]
(cond
(not (string? value))
(u/not-valid plugin-id :fontFamily value)
(let [font (when (string? value) (fonts/find-font-data {:family value}))
variant (fonts/get-default-variant font)]
(cond
(nil? font)
(u/not-valid plugin-id :fontFamily value)
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :fontFamily "Plugin doesn't have 'library:write' permission")
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :fontFamily "Plugin doesn't have 'library:write' permission")
:else
(let [typo (-> (u/proxy->library-typography self)
(assoc :font-family value))]
(st/emit! (dwl/update-typography typo file-id)))))}
:else
(let [typo (-> (u/proxy->library-typography self)
(merge (font-data font variant)))]
(st/emit! (dwl/update-typography typo file-id))))))}
:fontVariantId
{:this true
:get #(-> % u/proxy->library-typography :font-variant-id)
:set
(fn [self value]
(cond
(not (string? value))
(u/not-valid plugin-id :fontVariantId value)
(let [typo (u/proxy->library-typography self)
font (fonts/get-font-data (:font-id typo))
variant (when (string? value) (fonts/find-variant font {:id value}))]
(cond
(nil? variant)
(u/not-valid plugin-id :fontVariantId value)
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :fontVariantId "Plugin doesn't have 'library:write' permission")
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :fontVariantId "Plugin doesn't have 'library:write' permission")
:else
(let [typo (-> (u/proxy->library-typography self)
(assoc :font-variant-id value))]
(st/emit! (dwl/update-typography typo file-id)))))}
:else
(st/emit! (dwl/update-typography (merge typo (variant-data variant)) file-id)))))}
:fontSize
{:this true
:get #(-> % u/proxy->library-typography :font-size)
:set
(fn [self value]
(cond
(not (string? value))
(u/not-valid plugin-id :fontSize value)
(let [value (some-> value str/trim)]
(cond
(not (txt/valid-font-size? value))
(u/not-valid plugin-id :fontSize value)
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :fontSize "Plugin doesn't have 'library:write' permission")
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :fontSize "Plugin doesn't have 'library:write' permission")
:else
(let [typo (-> (u/proxy->library-typography self)
(assoc :font-size value))]
(st/emit! (dwl/update-typography typo file-id)))))}
:else
(let [typo (-> (u/proxy->library-typography self)
(assoc :font-size value))]
(st/emit! (dwl/update-typography typo file-id))))))}
:fontWeight
{:this true
:get #(-> % u/proxy->library-typography :font-weight)
:set
(fn [self value]
(cond
(not (string? value))
(u/not-valid plugin-id :fontWeight value)
(let [typo (u/proxy->library-typography self)
font (fonts/get-font-data (:font-id typo))
variant (when (string? value)
(or (fonts/find-variant font {:style (:font-style typo) :weight value})
(fonts/find-variant font {:weight value})))]
(cond
(nil? variant)
(u/not-valid plugin-id :fontWeight value)
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :fontWeight "Plugin doesn't have 'library:write' permission")
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :fontWeight "Plugin doesn't have 'library:write' permission")
:else
(let [typo (-> (u/proxy->library-typography self)
(assoc :font-weight value))]
(st/emit! (dwl/update-typography typo file-id)))))}
:else
(st/emit! (dwl/update-typography (merge typo (variant-data variant)) file-id)))))}
:fontStyle
{:this true
:get #(-> % u/proxy->library-typography :font-style)
:set
(fn [self value]
(cond
(not (string? value))
(u/not-valid plugin-id :fontStyle value)
(let [typo (u/proxy->library-typography self)
font (fonts/get-font-data (:font-id typo))
variant (when (string? value)
(or (fonts/find-variant font {:weight (:font-weight typo) :style value})
(fonts/find-variant font {:style value})))]
(cond
(nil? variant)
(u/not-valid plugin-id :fontStyle value)
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :fontStyle "Plugin doesn't have 'library:write' permission")
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :fontStyle "Plugin doesn't have 'library:write' permission")
:else
(let [typo (-> (u/proxy->library-typography self)
(assoc :font-style value))]
(st/emit! (dwl/update-typography typo file-id)))))}
:else
(st/emit! (dwl/update-typography (merge typo (variant-data variant)) file-id)))))}
:lineHeight
{:this true
:get #(-> % u/proxy->library-typography :font-height)
:get #(-> % u/proxy->library-typography :line-height)
:set
(fn [self value]
(cond
(not (string? value))
(u/not-valid plugin-id :lineHeight value)
(let [value (some-> value str/trim)]
(cond
(not (txt/valid-line-height? value))
(u/not-valid plugin-id :lineHeight value)
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :lineHeight "Plugin doesn't have 'library:write' permission")
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :lineHeight "Plugin doesn't have 'library:write' permission")
:else
(let [typo (-> (u/proxy->library-typography self)
(assoc :font-height value))]
(st/emit! (dwl/update-typography typo file-id)))))}
:else
(let [typo (-> (u/proxy->library-typography self)
(assoc :line-height value))]
(st/emit! (dwl/update-typography typo file-id))))))}
:letterSpacing
{:this true
:get #(-> % u/proxy->library-typography :letter-spacing)
:set
(fn [self value]
(cond
(not (string? value))
(u/not-valid plugin-id :letterSpacing value)
(let [value (some-> value str/trim)]
(cond
(not (txt/valid-letter-spacing? value))
(u/not-valid plugin-id :letterSpacing value)
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :letterSpacing "Plugin doesn't have 'library:write' permission")
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :letterSpacing "Plugin doesn't have 'library:write' permission")
:else
(let [typo (-> (u/proxy->library-typography self)
(assoc :letter-spacing value))]
(st/emit! (dwl/update-typography typo file-id)))))}
:else
(let [typo (-> (u/proxy->library-typography self)
(assoc :letter-spacing value))]
(st/emit! (dwl/update-typography typo file-id))))))}
:textTransform
{:this true
@@ -478,7 +510,7 @@
:set
(fn [self value]
(cond
(not (string? value))
(not (txt/valid-text-transform? value))
(u/not-valid plugin-id :textTransform value)
(not (r/check-permission plugin-id "library:write"))
@@ -495,6 +527,11 @@
(not (obj/type-of? font "FontProxy"))
(u/not-valid plugin-id :setFont font)
(and (some? variant)
(or (not (obj/type-of? variant "FontVariantProxy"))
(not= (obj/get font "fontId") (obj/get variant "$font-id"))))
(u/not-valid plugin-id :setFont variant)
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :setFont "Plugin doesn't have 'library:write' permission")
@@ -720,7 +757,8 @@
:removeProperty
(fn [pos]
(let [nprops (->> (get-variant-components file-id id) first :variant-properties count)]
(let [properties (->> (get-variant-components file-id id) first :variant-properties)
nprops (count properties)]
(cond
(or (not (nat-int? pos)) (>= pos nprops))
(u/not-valid plugin-id :pos pos)
@@ -728,6 +766,9 @@
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :removeProperty "Plugin doesn't have 'library:write' permission")
(not (ctv/can-remove-property? properties))
(u/not-valid plugin-id :removeProperty "A variant must keep at least one property")
:else
(st/emit!
(se/event plugin-id "remove-property")
@@ -740,7 +781,7 @@
(or (not (nat-int? pos)) (>= pos nprops))
(u/not-valid plugin-id :pos pos)
(not (string? name))
(not (ctv/valid-property-name? name))
(u/not-valid plugin-id :name name)
(not (r/check-permission plugin-id "library:write"))
@@ -748,7 +789,8 @@
:else
(st/emit!
(dwv/update-property-name id pos name {:trigger "plugin:rename-property"})))))))
(dwv/update-property-name id pos (ctv/normalize-property-text name)
{:trigger "plugin:rename-property"})))))))
(set! shape/variant-proxy variant-proxy)
@@ -978,8 +1020,8 @@
(or (not (nat-int? pos)) (>= pos nprops))
(u/not-valid plugin-id :pos (str pos))
(not (string? value))
(u/not-valid plugin-id :name value)
(not (ctv/valid-property-value? value))
(u/not-valid plugin-id :value value)
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :setVariantProperty "Plugin doesn't have 'library:write' permission")
@@ -987,7 +1029,7 @@
:else
(st/emit!
(se/event plugin-id "variant-edit-property-value")
(dwv/update-property-value id pos value)))))))
(dwv/update-property-value id pos (ctv/normalize-property-text value))))))))
(defn library-proxy? [p]
(obj/type-of? p "LibraryProxy"))
+43 -30
View File
@@ -12,6 +12,7 @@
[app.common.geom.point :as gpt]
[app.common.schema :as sm]
[app.common.types.color :as cc]
[app.common.types.page :as ctp]
[app.common.uuid :as uuid]
[app.main.data.comments :as dc]
[app.main.data.common :as dcm]
@@ -78,15 +79,19 @@
(shape/shape-proxy plugin-id file-id page-id frame)))
:set
(fn [_ value]
(cond
(not (shape/shape-proxy? value))
(u/not-valid plugin-id :startingBoard value)
(let [page (u/locate-page file-id page-id)]
(cond
(or (not (shape/shape-proxy? value))
(not= file-id (obj/get value "$file"))
(not= page-id (obj/get value "$page"))
(not (ctp/valid-flow-starting-frame? page (obj/get value "$id") id)))
(u/not-valid plugin-id :startingBoard value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :startingBoard "Plugin doesn't have 'content:write' permission")
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :startingBoard "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwi/update-flow page-id id #(assoc % :starting-frame (obj/get value "$id"))))))}
:else
(st/emit! (dwi/update-flow page-id id #(assoc % :starting-frame (obj/get value "$id")))))))}
:remove
(fn []
@@ -115,15 +120,16 @@
:get #(-> % u/proxy->page :name)
:set
(fn [_ value]
(cond
(not (string? value))
(u/not-valid plugin-id :name value)
(let [value (ctp/normalize-page-name value)]
(cond
(not (ctp/valid-page-name? value))
(u/not-valid plugin-id :name value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission")
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dw/rename-page id value))))}
:else
(st/emit! (dw/rename-page id value)))))}
:getRoot
(fn []
@@ -319,22 +325,26 @@
:createFlow
(fn [name frame]
(cond
(or (not (string? name)) (empty? name))
(u/not-valid plugin-id :createFlow-name name)
(let [page (u/locate-page file-id id)]
(cond
(or (not (string? name)) (empty? name))
(u/not-valid plugin-id :createFlow-name name)
(not (shape/shape-proxy? frame))
(u/not-valid plugin-id :createFlow-frame frame)
(or (not (shape/shape-proxy? frame))
(not= file-id (obj/get frame "$file"))
(not= id (obj/get frame "$page"))
(not (ctp/valid-flow-starting-frame? page (obj/get frame "$id") nil)))
(u/not-valid plugin-id :createFlow-frame frame)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :createFlow "Plugin doesn't have 'content:write' permission")
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :createFlow "Plugin doesn't have 'content:write' permission")
:else
(let [flow-id (uuid/next)]
(st/emit!
(dwi/add-flow flow-id id name (obj/get frame "$id"))
(se/event plugin-id "add-flow"))
(flow-proxy plugin-id file-id id flow-id))))
:else
(let [flow-id (uuid/next)]
(st/emit!
(dwi/add-flow flow-id id name (obj/get frame "$id"))
(se/event plugin-id "add-flow"))
(flow-proxy plugin-id file-id id flow-id)))))
:removeFlow
(fn [flow]
@@ -352,7 +362,8 @@
:addRulerGuide
(fn [orientation value board]
(let [shape (u/proxy->shape board)]
(let [shape (when (shape/shape-proxy? board)
(u/locate-shape file-id id (obj/get board "$id")))]
(cond
(not (sm/valid-safe-number? value))
(u/not-valid plugin-id :addRulerGuide "Value not a safe number")
@@ -360,8 +371,10 @@
(not (contains? #{"vertical" "horizontal"} orientation))
(u/not-valid plugin-id :addRulerGuide "Orientation should be either 'vertical' or 'horizontal'")
(and (some? shape)
(and (some? board)
(or (not (shape/shape-proxy? board))
(not= file-id (obj/get board "$file"))
(not= id (obj/get board "$page"))
(not (cfh/frame-shape? shape))))
(u/not-valid plugin-id :addRulerGuide "The shape is not a board")
@@ -405,7 +418,7 @@
(let [shape (when board (u/proxy->shape board))
position (parser/parse-point position)]
(cond
(or (not (string? content)) (empty? content))
(not (dc/valid-comment-content? content))
(u/not-valid plugin-id :addCommentThread "Content not valid")
(or (not (sm/valid-safe-number? (:x position)))
+2 -1
View File
@@ -10,6 +10,7 @@
[app.common.geom.point :as gpt]
[app.common.json :as json]
[app.common.types.path :as path]
[app.common.types.shape.interactions :as ctsi]
[app.common.uuid :as uuid]
[app.util.object :as obj]
[cuerdas.core :as str]))
@@ -506,7 +507,7 @@
:open-url
{:action-type action-type
:url (obj/get action "url")}
:url (ctsi/normalize-url (obj/get action "url"))}
nil)))))
+72 -35
View File
@@ -64,7 +64,7 @@
[app.plugins.strokes :as strokes]
[app.plugins.system-events :as se]
[app.plugins.text :as text]
[app.plugins.tokens :refer [applied-tokens-plugin->applied-tokens token-attr-plugin->token-attr token-attr?]]
[app.plugins.tokens :refer [applied-tokens-plugin->applied-tokens token-attr-plugin->token-attr token-attr? valid-token-resolution?]]
[app.plugins.utils :as u]
[app.util.http :as http]
[app.util.object :as obj]
@@ -79,6 +79,21 @@
(defn interaction-proxy? [p]
(obj/type-of? p "InteractionProxy"))
(defn- valid-interaction-action?
[file-id page-id source raw-action interaction]
(let [page (u/locate-page file-id page-id)
destination-proxy (obj/get raw-action "destination")
destination-id (:destination interaction)
animation-type (get-in interaction [:animation :animation-type])]
(and (sm/validate ctsi/schema:interaction interaction)
(ctsi/valid-delay? interaction)
(or (nil? destination-proxy)
(and (shape-proxy? destination-proxy)
(= file-id (obj/get destination-proxy "$file"))
(= page-id (obj/get destination-proxy "$page"))))
(ctsi/valid-destination? (:objects page) source destination-id)
(ctsi/allowed-animation? (:action-type interaction) animation-type))))
(defn interaction-proxy
[plugin-id file-id page-id shape-id index]
(obj/reify {:name "InteractionProxy"}
@@ -98,9 +113,10 @@
:get #(-> % u/proxy->interaction :event-type format/format-key)
:set
(fn [_ value]
(let [value (parser/parse-keyword value)]
(let [value (parser/parse-keyword value)
shape (u/locate-shape file-id page-id shape-id)]
(cond
(not (contains? ctsi/event-types value))
(not (ctsi/valid-event-type-for-shape? shape value))
(u/not-valid plugin-id :trigger value)
(not (r/check-permission plugin-id "content:write"))
@@ -108,9 +124,9 @@
:else
(st/emit! (dwi/update-interaction
(u/locate-shape file-id page-id shape-id)
shape
index
#(assoc % :event-type value)
#(ctsi/set-event-type % value shape)
{:page-id page-id})))))}
:delay
@@ -137,12 +153,13 @@
:get #(-> % u/proxy->interaction (format/format-action plugin-id file-id page-id))
:set
(fn [self value]
(let [params (parser/parse-action value)
(let [shape (u/locate-shape file-id page-id shape-id)
params (parser/parse-action value)
interaction
(-> (u/proxy->interaction self)
(d/patch-object params))]
(cond
(not (sm/validate ctsi/schema:interaction interaction))
(not (valid-interaction-action? file-id page-id shape value interaction))
(u/not-valid plugin-id :action interaction)
(not (r/check-permission plugin-id "content:write"))
@@ -507,7 +524,7 @@
(fn [self value]
(let [id (obj/get self "$id")]
(cond
(not (sm/valid-safe-number? value))
(not (sm/valid-non-negative-safe-number? value))
(u/not-valid plugin-id :borderRadiusTopLeft value)
(not (r/check-permission plugin-id "content:write"))
@@ -526,7 +543,7 @@
(fn [self value]
(let [id (obj/get self "$id")]
(cond
(not (sm/valid-safe-number? value))
(not (sm/valid-non-negative-safe-number? value))
(u/not-valid plugin-id :borderRadiusTopRight value)
(not (r/check-permission plugin-id "content:write"))
@@ -545,7 +562,7 @@
(fn [self value]
(let [id (obj/get self "$id")]
(cond
(not (sm/valid-safe-number? value))
(not (sm/valid-non-negative-safe-number? value))
(u/not-valid plugin-id :borderRadiusBottomRight value)
(not (r/check-permission plugin-id "content:write"))
@@ -564,7 +581,7 @@
(fn [self value]
(let [id (obj/get self "$id")]
(cond
(not (sm/valid-safe-number? value))
(not (sm/valid-non-negative-safe-number? value))
(u/not-valid plugin-id :borderRadiusBottomLeft value)
(not (r/check-permission plugin-id "content:write"))
@@ -1500,7 +1517,12 @@
:swapComponent
(fn [component]
(let [shape (u/locate-shape file-id page-id id)]
(let [shape (u/locate-shape file-id page-id id)
objects (u/locate-objects file-id page-id)
valid-component? (obj/type-of? component "LibraryComponentProxy")
target-file (when valid-component? (obj/get component "$file"))
target-id (when valid-component? (obj/get component "$id"))
target-data (some-> (u/locate-file target-file) :data)]
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :swapComponent "Plugin doesn't have 'content:write' permission")
@@ -1508,16 +1530,19 @@
(not (u/page-active? page-id))
(u/not-valid plugin-id :swapComponent "Cannot modify a page that is not currently active")
(not (obj/type-of? component "LibraryComponentProxy"))
(not valid-component?)
(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")
(dwl/component-swap-nesting-loop? objects shape target-data target-id)
(u/not-valid plugin-id :swapComponent "The swap would create a component nesting loop")
:else
(st/emit! (dwl/component-swap shape
(obj/get component "$file")
(obj/get component "$id")
target-file
target-id
true)))))
:resetOverrides
@@ -1611,11 +1636,15 @@
;; Interactions
:addInteraction
(fn [trigger action delay]
(let [interaction
(-> ctsi/default-interaction
(d/patch-object (parser/parse-interaction trigger action delay)))]
(let [shape (u/locate-shape file-id page-id id)
event-type (parser/parse-keyword trigger)
interaction (when (ctsi/valid-event-type-for-shape? shape event-type)
(-> ctsi/default-interaction
(ctsi/set-event-type event-type shape)
(d/patch-object (parser/parse-action action))
(cond-> (some? delay) (assoc :delay delay))))]
(cond
(not (sm/validate ctsi/schema:interaction interaction))
(not (valid-interaction-action? file-id page-id shape action interaction))
(u/not-valid plugin-id :addInteraction interaction)
(not (r/check-permission plugin-id "content:write"))
@@ -1715,15 +1744,23 @@
[:fn token-proxy?]
[:maybe [::sm/set [:and ::sm/keyword [:fn token-attr?]]]]]
:fn (fn [token attrs]
(let [token (u/locate-token file-id (obj/get token "$set-id") (obj/get token "$id"))
(let [set-id (obj/get token "$set-id")
token-id (obj/get token "$id")
token (u/locate-token file-id set-id token-id)
kw-attrs (into #{} (map token-attr-plugin->token-attr attrs))]
(cond
(some #(not (token-attr? %)) kw-attrs)
(u/not-valid plugin-id :applyToken attrs)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :applyToken "Plugin doesn't have 'content:write' permission")
(nil? token)
(u/not-valid plugin-id :applyToken token-id)
(not (valid-token-resolution? file-id set-id token-id))
(u/not-valid plugin-id :applyToken (:value token))
(some #(not (token-attr? %)) kw-attrs)
(u/not-valid plugin-id :applyToken attrs)
:else
(st/emit!
(-> (dwta/toggle-token {:token token
@@ -1746,6 +1783,12 @@
:switchVariant
(fn [pos value]
(cond
(not (u/page-active? page-id))
(u/not-valid plugin-id :switchVariant "Cannot modify a page that is not currently active")
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :switchVariant "Plugin doesn't have 'content:write' permission")
(not (nat-int? pos))
(u/not-valid plugin-id :pos pos)
@@ -1756,11 +1799,11 @@
(u/not-valid plugin-id :switchVariant "Plugin doesn't have 'content:write' permission")
:else
(let [shape (u/locate-shape file-id page-id id)
component (u/locate-library-component file-id (:component-id shape))]
(when (and component (ctk/is-variant? component))
(let [shape (u/locate-shape file-id page-id id)]
(if (dwv/valid-variant-switch? @st/state shape pos value)
(st/emit! (-> (dwv/variants-switch {:shapes [shape] :pos pos :val value})
(se/add-event plugin-id)))))))
(se/add-event plugin-id)))
(u/not-valid plugin-id :switchVariant "Shape, property, or value is not valid")))))
:combineAsVariants
(fn [ids]
@@ -1782,13 +1825,7 @@
(distinct))
ids)
valid?
(every?
(fn [id]
(let [shape (u/locate-shape file-id page-id id)
component (u/locate-library-component file-id (:component-id shape))]
(not (ctk/is-variant? component))))
ids)]
valid? (dwv/valid-components-for-variants? @st/state page-id ids)]
(if valid?
(let [variant-id (uuid/next)]
@@ -1796,7 +1833,7 @@
ids
{:trigger "plugin:combine-as-variants" :variant-id variant-id})
(se/add-event plugin-id)))
(shape-proxy plugin-id variant-id))
(shape-proxy plugin-id file-id page-id variant-id))
(u/not-valid plugin-id :ids "One of the components is not on the same page or is already a variant"))))))
+14 -19
View File
@@ -28,13 +28,6 @@
[app.util.text-editor :as ted]
[cuerdas.core :as str]))
;; This regex seems duplicated but probably in the future when we support diferent units
;; this will need to reflect changes for each property
(def ^:private font-size-re #"^\d*\.?\d*$")
(def ^:private line-height-re #"^\d*\.?\d*$")
(def ^:private letter-spacing-re #"^-?\d*\.?\d*$")
(def ^:private text-transform-re #"uppercase|capitalize|lowercase|none")
(def ^:private text-decoration-re #"underline|line-through|none")
(def ^:private text-direction-re #"ltr|rtl")
(def ^:private text-align-re #"left|center|right|justify")
@@ -160,7 +153,7 @@
(let [font (fonts/find-font-data {:family value})
variant (fonts/get-default-variant font)]
(cond
(not (string? value))
(nil? font)
(u/not-valid plugin-id :fontFamily value)
(not (r/check-permission plugin-id "content:write"))
@@ -182,9 +175,10 @@
:set
(fn [self value]
(let [font (fonts/get-font-data (obj/get self "fontId"))
variant (fonts/get-variant font value)]
variant (when (string? value)
(fonts/find-variant font {:id value}))]
(cond
(not (string? value))
(nil? variant)
(u/not-valid plugin-id :fontVariantId value)
(not (r/check-permission plugin-id "content:write"))
@@ -207,7 +201,7 @@
(fn [_ value]
(let [value (str/trim (dm/str value))]
(cond
(or (empty? value) (not (re-matches font-size-re value)))
(not (txt/valid-font-size? value))
(u/not-valid plugin-id :fontSize value)
(not (r/check-permission plugin-id "content:write"))
@@ -289,7 +283,7 @@
(fn [_ value]
(let [value (str/trim (dm/str value))]
(cond
(or (empty? value) (not (re-matches line-height-re value)))
(not (txt/valid-line-height? value))
(u/not-valid plugin-id :lineHeight value)
(not (r/check-permission plugin-id "content:write"))
@@ -312,7 +306,7 @@
(fn [_ value]
(let [value (str/trim (dm/str value))]
(cond
(or (not (string? value)) (not (re-matches letter-spacing-re value)))
(not (txt/valid-letter-spacing? value))
(u/not-valid plugin-id :letterSpacing value)
(not (r/check-permission plugin-id "content:write"))
@@ -334,7 +328,7 @@
:set
(fn [_ value]
(cond
(and (string? value) (not (re-matches text-transform-re value)))
(not (txt/valid-text-transform? value))
(u/not-valid plugin-id :textTransform value)
(not (r/check-permission plugin-id "content:write"))
@@ -550,7 +544,8 @@
(fn [self value]
(let [id (obj/get self "$id")
font (fonts/get-font-data (obj/get self "fontId"))
variant (fonts/get-variant font value)]
variant (when (string? value)
(fonts/find-variant font {:id value}))]
(cond
(not variant)
(u/not-valid plugin-id :fontVariantId value)
@@ -571,7 +566,7 @@
(let [id (obj/get self "$id")
value (str/trim (dm/str value))]
(cond
(or (empty? value) (not (re-matches font-size-re value)))
(not (txt/valid-font-size? value))
(u/not-valid plugin-id :fontSize value)
(not (r/check-permission plugin-id "content:write"))
@@ -640,7 +635,7 @@
(let [id (obj/get self "$id")
value (str/trim (dm/str value))]
(cond
(or (empty? value) (not (re-matches line-height-re value)))
(not (txt/valid-line-height? value))
(u/not-valid plugin-id :lineHeight value)
(not (r/check-permission plugin-id "content:write"))
@@ -659,7 +654,7 @@
(let [id (obj/get self "$id")
value (str/trim (dm/str value))]
(cond
(or (not (string? value)) (not (re-matches letter-spacing-re value)))
(not (txt/valid-letter-spacing? value))
(u/not-valid plugin-id :letterSpacing value)
(not (r/check-permission plugin-id "content:write"))
@@ -677,7 +672,7 @@
(fn [self value]
(let [id (obj/get self "$id")]
(cond
(or (not (string? value)) (not (re-matches text-transform-re value)))
(not (txt/valid-text-transform? value))
(u/not-valid plugin-id :textTransform value)
(not (r/check-permission plugin-id "content:write"))
+104 -45
View File
@@ -7,6 +7,7 @@
(ns app.plugins.tokens
(:require
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.files.tokens :as cfo]
[app.common.json :as json]
[app.common.schema :as sm]
@@ -84,6 +85,50 @@
[attr]
(cto/token-attr? (token-attr-plugin->token-attr attr)))
(defn- token-name-schema
[file-id set-id token]
(let [tokens-lib (u/locate-tokens-lib file-id)
tokens (-> (ctob/get-tokens tokens-lib set-id)
(dissoc (:name token))
(ctob/tokens-tree))]
(cfo/make-token-name-schema tokens)))
(defn- token-value-schema
[token]
(let [base (cfo/make-token-value-schema (:type token))]
(if (= :font-family (:type token))
[:or :string base]
base)))
(defn- normalize-token-value
[token value]
(case (:type token)
:font-family (ctob/convert-dtcg-font-family value)
:typography (ctob/convert-dtcg-typography-composite value)
:shadow (ctob/convert-dtcg-shadow-composite value)
value))
(defn- valid-token-candidate?
[file-id set-id token attrs]
(let [tokens-lib (u/locate-tokens-lib file-id)
candidate (merge (datafy token) attrs)
tokens (-> (merge (ctob/get-all-tokens-map tokens-lib)
(ctob/get-tokens tokens-lib set-id))
(dissoc (:name token))
(assoc (:name candidate) candidate))
resolved (get (ts/resolve-tokens tokens) (:name candidate))]
(and (sm/validate (token-name-schema file-id set-id token) (:name candidate))
(sm/validate (cfo/make-token-value-schema (:type candidate)) (:value candidate))
(or (nil? (:description candidate))
(sm/validate cfo/schema:token-description (:description candidate)))
(contains? resolved :resolved-value)
(empty? (:errors resolved)))))
(defn valid-token-resolution?
[file-id set-id id]
(when-let [token (u/locate-token file-id set-id id)]
(valid-token-candidate? file-id set-id token {})))
(defn- apply-token-to-shapes
[plugin-id file-id set-id id shape-ids attrs]
(cond
@@ -92,8 +137,17 @@
:else
(let [token (u/locate-token file-id set-id id)]
(if (some #(not (token-attr? %)) attrs)
(cond
(nil? token)
(u/not-valid plugin-id :applyToSelected id)
(not (valid-token-candidate? file-id set-id token {}))
(u/not-valid plugin-id :applyToSelected (:value token))
(some #(not (token-attr? %)) attrs)
(u/not-valid plugin-id :applyToSelected attrs)
:else
(st/emit!
(-> (dwta/toggle-token {:token token
:attrs (into #{} (map token-attr-plugin->token-attr) attrs)
@@ -204,9 +258,8 @@
(fn [_]
(let [token (u/locate-token file-id set-id id)]
(ctob/get-name token)))
:schema (cfo/make-token-name-schema
(some-> (u/locate-tokens-lib file-id)
(ctob/get-tokens set-id)))
:schema (fn [_]
(token-name-schema file-id set-id (u/locate-token file-id set-id id)))
:set
(fn [_ value]
(cond
@@ -214,8 +267,11 @@
(u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (-> (dwtl/update-token set-id id {:name value})
(se/add-event plugin-id)))))}
(let [token (u/locate-token file-id set-id id)]
(if (valid-token-candidate? file-id set-id token {:name value})
(st/emit! (-> (dwtl/update-token set-id id {:name value})
(se/add-event plugin-id)))
(u/not-valid plugin-id :name value)))))}
:type
{:this true
@@ -230,14 +286,11 @@
(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)
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))
:decode/fn (fn [value]
(let [token (u/locate-token file-id set-id id)]
(normalize-token-value token (json/->clj value))))
:schema (fn [_]
(token-value-schema (u/locate-token file-id set-id id)))
:set
(fn [_ value]
(cond
@@ -246,10 +299,10 @@
:else
(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})))))}
value (normalize-token-value token value)]
(if (valid-token-candidate? file-id set-id token {:value value})
(st/emit! (dwtl/update-token set-id id {:value value}))
(u/not-valid plugin-id :value value)))))}
:resolvedValue
{:this true
@@ -301,9 +354,11 @@
;; - return the new token proxy using the locally forced id
;; - do the same with sets and themes
(let [token (u/locate-token file-id set-id id)
names (map :name (vals (ctob/get-tokens (u/locate-tokens-lib file-id) set-id)))
name (cfh/generate-unique-name (:name token) names :suffix "copy")
token' (ctob/make-token (-> (datafy token)
(dissoc :id
:modified-at)))]
(assoc :name name)
(dissoc :id :modified-at)))]
(st/emit! (-> (dwtl/create-token set-id token')
(se/add-event plugin-id)))
(token-proxy plugin-id file-id set-id (:id token')))))
@@ -362,9 +417,10 @@
(if (some? set)
(ctob/get-name set)
initial-name)))
:schema (cfo/make-token-set-name-schema
(u/locate-tokens-lib file-id)
id)
:schema (fn [_]
(cfo/make-token-set-name-schema
(u/locate-tokens-lib file-id)
id))
:set
(fn [_ name]
(cond
@@ -554,11 +610,12 @@
(fn [_]
(let [theme (u/locate-token-theme file-id id)]
(:group theme)))
:schema (let [theme (u/locate-token-theme file-id id)]
(cfo/make-token-theme-group-schema
(u/locate-tokens-lib file-id)
(:name theme)
(:id theme)))
:schema (fn [_]
(let [theme (u/locate-token-theme file-id id)]
(cfo/make-token-theme-group-schema
(u/locate-tokens-lib file-id)
(:name theme)
(:id theme))))
:set
(fn [_ group]
(cond
@@ -575,11 +632,12 @@
(fn [_]
(let [theme (u/locate-token-theme file-id id)]
(:name theme)))
:schema (let [theme (u/locate-token-theme file-id id)]
(cfo/make-token-theme-name-schema
(u/locate-tokens-lib file-id)
(:id theme)
(:group theme)))
:schema (fn [_]
(let [theme (u/locate-token-theme file-id id)]
(cfo/make-token-theme-name-schema
(u/locate-tokens-lib file-id)
(:group theme)
(:id theme))))
:set
(fn [_ name]
(cond
@@ -665,8 +723,14 @@
(u/not-valid plugin-id :duplicate "Plugin doesn't have 'content:write' permission")
:else
(let [theme (u/locate-token-theme file-id id)
(let [tokens-lib (u/locate-tokens-lib file-id)
theme (u/locate-token-theme file-id id)
names (->> (ctob/get-themes tokens-lib)
(filter #(= (:group theme) (:group %)))
(map :name))
name (cfh/generate-unique-name (:name theme) names :suffix "copy")
theme' (ctob/make-token-theme (-> (datafy theme)
(assoc :name name)
(dissoc :id
:modified-at)))]
(st/emit! (dwtl/create-token-theme theme'))
@@ -730,17 +794,12 @@
:addSet
{:enumerable false
:schema [:tuple (-> (sm/schema (cfo/make-token-set-schema
(u/locate-tokens-lib file-id)
nil))
(sm/dissoc-key :id) ;; We don't allow plugins to set the id
;; Allow an optional `active` flag so a plugin can create
;; an already-active set in a single call. Newly created
;; sets are inactive by default (only active sets affect
;; shapes and reference resolution). `active` is not part
;; of the token-set data model, so the :fn strips it and
;; applies it through the set-activation logic.
(sm/merge [:map [:active {:optional true} ::sm/boolean]]))]
:schema (fn [_]
[:tuple (-> (sm/schema (cfo/make-token-set-schema
(u/locate-tokens-lib file-id)
nil))
(sm/dissoc-key :id)
(sm/merge [:map [:active {:optional true} ::sm/boolean]]))])
:fn (fn [attrs]
(cond
+1 -1
View File
@@ -43,7 +43,7 @@
:set
(fn [value]
(cond
(not (sm/valid-safe-number? value))
(not (sm/valid-non-negative-safe-number? value))
(u/not-valid plugin-id :value value)
(not (r/check-permission plugin-id "content:write"))
@@ -156,4 +156,23 @@ describe.skipIfMocked('Comments', () => {
cleanup(thread);
}
});
for (const content of [' ', '\n\t', '\u200b']) {
test(`blank comment content ${JSON.stringify(content)} rejects everywhere`, async (ctx) => {
const p = page(ctx);
await expectReject(() => p.addCommentThread(content, { x: 0, y: 0 }));
const thread = await p.addCommentThread('parent', { x: 12, y: 12 });
try {
await expectReject(() => thread.reply(content));
const comments = await thread.findComments();
expect(comments.length).toBeGreaterThan(0);
expect(() => {
comments[0].content = content;
}).toThrow();
} finally {
cleanup(thread);
}
});
}
});
@@ -222,13 +222,21 @@ describe('Component instances', () => {
const inst = comp.instance();
ctx.board.appendChild(inst);
const mainColor = main.fills?.[0]?.fillColor;
const mainFill = main.fills?.[0];
const mainColor =
typeof mainFill === 'string' ? mainFill : mainFill?.fillColor;
inst.fills = [{ fillColor: '#FF0000', fillOpacity: 1 }];
// The override applied (fill getter normalizes to lowercase).
expect(inst.fills?.[0]?.fillColor?.toLowerCase()).toBe('#ff0000');
const overrideFill = inst.fills?.[0];
const overrideColor =
typeof overrideFill === 'string' ? overrideFill : overrideFill?.fillColor;
expect(overrideColor?.toLowerCase()).toBe('#ff0000');
inst.resetOverrides();
expect(inst.fills?.[0]?.fillColor).toBe(mainColor);
const resetFill = inst.fills?.[0];
expect(
typeof resetFill === 'string' ? resetFill : resetFill?.fillColor,
).toBe(mainColor);
});
test('resetOverrides on a plain shape throws', (ctx) => {
@@ -266,6 +274,23 @@ describe('Component instances', () => {
).toThrow();
});
test('swapComponent rejects a component nesting loop', (ctx) => {
const leaf = ctx.penpot.createRectangle();
ctx.board.appendChild(leaf);
const inner = ctx.penpot.library.local.createComponent([leaf]);
const wrapper = ctx.penpot.createBoard();
ctx.board.appendChild(wrapper);
wrapper.appendChild(inner.instance());
const outer = ctx.penpot.library.local.createComponent([wrapper]);
const nested = (outer.mainInstance() as Board).children.find((child) =>
child.isComponentInstance(),
);
expect(nested).toBeDefined();
if (nested) expect(() => nested.swapComponent(outer)).toThrow();
});
test('two instances of one component are independent but share the source', (ctx) => {
const comp = makeComponent(ctx);
const first = comp.instance();
@@ -51,7 +51,9 @@ describe('File', () => {
// The exporter service may be unavailable in the headless runner, so a
// rejection here is treated as an environment limitation; when it does
// run, the result must be a non-empty byte array.
const data = await file.export('penpot', 'detach').catch(() => null);
const data = await file
.export('penpot', 'detach-libraries')
.catch(() => null);
if (data) {
expect(data.length).toBeGreaterThan(0);
}
@@ -267,14 +267,16 @@ describe('Fills & strokes', () => {
]);
});
test('negative strokeWidth is accepted (currently unvalidated)', (ctx) => {
// The plugin API does not constrain strokeWidth to be non-negative, so a
// negative value is stored as-is rather than rejected. This pins the current
// (lenient) behaviour.
test('negative strokeWidth throws', (ctx) => {
const r = rect(ctx);
r.strokes = [{ strokeColor: '#000000', strokeWidth: -3 }];
expect(r.strokes).toHaveLength(1);
expect(typeof r.strokes[0].strokeWidth).toBe('number');
expect(() => {
r.strokes = [{ strokeColor: '#000000', strokeWidth: -3 }];
}).toThrow();
r.strokes = [{ strokeColor: '#000000', strokeWidth: 1 }];
expect(() => {
r.strokes[0].strokeWidth = -1;
}).toThrow();
});
test('invalid strokeStyle throws', (ctx) => {
@@ -119,4 +119,17 @@ describe('Fonts', () => {
expect(t.fontVariantId).toBe(variant.fontVariantId);
expect(t.fontWeight).toBe(variant.fontWeight);
});
test('a font rejects a variant owned by another font', (ctx) => {
const fonts = ctx.penpot.fonts.all;
const first = fonts[0];
const second = fonts.find((font) => font.fontId !== first.fontId);
if (second) {
const t = text(ctx);
expect(() => first.applyToText(t, second.variants[0])).toThrow();
expect(() =>
first.applyToRange(t.getRange(0, 5), second.variants[0]),
).toThrow();
}
});
});
@@ -156,7 +156,7 @@ describe('Interactions', () => {
test('after-delay trigger carries a delay', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
const r = board(ctx);
const interaction = r.addInteraction(
'after-delay',
{ type: 'navigate-to', destination: dest },
@@ -169,7 +169,7 @@ describe('Interactions', () => {
// A zero delay is a valid value (fires immediately), not an error.
test('after-delay accepts a zero delay', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
const r = board(ctx);
const interaction = r.addInteraction(
'after-delay',
{ type: 'navigate-to', destination: dest },
@@ -198,7 +198,7 @@ describe('Interactions', () => {
// "don't persist" — that is stale: CI confirms they do.)
test('interaction delay and action setters persist', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
const r = board(ctx);
const interaction = r.addInteraction(
'after-delay',
{ type: 'navigate-to', destination: dest },
@@ -218,7 +218,7 @@ describe('Interactions', () => {
// The delay setter accepts zero (fires immediately) as a valid value.
test('delay setter accepts a zero value', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
const r = board(ctx);
const interaction = r.addInteraction(
'after-delay',
{ type: 'navigate-to', destination: dest },
@@ -361,35 +361,205 @@ describe('Interactions', () => {
expect(interaction.trigger).toBe('mouse-enter');
});
test('unknown interaction triggers are rejected', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
expect(() =>
r.addInteraction('unknown-trigger' as unknown as 'click', {
type: 'navigate-to',
destination: dest,
}),
).toThrow();
const interaction = r.addInteraction('click', {
type: 'navigate-to',
destination: dest,
});
expect(() => {
interaction.trigger = 'unknown-trigger' as unknown as 'click';
}).toThrow();
});
// ---------------------------------------------------------------------------
// Edge cases. "fail" tests assert invalid interaction input is
// rejected; the "success" test checks several triggers coexisting.
// ---------------------------------------------------------------------------
// addInteraction validates the interaction's structure (schema) but not the
// liveness of a navigate destination nor the format of an open-url string,
// so both of these are accepted rather than rejected. These pin the current
// (lenient) behaviour.
test('navigate-to a removed board is accepted (dangling destination)', (ctx) => {
test('navigate-to a removed board throws', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
dest.remove();
expect(() =>
r.addInteraction('click', { type: 'navigate-to', destination: dest }),
).not.toThrow();
).toThrow();
});
test('open-url accepts an arbitrary url string', (ctx) => {
test('open-url rejects invalid input and normalizes a bare hostname', (ctx) => {
const r = rect(ctx);
expect(() =>
r.addInteraction('click', {
type: 'open-url',
url: 'not a valid url',
}),
).toThrow();
expect(() =>
r.addInteraction('click', {
type: 'open-url',
url: 'ftp://example.com/file',
}),
).toThrow();
const interaction = r.addInteraction('click', {
type: 'open-url',
url: 'not a valid url',
url: 'example.com/path',
});
expect(interaction.action.type).toBe('open-url');
if (interaction.action.type === 'open-url') {
expect(interaction.action.url).toBe('not a valid url');
expect(interaction.action.url).toBe('http://example.com/path');
}
});
test('after-delay is board-only and initializes its default delay', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
expect(() =>
r.addInteraction('after-delay', {
type: 'navigate-to',
destination: dest,
}),
).toThrow();
const source = board(ctx);
const interaction = source.addInteraction('click', {
type: 'navigate-to',
destination: dest,
});
interaction.trigger = 'after-delay';
expect(interaction.delay).toBeCloseTo(600, 0);
expect(() => {
const invalid = r.addInteraction('click', {
type: 'navigate-to',
destination: dest,
});
invalid.trigger = 'after-delay';
}).toThrow();
});
test('after-delay creation rejects invalid delays', (ctx) => {
const dest = board(ctx);
const source = board(ctx);
for (const delay of ['bad', 1.5, -1]) {
expect(() =>
source.addInteraction(
'after-delay',
{ type: 'navigate-to', destination: dest },
delay as unknown as number,
),
).toThrow();
}
const interaction = source.addInteraction(
'after-delay',
{ type: 'navigate-to', destination: dest },
10,
);
for (const delay of ['bad', 1.5, -1]) {
expect(() => {
interaction.delay = delay as unknown as number;
}).toThrow();
}
});
test('navigation destinations must be eligible boards', (ctx) => {
const source = board(ctx);
const child = rect(ctx);
const rectangle = rect(ctx);
expect(() =>
source.addInteraction('click', {
type: 'navigate-to',
destination: source,
}),
).toThrow();
expect(() =>
child.addInteraction('click', {
type: 'navigate-to',
destination: ctx.board,
}),
).toThrow();
expect(() =>
source.addInteraction('click', {
type: 'navigate-to',
destination: rectangle as unknown as Board,
}),
).toThrow();
});
test('interaction destinations must belong to the current page', async (ctx) => {
const original = ctx.penpot.currentPage;
expect(original).not.toBeNull();
if (!original) return;
const source = rect(ctx);
const localDestination = board(ctx);
const interaction = source.addInteraction('click', {
type: 'navigate-to',
destination: localDestination,
});
const otherPage = ctx.penpot.createPage();
try {
await ctx.penpot.openPage(otherPage);
const otherBoard = ctx.penpot.createBoard();
(otherPage.root as Board).appendChild(otherBoard);
await ctx.penpot.openPage(original);
expect(() =>
source.addInteraction('click', {
type: 'navigate-to',
destination: otherBoard,
}),
).toThrow();
expect(() => {
interaction.action = {
type: 'navigate-to',
destination: otherBoard,
};
}).toThrow();
} finally {
if (ctx.penpot.currentPage?.id !== original.id) {
await ctx.penpot.openPage(original);
}
otherPage.remove();
}
});
test('push animation is rejected for overlay actions and replacements', (ctx) => {
const overlay = board(ctx);
const r = rect(ctx);
const push = {
type: 'push' as const,
direction: 'left' as const,
duration: 300,
easing: 'linear' as const,
};
expect(() =>
r.addInteraction('click', {
type: 'open-overlay',
destination: overlay,
animation: push,
}),
).toThrow();
const interaction = r.addInteraction('click', {
type: 'navigate-to',
destination: overlay,
});
expect(() => {
interaction.action = {
type: 'open-overlay',
destination: overlay,
animation: push,
};
}).toThrow();
});
test('several triggers on one shape coexist', (ctx) => {
const dest = board(ctx);
const r = rect(ctx);
@@ -82,6 +82,34 @@ describe('Layout', () => {
expect(flex.leftPadding).toBeCloseTo(4.5, 2);
});
test('every flex gap and padding setter rejects negative values', (ctx) => {
const flex = board(ctx).addFlexLayout();
expect(() => {
flex.rowGap = -1;
}).toThrow();
expect(() => {
flex.columnGap = -1;
}).toThrow();
expect(() => {
flex.verticalPadding = -1;
}).toThrow();
expect(() => {
flex.horizontalPadding = -1;
}).toThrow();
expect(() => {
flex.topPadding = -1;
}).toThrow();
expect(() => {
flex.rightPadding = -1;
}).toThrow();
expect(() => {
flex.bottomPadding = -1;
}).toThrow();
expect(() => {
flex.leftPadding = -1;
}).toThrow();
});
// paddingType is "simple" (sides mirrored) or "multiple" (each side independent).
test('paddingType round-trips', (ctx) => {
const flex = board(ctx).addFlexLayout();
@@ -185,6 +213,37 @@ describe('Layout', () => {
expect(grid.columns[0].type).toBe('percent');
});
test('grid track creation and replacement reject negative values', (ctx) => {
const grid = board(ctx).addGridLayout();
for (const type of ['fixed', 'percent', 'flex'] as const) {
expect(() => grid.addRow(type, -1)).toThrow();
expect(() => grid.addColumn(type, -1)).toThrow();
}
expect(() =>
grid.addColumn('fixed', 'bad' as unknown as number),
).toThrow();
grid.addRow('flex', 1);
grid.addColumn('flex', 1);
expect(() => grid.addRowAtIndex(0, 'fixed', -1)).toThrow();
expect(() => grid.addColumnAtIndex(0, 'fixed', -1)).toThrow();
expect(() => grid.setRow(0, 'flex', -1)).toThrow();
expect(() => grid.setColumn(0, 'flex', -1)).toThrow();
});
test('retained grid track proxies reject negative values', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addRow('fixed', 10);
grid.addColumn('fixed', 10);
const row = grid.rows[0];
const column = grid.columns[0];
expect(() => {
row.value = -1;
}).toThrow();
expect(() => {
column.value = -1;
}).toThrow();
});
test('removeRow and removeColumn drop tracks', (ctx) => {
const grid = board(ctx).addGridLayout();
grid.addRow('flex', 1);
@@ -238,6 +297,34 @@ describe('Layout', () => {
expect(grid.leftPadding).toBeCloseTo(4.5, 2);
});
test('every grid gap and padding setter rejects negative values', (ctx) => {
const grid = board(ctx).addGridLayout();
expect(() => {
grid.rowGap = -1;
}).toThrow();
expect(() => {
grid.columnGap = -1;
}).toThrow();
expect(() => {
grid.verticalPadding = -1;
}).toThrow();
expect(() => {
grid.horizontalPadding = -1;
}).toThrow();
expect(() => {
grid.topPadding = -1;
}).toThrow();
expect(() => {
grid.rightPadding = -1;
}).toThrow();
expect(() => {
grid.bottomPadding = -1;
}).toThrow();
expect(() => {
grid.leftPadding = -1;
}).toThrow();
});
// paddingType behaves the same as on flex layouts (see issue #10278).
test('paddingType round-trips', (ctx) => {
const grid = board(ctx).addGridLayout();
@@ -433,6 +520,29 @@ describe('Layout', () => {
}
});
test('every layout child min and max bound rejects negatives', (ctx) => {
const b = board(ctx);
const flex = b.addFlexLayout();
const rect = ctx.penpot.createRectangle();
flex.appendChild(rect);
const child = rect.layoutChild;
expect(child).toBeDefined();
if (child) {
expect(() => {
child.minWidth = -1;
}).toThrow();
expect(() => {
child.maxWidth = -1;
}).toThrow();
expect(() => {
child.minHeight = -1;
}).toThrow();
expect(() => {
child.maxHeight = -1;
}).toThrow();
}
});
// marginType is the child-margin counterpart of a layout's paddingType.
test('marginType round-trips', (ctx) => {
const b = board(ctx);
@@ -491,7 +601,7 @@ describe('Layout', () => {
b.resize(300, 200);
b.addFlexLayout();
const found = ctx.penpot.currentPage.getShapeById(b.id) as Board;
const found = ctx.penpot.currentPage!.getShapeById(b.id) as Board;
expect(found).not.toBeNull();
const child = ctx.penpot.createRectangle();
found.appendChild(child);
@@ -206,28 +206,85 @@ describe('Library', () => {
const typo = ctx.penpot.library.local.createTypography();
expect(typeof typo.fontFamily).toBe('string');
typo.fontFamily = 'Arial';
typo.fontId = 'gfont-arial';
expect(typo.fontFamily).toBe('Arial');
expect(typo.fontId).toBe('gfont-arial');
const font = ctx.penpot.fonts.all[0];
typo.fontFamily = font.fontFamily;
typo.fontId = font.fontId;
expect(typo.fontFamily).toBe(font.fontFamily);
expect(typo.fontId).toBe(font.fontId);
});
test('typography style members round-trip', (ctx) => {
const typo = ctx.penpot.library.local.createTypography();
typo.fontStyle = 'italic';
const font =
ctx.penpot.fonts.all.find((item) =>
item.variants.some((variant) => variant.fontStyle === 'italic'),
) ?? ctx.penpot.fonts.all[0];
typo.setFont(font);
const italic = font.variants.find(
(variant) => variant.fontStyle === 'italic',
);
if (italic) {
typo.fontStyle = italic.fontStyle;
expect(typo.fontStyle).toBe(italic.fontStyle);
expect(typo.fontVariantId).toBe(italic.fontVariantId);
expect(typo.fontWeight).toBe(italic.fontWeight);
}
typo.textTransform = 'uppercase';
typo.fontWeight = '700';
typo.fontVariantId = 'regular';
const variant = font.variants[font.variants.length - 1];
typo.fontWeight = variant.fontWeight;
typo.fontVariantId = variant.fontVariantId;
typo.lineHeight = '1.5';
typo.letterSpacing = '1';
expect(typo.fontStyle).toBe('italic');
expect(typo.textTransform).toBe('uppercase');
expect(typo.fontWeight).toBe('700');
expect(typo.fontVariantId).toBe('regular');
expect(typeof typo.lineHeight).toBe('string');
expect(typo.fontWeight).toBe(variant.fontWeight);
expect(typo.fontStyle).toBe(variant.fontStyle);
expect(typo.fontVariantId).toBe(variant.fontVariantId);
expect(typo.lineHeight).toBe('1.5');
expect(typeof typo.letterSpacing).toBe('string');
});
test('typography text values use text validation', (ctx) => {
const typo = ctx.penpot.library.local.createTypography();
expect(() => {
typo.fontSize = '2';
}).toThrow();
expect(() => {
typo.fontSize = '12px';
}).toThrow();
expect(() => {
typo.lineHeight = '201';
}).toThrow();
expect(() => {
typo.lineHeight = '12px';
}).toThrow();
expect(() => {
typo.letterSpacing = '12px';
}).toThrow();
expect(() => {
typo.textTransform = 'not-a-transform' as unknown as 'uppercase';
}).toThrow();
});
test('typography font fields require installed fonts and variants', (ctx) => {
const typo = ctx.penpot.library.local.createTypography();
expect(() => {
typo.fontId = 'missing-font';
}).toThrow();
expect(() => {
typo.fontFamily = 'Missing Font Family';
}).toThrow();
expect(() => {
typo.fontVariantId = 'missing-variant';
}).toThrow();
const fonts = ctx.penpot.fonts.all;
const first = fonts[0];
const second = fonts.find((font) => font.fontId !== first.fontId);
if (second) {
expect(() => typo.setFont(first, second.variants[0])).toThrow();
}
});
test('typography setFont updates the font', (ctx) => {
const typo = ctx.penpot.library.local.createTypography();
const font = ctx.penpot.fonts.all[0];
@@ -1,5 +1,6 @@
import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type { Board } from '@penpot/plugin-types';
// Pages, selection and flows.
// Most assertions use the active page (`currentPage`) and the scratch board so
@@ -16,6 +17,18 @@ describe('Pages', () => {
}
});
test('page names are trimmed and cannot be blank', (ctx) => {
const page = ctx.penpot.currentPage;
expect(page).not.toBeNull();
if (page) {
expect(() => {
page.name = ' ';
}).toThrow();
page.name = ' Trimmed page ';
expect(page.name).toBe('Trimmed page');
}
});
test('createPage and openPage activate a new page', async (ctx) => {
const original = ctx.penpot.currentPage;
const page = ctx.penpot.createPage();
@@ -206,4 +219,66 @@ describe('Flows', () => {
expect(page.flows.length).toBe(before - 1);
}
});
test('flows require a live, unused board on their page', (ctx) => {
const page = ctx.penpot.currentPage;
expect(page).not.toBeNull();
if (page) {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
expect(() =>
page.createFlow('rect-flow', rect as unknown as Board),
).toThrow();
const removed = ctx.penpot.createBoard();
ctx.board.appendChild(removed);
removed.remove();
expect(() => page.createFlow('removed-flow', removed)).toThrow();
const target = ctx.penpot.createBoard();
ctx.board.appendChild(target);
const first = page.createFlow('first-flow', target);
expect(() => page.createFlow('duplicate-flow', target)).toThrow();
const secondTarget = ctx.penpot.createBoard();
ctx.board.appendChild(secondTarget);
const second = page.createFlow('second-flow', secondTarget);
expect(() => {
second.startingBoard = target;
}).toThrow();
expect(() => {
second.startingBoard = removed;
}).toThrow();
first.remove();
second.remove();
}
});
test('flows reject a starting board from another page', async (ctx) => {
const original = ctx.penpot.currentPage;
expect(original).not.toBeNull();
if (!original) return;
const localBoard = ctx.penpot.createBoard();
ctx.board.appendChild(localBoard);
const flow = original.createFlow('cross-page-flow', localBoard);
const otherPage = ctx.penpot.createPage();
try {
await ctx.penpot.openPage(otherPage);
const otherBoard = ctx.penpot.createBoard();
(otherPage.root as Board).appendChild(otherBoard);
await ctx.penpot.openPage(original);
expect(() => original.createFlow('foreign-flow', otherBoard)).toThrow();
expect(() => {
flow.startingBoard = otherBoard;
}).toThrow();
} finally {
if (ctx.penpot.currentPage?.id !== original.id) {
await ctx.penpot.openPage(original);
}
flow.remove();
otherPage.remove();
}
});
});
@@ -58,6 +58,36 @@ describe('Shadows', () => {
expect(shadow.style).toBe('inner-shadow');
expect(shadow.hidden).toBe(true);
});
test('negative shadow blur throws', (ctx) => {
const r = rect(ctx);
expect(() => {
r.shadows = [
{
style: 'drop-shadow',
offsetX: 0,
offsetY: 0,
blur: -1,
spread: 0,
color: { color: '#000000', opacity: 1 },
},
];
}).toThrow();
r.shadows = [
{
style: 'drop-shadow',
offsetX: 0,
offsetY: 0,
blur: 1,
spread: 0,
color: { color: '#000000', opacity: 1 },
},
];
expect(() => {
r.shadows[0].blur = -1;
}).toThrow();
});
});
describe('Blur', () => {
@@ -78,4 +108,14 @@ describe('Blur', () => {
expect(r.backgroundBlur).toBeDefined();
expect(r.backgroundBlur && r.backgroundBlur.value).toBeCloseTo(5, 0);
});
test('negative layer and background blur throw', (ctx) => {
const r = rect(ctx);
expect(() => {
r.blur = { value: -1 };
}).toThrow();
expect(() => {
r.backgroundBlur = { value: -1 };
}).toThrow();
});
});
@@ -1,5 +1,6 @@
import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type { Group } from '@penpot/plugin-types';
import type { TestContext } from '../framework/types';
// Shapes & geometry.
@@ -254,6 +255,22 @@ describe('Shapes', () => {
expect(r.borderRadiusBottomRight).toBeCloseTo(3.75, 2);
expect(r.borderRadiusBottomLeft).toBeCloseTo(0.5, 2);
});
test('individual corner radii reject negative values', (ctx) => {
const r = rect(ctx);
expect(() => {
r.borderRadiusTopLeft = -1;
}).toThrow();
expect(() => {
r.borderRadiusTopRight = -1;
}).toThrow();
expect(() => {
r.borderRadiusBottomRight = -1;
}).toThrow();
expect(() => {
r.borderRadiusBottomLeft = -1;
}).toThrow();
});
});
describe('Ordering', () => {
@@ -365,7 +382,7 @@ describe('Shapes', () => {
expect(group).not.toBeNull();
if (!group) return;
const copy = group.clone();
const copy = group.clone() as Group;
ctx.board.appendChild(copy);
expect(copy.id).not.toBe(group.id);
expect(copy.children).toHaveLength(group.children.length);
@@ -447,6 +464,21 @@ describe('Shapes', () => {
}).toThrow();
});
test('export scale must be positive', (ctx) => {
const r = rect(ctx);
expect(() => {
r.exports = [{ type: 'png', scale: 0, suffix: '' }];
}).toThrow();
expect(() => {
r.exports = [{ type: 'png', scale: -1, suffix: '' }];
}).toThrow();
r.exports = [{ type: 'png', scale: 1, suffix: '' }];
expect(() => {
r.exports[0].scale = 0;
}).toThrow();
});
test('resize to zero dimensions throws', (ctx) => {
const r = rect(ctx);
expect(() => {
@@ -302,6 +302,60 @@ describe('Text', () => {
}).toThrow();
});
test('text numeric fields require complete numbers within editor bounds', (ctx) => {
const t = text(ctx);
for (const value of ['.', '-', '12px', '2', '1001']) {
expect(() => {
t.fontSize = value;
}).toThrow();
}
for (const value of ['.', '-', '12px', '-201', '201']) {
expect(() => {
t.lineHeight = value;
}).toThrow();
expect(() => {
t.letterSpacing = value;
}).toThrow();
}
});
test('text range numeric fields use the same validation', (ctx) => {
const range = text(ctx, 'Hello').getRange(0, 5);
expect(() => {
range.fontSize = '12px';
}).toThrow();
expect(() => {
range.lineHeight = '12px';
}).toThrow();
expect(() => {
range.letterSpacing = '12px';
}).toThrow();
});
test('text and range font fields reject missing fonts and variants', (ctx) => {
const t = text(ctx, 'Hello');
expect(() => {
t.fontId = 'missing-font';
}).toThrow();
expect(() => {
t.fontFamily = 'Missing Font Family';
}).toThrow();
expect(() => {
t.fontVariantId = 'missing-variant';
}).toThrow();
const range = t.getRange(0, 5);
expect(() => {
range.fontId = 'missing-font';
}).toThrow();
expect(() => {
range.fontFamily = 'Missing Font Family';
}).toThrow();
expect(() => {
range.fontVariantId = 'missing-variant';
}).toThrow();
});
test('invalid align value throws', (ctx) => {
const t = text(ctx);
expect(() => {
@@ -314,6 +368,9 @@ describe('Text', () => {
expect(() => {
t.textTransform = 'UPPERCASE' as unknown as 'uppercase';
}).toThrow();
expect(() => {
t.getRange(0, 1).textTransform = 'UPPERCASE' as unknown as 'uppercase';
}).toThrow();
});
test('invalid direction value throws', (ctx) => {
@@ -56,6 +56,20 @@ describe('Tokens', () => {
expect(cat.themes.length).toBeGreaterThan(0);
expect(cat.getThemeById(theme.id)).toBeDefined();
});
test('catalog name validation uses current state and rejects blank names', (ctx) => {
const cat = catalog(ctx);
const name = unique('live-set');
cat.addSet({ name });
expect(() => cat.addSet({ name })).toThrow();
expect(() => cat.addSet({ name: ' ' })).toThrow();
expect(() => cat.addTheme({ group: '', name: ' ' })).toThrow();
const group = unique('live-theme-group');
const themeName = unique('live-theme');
cat.addTheme({ group, name: themeName });
expect(() => cat.addTheme({ group, name: themeName })).toThrow();
});
});
describe('Set', () => {
@@ -70,6 +84,17 @@ describe('Tokens', () => {
expect(set.active).toBe(true);
});
test('retained set proxies validate names against current state', (ctx) => {
const first = activeSet(ctx, unique('set'));
const second = activeSet(ctx, unique('set'));
expect(() => {
first.name = second.name;
}).toThrow();
expect(() => {
first.name = ' ';
}).toThrow();
});
// Community report (forum #10700, issue #14): toggling a token set's
// active state was said to freeze and roll back when tokens from the set
// are bound to shapes. Did not reproduce; kept as a regression pin.
@@ -148,15 +173,42 @@ describe('Tokens', () => {
}),
).toThrow();
});
test('empty composites and line-height without font-size throw', (ctx) => {
const set = activeSet(ctx, unique('set'));
expect(() =>
set.addToken({
type: 'typography',
name: unique('empty-typography.'),
value: {} as never,
}),
).toThrow();
expect(() =>
set.addToken({
type: 'shadow',
name: unique('empty-shadow.'),
value: [] as never,
}),
).toThrow();
expect(() =>
set.addToken({
type: 'typography',
name: unique('line-height-only.'),
value: { lineHeight: '1.2' } as never,
}),
).toThrow();
});
});
describe('Theme', () => {
test('group, name and active round-trip', (ctx) => {
const theme = catalog(ctx).addTheme({ group: '', name: unique('theme') });
theme.group = 'brand';
theme.name = 'dark';
expect(theme.group).toBe('brand');
expect(theme.name).toBe('dark');
const group = unique('brand');
const name = unique('dark');
theme.group = group;
theme.name = name;
expect(theme.group).toBe(group);
expect(theme.name).toBe(name);
theme.active = true;
expect(theme.active).toBe(true);
theme.toggleActive();
@@ -191,6 +243,33 @@ describe('Tokens', () => {
expect(dup.id).not.toBe(theme.id);
dup.remove();
});
test('theme names stay unique within their current group', (ctx) => {
const cat = catalog(ctx);
const group = unique('group');
const first = cat.addTheme({ group, name: unique('theme') });
const second = cat.addTheme({ group, name: unique('theme') });
expect(() => {
first.name = second.name;
}).toThrow();
expect(() => {
first.name = ' ';
}).toThrow();
const duplicate = first.duplicate();
expect(duplicate.name).not.toBe(first.name);
duplicate.remove();
});
test('moving a retained theme cannot create a group/name collision', (ctx) => {
const cat = catalog(ctx);
const name = unique('shared-theme');
const first = cat.addTheme({ group: unique('group-a'), name });
const second = cat.addTheme({ group: unique('group-b'), name });
expect(() => {
first.group = second.group;
}).toThrow();
});
});
describe('Token', () => {
@@ -315,9 +394,131 @@ describe('Tokens', () => {
});
const dup = token.duplicate();
expect(dup.id).not.toBe(token.id);
expect(dup.name).not.toBe(token.name);
dup.remove();
});
test('token edits reject missing, self, cyclic, and dotted-name conflicts', (ctx) => {
const set = activeSet(ctx, unique('set'));
const prefix = unique('tree');
set.addToken({
type: 'dimension',
name: `${prefix}.child`,
value: '8',
});
const token = set.addToken({
type: 'dimension',
name: unique('editable.'),
value: '4',
});
expect(() => {
token.name = prefix;
}).toThrow();
expect(() => {
token.value = `{${token.name}}`;
}).toThrow();
expect(() => {
token.value = '{missing-token}';
}).toThrow();
const other = set.addToken({
type: 'dimension',
name: unique('other.'),
value: '2',
});
token.value = `{${other.name}}`;
expect(() => {
other.value = `{${token.name}}`;
}).toThrow();
});
test('retained token proxies validate names against current state', (ctx) => {
const set = activeSet(ctx, unique('set'));
const retained = set.addToken({
type: 'dimension',
name: unique('retained.'),
value: '1',
});
const later = set.addToken({
type: 'dimension',
name: unique('later.'),
value: '2',
});
expect(() => {
retained.name = later.name;
}).toThrow();
});
test('renaming validates the whole token and rejects a new self-reference', (ctx) => {
const targetName = unique('rename-target');
activeSet(ctx, unique('target-set')).addToken({
type: 'dimension',
name: targetName,
value: '8',
});
const token = activeSet(ctx, unique('source-set')).addToken({
type: 'dimension',
name: unique('rename-source'),
value: `{${targetName}}`,
});
expect(() => {
token.name = targetName;
}).toThrow();
});
test('composite token updates reject empty values and missing font size', (ctx) => {
const set = activeSet(ctx, unique('set'));
const typography = set.addToken({
type: 'typography',
name: unique('typography.'),
value: { fontSizes: '14', lineHeight: '1.2' } as never,
}) as TokenTypography;
const shadow = set.addToken({
type: 'shadow',
name: unique('shadow.'),
value: {
color: '#000000',
inset: 'false',
offsetX: '0',
offsetY: '0',
spread: '0',
blur: '1',
},
}) as TokenShadow;
expect(() => {
typography.value = {} as never;
}).toThrow();
expect(() => {
typography.value = { lineHeight: '1.2' } as never;
}).toThrow();
expect(() => {
shadow.value = [];
}).toThrow();
});
test('tokens with newly broken references cannot be applied', (ctx) => {
const set = activeSet(ctx, unique('set'));
const base = set.addToken({
type: 'borderRadius',
name: unique('base.'),
value: '8',
});
const ref = set.addToken({
type: 'borderRadius',
name: unique('ref.'),
value: `{${base.name}}`,
});
base.remove();
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
ctx.penpot.selection = [rect];
expect(() => ref.applyToShapes([rect])).toThrow();
expect(() => ref.applyToSelected()).toThrow();
expect(() => rect.applyToken(ref)).toThrow();
});
// Reference resolution — a token referencing another resolves transitively.
test('a token referencing another token resolves transitively', (ctx) => {
const set = activeSet(ctx, unique('set'));
@@ -247,13 +247,11 @@ describe('Value objects', () => {
}
});
test('negative blur value is accepted (currently unvalidated)', (ctx) => {
// The blur setter does not reject a negative value; this pins the current
// lenient behaviour (a candidate for future hardening).
test('negative blur value throws', (ctx) => {
const r = rect(ctx);
expect(() => {
r.blur = { value: -5 };
}).not.toThrow();
}).toThrow();
});
});
@@ -232,9 +232,12 @@ describe('Variants', () => {
const instance = vc.instance();
ctx.board.appendChild(instance);
// Valid args (nat-int pos, string value): switches to the nearest variant
// with that value at the property position, or no-ops — never throws.
expect(() => instance.switchVariant(0, 'large')).not.toThrow();
const property = vc.variants!.properties[0];
const target =
vc.variants!.variantComponents()[1] as LibraryVariantComponent;
expect(() =>
instance.switchVariant(0, target.variantProps[property]),
).not.toThrow();
});
// Community report (forum #10700, issue #3): switchVariant on an instance
@@ -256,7 +259,12 @@ describe('Variants', () => {
const clonedInstance = cloned.children.find((s) => s.isComponentInstance());
expect(clonedInstance).toBeDefined();
if (clonedInstance) {
expect(() => clonedInstance.switchVariant(0, 'large')).not.toThrow();
const property = vc.variants!.properties[0];
const target =
vc.variants!.variantComponents()[1] as LibraryVariantComponent;
expect(() =>
clonedInstance.switchVariant(0, target.variantProps[property]),
).not.toThrow();
}
});
@@ -332,6 +340,14 @@ describe('Variants', () => {
expect(() => ctx.penpot.createVariantFromComponents([])).toThrow();
});
test('createVariantFromComponents requires two distinct components', (ctx) => {
const main = componentMain(ctx);
expect(() => ctx.penpot.createVariantFromComponents([main])).toThrow();
expect(() =>
ctx.penpot.createVariantFromComponents([main, main]),
).toThrow();
});
test('removeProperty out of bounds throws', async (ctx) => {
const vc = await variantComponent(ctx);
const v = vc.variants;
@@ -341,6 +357,13 @@ describe('Variants', () => {
}
});
test('the last variant property cannot be removed', async (ctx) => {
const vc = await variantComponent(ctx);
const v = vc.variants!;
expect(v.properties).toHaveLength(1);
expect(() => v.removeProperty(0)).toThrow();
});
test('renameProperty out of bounds throws', async (ctx) => {
const vc = await variantComponent(ctx);
const v = vc.variants;
@@ -350,8 +373,88 @@ describe('Variants', () => {
}
});
test('variant property names are trimmed, nonblank, and at most 60 characters', async (ctx) => {
const vc = await variantComponent(ctx);
const v = vc.variants!;
expect(() => v.renameProperty(0, ' ')).toThrow();
expect(() => v.renameProperty(0, 'x'.repeat(61))).toThrow();
v.renameProperty(0, ' Size ');
expect(v.properties[0]).toBe('Size');
});
test('setVariantProperty out of bounds throws', async (ctx) => {
const vc = await variantComponent(ctx);
expect(() => vc.setVariantProperty(999, 'large')).toThrow();
});
test('variant values are trimmed and at most 60 characters', async (ctx) => {
const vc = await variantComponent(ctx);
expect(() => vc.setVariantProperty(0, 'x'.repeat(61))).toThrow();
vc.setVariantProperty(0, ' Large ');
expect(vc.variantProps[vc.variants!.properties[0]]).toBe('Large');
});
test('empty variant values remain allowed after trimming', async (ctx) => {
const vc = await variantComponent(ctx);
expect(() => vc.setVariantProperty(0, ' ')).not.toThrow();
expect(vc.variantProps[vc.variants!.properties[0]]).toBe('');
});
test('switchVariant rejects mains, bad positions, and unavailable values', async (ctx) => {
const vc = await variantComponent(ctx);
vc.addVariant();
await waitFor(() => (vc.variants?.variantComponents().length ?? 0) > 1);
const instance = vc.instance();
ctx.board.appendChild(instance);
expect(() => vc.mainInstance().switchVariant(0, 'Value2')).toThrow();
expect(() => instance.switchVariant(999, 'Value2')).toThrow();
expect(() => instance.switchVariant(0, 'missing-value')).toThrow();
});
test('variant combining rejects copies and existing variants', async (ctx) => {
const standard = componentWithMain(ctx);
const other = componentWithMain(ctx);
const copy = other.comp.instance() as Board;
ctx.board.appendChild(copy);
expect(() =>
ctx.penpot.createVariantFromComponents([standard.main, copy]),
).toThrow();
expect(() => standard.main.combineAsVariants([copy.id])).toThrow();
const variant = await variantComponent(ctx);
expect(() =>
ctx.penpot.createVariantFromComponents([
standard.main,
variant.mainInstance() as Board,
]),
).toThrow();
expect(() =>
standard.main.combineAsVariants([variant.mainInstance().id]),
).toThrow();
});
test('variant combining rejects components from another page', async (ctx) => {
const original = ctx.penpot.currentPage;
expect(original).not.toBeNull();
if (!original) return;
const originalMain = componentMain(ctx);
const otherPage = ctx.penpot.createPage();
try {
await ctx.penpot.openPage(otherPage);
const rect = ctx.penpot.createRectangle();
(otherPage.root as Board).appendChild(rect);
const other = ctx.penpot.library.local.createComponent([rect]);
const otherMain = other.mainInstance() as Board;
expect(() =>
ctx.penpot.createVariantFromComponents([originalMain, otherMain]),
).toThrow();
expect(() => otherMain.combineAsVariants([originalMain.id])).toThrow();
} finally {
await ctx.penpot.openPage(original);
otherPage.remove();
}
});
});
@@ -1,5 +1,6 @@
import { expect } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type { Board } from '@penpot/plugin-types';
// Viewport and guides (ruler guides + board guides).
@@ -80,6 +81,51 @@ describe('Ruler guides', () => {
page.removeRulerGuide(guide);
}
});
test('page ruler guide rejects a removed board', (ctx) => {
const page = ctx.penpot.currentPage;
expect(page).not.toBeNull();
if (page) {
const removed = ctx.penpot.createBoard();
ctx.board.appendChild(removed);
removed.remove();
expect(() => page.addRulerGuide('vertical', 20, removed)).toThrow();
const guide = page.addRulerGuide('vertical', 20);
expect(() => {
guide.board = removed;
}).toThrow();
guide.remove();
}
});
test('ruler guides reject boards from another page', async (ctx) => {
const original = ctx.penpot.currentPage;
expect(original).not.toBeNull();
if (!original) return;
const guide = original.addRulerGuide('vertical', 20);
const otherPage = ctx.penpot.createPage();
try {
await ctx.penpot.openPage(otherPage);
const otherBoard = ctx.penpot.createBoard();
(otherPage.root as Board).appendChild(otherBoard);
await ctx.penpot.openPage(original);
expect(() =>
original.addRulerGuide('vertical', 20, otherBoard),
).toThrow();
expect(() => {
guide.board = otherBoard;
}).toThrow();
} finally {
if (ctx.penpot.currentPage?.id !== original.id) {
await ctx.penpot.openPage(original);
}
guide.remove();
otherPage.remove();
}
});
});
describe('Board guides', () => {
@@ -137,4 +183,66 @@ describe('Board guides', () => {
}
}
});
test('board guides allow automatic sizes', (ctx) => {
expect(() => {
ctx.board.guides = [
{
type: 'column',
display: true,
params: { color: { color: '#ff0000', opacity: 1 } },
},
{
type: 'row',
display: true,
params: { color: { color: '#00ff00', opacity: 1 } },
},
{
type: 'square',
display: true,
params: { color: { color: '#0000ff', opacity: 1 } },
},
];
}).not.toThrow();
});
test('board guide sizes must meet their positive minimums', (ctx) => {
expect(() => {
ctx.board.guides = [
{
type: 'column',
display: true,
params: {
color: { color: '#000000', opacity: 1 },
type: 'stretch',
size: 0.5,
gutter: 0,
},
},
];
}).toThrow();
expect(() => {
ctx.board.guides = [
{
type: 'row',
display: true,
params: {
color: { color: '#000000', opacity: 1 },
type: 'stretch',
size: 0.5,
gutter: 0,
},
},
];
}).toThrow();
expect(() => {
ctx.board.guides = [
{
type: 'square',
display: true,
params: { color: { color: '#000000', opacity: 1 }, size: 0.009 },
},
];
}).toThrow();
});
});