mirror of
https://github.com/penpot/penpot.git
synced 2026-09-09 12:19:58 -04:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
217689e7a3 | ||
|
|
cefc842826 | ||
|
|
ed70f921a5 |
No files matched your search
@@ -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"]))
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@@ -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]])
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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")))
|
||||
@@ -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)))))))
|
||||
|
||||
@@ -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))))
|
||||
|
||||
@@ -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")))))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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)))))
|
||||
|
||||
|
||||
@@ -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"))))))
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user