Compare commits

..

2 Commits

Author SHA1 Message Date
Andrés Moya
b786b8e07a 🔧 Use automatic validation in token proxies 2025-12-10 09:22:45 +01:00
Andrey Antukh
e8ff89836e 🔧 Refactor token validation schemas 2025-12-10 09:22:45 +01:00
209 changed files with 16860 additions and 22088 deletions

View File

@@ -11,7 +11,7 @@ jobs:
secrets: inherit
with:
gh_ref: ${{ github.ref_name }}
build_wasm: "yes"
build_wasm: "no"
build_storybook: "yes"
build-docker:

2
.gitignore vendored
View File

@@ -20,7 +20,6 @@
.rebel_readline_history
.repl
.shadow-cljs
.pnpm-store/
/*.jpg
/*.md
/*.png
@@ -72,7 +71,6 @@
/library/target/
/library/*.zip
/external
/penpot-nitrate
clj-profiler/
node_modules

View File

@@ -114,8 +114,6 @@ example. It's still usable as before, we just removed the example.
- Fix unpublish library modal not scrolling a long file list [Taiga #12285](https://tree.taiga.io/project/penpot/issue/12285)
- Fix incorrect interaction betwen hower and scroll on assets sidebar [Taiga #12389](https://tree.taiga.io/project/penpot/issue/12389)
- Fix switch variants with paths [Taiga #12841](https://tree.taiga.io/project/penpot/issue/12841)
- Fix referencing typography tokens on font-family tokens [Taiga #12492](https://tree.taiga.io/project/penpot/issue/12492)
- Fix horizontal scroll on layer panel [Taiga #12843](https://tree.taiga.io/project/penpot/issue/12843)
## 2.11.1

View File

@@ -3,7 +3,7 @@
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Tokens%20starter%20kit.penpot"}
{:id "penpot-design-system"
:name "Penpot Design System | Pencil"
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Pencil-Penpot-Design-System.penpot"}
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/penpot-app.penpot"}
{:id "wireframing-kit"
:name "Wireframe library"
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Wireframing%20kit%20v1.1.penpot"}

View File

@@ -36,8 +36,7 @@ export PENPOT_FLAGS="\
enable-file-validation \
enable-file-schema-validation \
enable-redis-cache \
enable-subscriptions \
enable-nitrate";
enable-subscriptions";
# Default deletion delay for devenv
export PENPOT_DELETION_DELAY="24h"
@@ -56,8 +55,6 @@ export PENPOT_OBJECTS_STORAGE_BACKEND=s3
export PENPOT_OBJECTS_STORAGE_S3_ENDPOINT=http://minio:9000
export PENPOT_OBJECTS_STORAGE_S3_BUCKET=penpot
export PENPOT_NITRATE_BACKEND_URI=http://localhost:3000
export JAVA_OPTS="\
-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \
-Djdk.attach.allowAttachSelf \

View File

@@ -225,8 +225,6 @@
[:netty-io-threads {:optional true} ::sm/int]
[:executor-threads {:optional true} ::sm/int]
[:nitrate-backend-uri {:optional true} ::sm/uri]
;; DEPRECATED
[:assets-storage-backend {:optional true} :keyword]
[:storage-assets-fs-directory {:optional true} :string]

View File

@@ -323,7 +323,6 @@
{::http.client/client (ig/ref ::http.client/client)
::db/pool (ig/ref ::db/pool)
::rds/pool (ig/ref ::rds/pool)
:app.nitrate/client (ig/ref :app.nitrate/client)
::wrk/executor (ig/ref ::wrk/netty-executor)
::session/manager (ig/ref ::session/manager)
::ldap/provider (ig/ref ::ldap/provider)
@@ -340,9 +339,6 @@
::email/blacklist (ig/ref ::email/blacklist)
::email/whitelist (ig/ref ::email/whitelist)}
:app.nitrate/client
{::http.client/client (ig/ref ::http.client/client)}
:app.rpc/management-methods
{::http.client/client (ig/ref ::http.client/client)
::db/pool (ig/ref ::db/pool)
@@ -352,7 +348,6 @@
::sto/storage (ig/ref ::sto/storage)
::mtx/metrics (ig/ref ::mtx/metrics)
::mbus/msgbus (ig/ref ::mbus/msgbus)
:app.nitrate/client (ig/ref :app.nitrate/client)
::rds/client (ig/ref ::rds/client)
::setup/props (ig/ref ::setup/props)}

View File

@@ -1,123 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.nitrate
"Module that make calls to the external nitrate aplication"
(:require
[app.common.logging :as l]
[app.common.schema :as sm]
[app.config :as cf]
[app.http.client :as http]
[app.rpc :as-alias rpc]
[app.setup :as-alias setup]
[app.util.json :as json]
[clojure.core :as c]
[integrant.core :as ig]))
(def baseuri (cf/get :nitrate-backend-uri))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- coercer
[schema & {:as opts}]
(let [decode-fn (sm/decoder schema sm/json-transformer)
check-fn (sm/check-fn schema opts)]
(fn [data]
(-> data decode-fn check-fn))))
(defn- request-builder
[cfg method uri management-key profile-id]
(fn []
(http/req! cfg {:method method
:headers {"content-type" "application/json"
"accept" "application/json"
"x-shared-key" management-key
"x-profile-id" (str profile-id)}
:uri uri
:version :http1.1})))
(defn- with-retries
[handler max-retries]
(fn []
(loop [attempt 1]
(let [result (try
(handler)
(catch Exception e
(if (< attempt max-retries)
::retry
(do
;; TODO Error handling
(l/error :hint "request fail after multiple retries" :cause e)
nil))))]
(if (= result ::retry)
(recur (inc attempt))
result)))))
(defn- with-validate [handler uri schema]
(fn []
(let [coercer-http (coercer schema
:type :validation
:hint (str "invalid data received calling " uri))]
(try
(coercer-http (-> (handler) :body json/decode))
(catch Exception e
;; TODO Error handling
(l/error :hint "error validating json response" :cause e)
nil)))))
(defn- request-to-nitrate
[{:keys [::management-key] :as cfg} method uri schema {:keys [::rpc/profile-id] :as params}]
(let [full-http-call (-> (request-builder cfg method uri management-key profile-id)
(with-retries 3)
(with-validate uri schema))]
(full-http-call)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn call
[cfg method params]
(when (contains? cf/flags :nitrate)
(let [client (get cfg ::client)
method (get client method)]
(method params))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:organization
[:map
[:id ::sm/text]
[:name ::sm/text]])
(defn- get-team-org
[cfg {:keys [team-id] :as params}]
(request-to-nitrate cfg :get (str baseuri "/api/teams/" (str team-id)) schema:organization params))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; INITIALIZATION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defmethod ig/init-key ::client
[_ {:keys [::setup/props] :as cfg}]
(if (contains? cf/flags :nitrate)
(let [management-key (or (cf/get :management-api-key)
(get props :management-key))
cfg (assoc cfg ::management-key management-key)]
{:get-team-org (partial get-team-org cfg)})
{}))
(defmethod ig/halt-key! ::client
[_ {:keys []}]
(do :stuff))

View File

@@ -296,7 +296,6 @@
(let [cfg (assoc cfg ::type "management" ::metrics-id :rpc-management-timing)]
(->> (sv/scan-ns
'app.rpc.management.subscription
'app.rpc.management.nitrate
'app.rpc.management.exporter)
(map (partial process-method cfg "management" wrap-management))
(into {}))))

View File

@@ -23,7 +23,6 @@
[app.main :as-alias main]
[app.media :as media]
[app.msgbus :as mbus]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as-alias doc]
@@ -173,12 +172,6 @@
(map decode-row)
(map process-permissions)))
(defn- add-org-to-team
[cfg team params]
(let [params (assoc (or params {}) :team-id (:id team))
org (nitrate/call cfg :get-team-org params)]
(assoc team :organization-id (:id org) :organization-name (:name org))))
(defn get-teams
[conn profile-id]
(let [profile (profile/get-profile conn profile-id)
@@ -197,9 +190,7 @@
::sm/params schema:get-teams}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
(dm/with-open [conn (db/open pool)]
(cond->> (get-teams conn profile-id)
(contains? cf/flags :nitrate)
(map #(add-org-to-team cfg % params)))))
(get-teams conn profile-id)))
(def ^:private sql:get-owned-teams
"SELECT t.id, t.name,

View File

@@ -1,112 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.rpc.management.nitrate
"Internal Nitrate HTTP API.
Provides authenticated access to organization management and token validation endpoints.
All requests must include a valid shared key token in the `x-shared-key` header, and
a cookie `auth-token` with the user token.
They will return `401 Unauthorized` if the shared key or user token are invalid."
(:require
[app.common.schema :as sm]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.msgbus :as mbus]
[app.rpc :as-alias rpc]
[app.rpc.commands.files :as files]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as doc]
[app.util.services :as sv]))
;; ---- API: authenticate
(def ^:private schema:profile
[:map
[:id ::sm/uuid]
[:name :string]
[:email :string]
[:photo-url :string]])
(sv/defmethod ::authenticate
"Authenticate an user
@api GET /authenticate
@returns
200 OK: Returns the authenticated user."
{::doc/added "2.12"
::sm/result schema:profile}
[cfg {:keys [::rpc/profile-id] :as params}]
(let [profile (profile/get-profile cfg profile-id)]
{:id (get profile :id)
:name (get profile :fullname)
:email (get profile :email)
:photo-url (files/resolve-public-uri (get profile :photo-id))}))
;; ---- API: get-teams
(def ^:private sql:get-teams
"SELECT t.*
FROM team AS t
JOIN team_profile_rel AS tpr ON t.id = tpr.team_id
WHERE tpr.profile_id = ?
AND tpr.is_owner = 't'
AND t.is_default = 'f'
AND t.deleted_at is null;")
(def ^:private schema:team
[:map
[:id ::sm/uuid]
[:name :string]])
(def ^:private schema:get-teams-result
[:vector schema:team])
(sv/defmethod ::get-teams
"List teams for which current user is owner.
@api GET /get-teams
@returns
200 OK: Returns the list of teams for the user."
{::doc/added "2.12"
::sm/result schema:get-teams-result}
[cfg {:keys [::rpc/profile-id]}]
(when (contains? cf/flags :nitrate)
(let [current-user-id (-> (profile/get-profile cfg profile-id) :id)]
(->> (db/exec! cfg [sql:get-teams current-user-id])
(map #(select-keys % [:id :name]))))))
;; ---- API: notify-team-change
(def ^:private schema:notify-team-change
[:map
[:id ::sm/uuid]
[:organization-id ::sm/text]])
(sv/defmethod ::notify-team-change
"Notify to Penpot a team change from nitrate
@api POST /notify-team-change
@returns
200 OK"
{::doc/added "2.12"
::sm/params schema:notify-team-change
::rpc/auth false}
[cfg {:keys [id organization-id organization-name]}]
(when (contains? cf/flags :nitrate)
(let [msgbus (::mbus/msgbus cfg)]
(mbus/pub! msgbus
;;TODO There is a bug on dashboard with teams notifications.
;;For now we send it to uuid/zero instead of team-id
:topic uuid/zero
:message {:type :team-org-change
:team-id id
:organization-id organization-id
:organization-name organization-name}))))

View File

@@ -27,6 +27,7 @@
[app.common.types.path :as path]
[app.common.types.shape :as cts]
[app.common.types.shape-tree :as ctst]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob]
[app.common.types.typographies-list :as ctyl]
[app.common.types.typography :as ctt]
@@ -378,7 +379,7 @@
[:type [:= :set-token]]
[:set-id ::sm/uuid]
[:token-id ::sm/uuid]
[:attrs [:maybe ctob/schema:token-attrs]]]]
[:attrs [:maybe cto/schema:token-attrs]]]]
[:set-token-set
[:map {:title "SetTokenSetChange"}

View File

@@ -82,113 +82,6 @@
(declare create-svg-children)
(declare parse-svg-element)
(defn- process-gradient-stops
"Processes gradient stops to extract stop-color and stop-opacity from style attributes
and convert them to direct attributes. This ensures stops with style='stop-color:#...;stop-opacity:1'
are properly converted to stop-color and stop-opacity attributes."
[stops]
(mapv (fn [stop]
(let [stop-attrs (:attrs stop)
stop-style (get stop-attrs :style)
;; Parse style if it's a string using csvg/parse-style utility
parsed-style (when (and (string? stop-style) (seq stop-style))
(csvg/parse-style stop-style))
;; Extract stop-color and stop-opacity from style
style-stop-color (when parsed-style (:stop-color parsed-style))
style-stop-opacity (when parsed-style (:stop-opacity parsed-style))
;; Merge: use direct attributes first, then style values as fallback
final-attrs (cond-> stop-attrs
(and style-stop-color (not (contains? stop-attrs :stop-color)))
(assoc :stop-color style-stop-color)
(and style-stop-opacity (not (contains? stop-attrs :stop-opacity)))
(assoc :stop-opacity style-stop-opacity)
;; Remove style attribute if we've extracted its values
(or style-stop-color style-stop-opacity)
(dissoc :style))]
(assoc stop :attrs final-attrs)))
stops))
(defn- resolve-gradient-href
"Resolves xlink:href references in gradients by merging the referenced gradient's
stops and attributes with the referencing gradient. This ensures gradients that
reference other gradients (like linearGradient3550 referencing linearGradient3536)
inherit the stops from the base gradient.
According to SVG spec, when a gradient has xlink:href:
- It inherits all attributes from the referenced gradient
- It inherits all stops from the referenced gradient
- The referencing gradient's attributes override the base ones
- If the referencing gradient has stops, they replace the base stops
Returns the defs map with all gradient href references resolved."
[defs]
(letfn [(resolve-gradient [gradient-id gradient-node defs visited]
(if (contains? visited gradient-id)
(do
#?(:cljs (js/console.warn "[resolve-gradient] Circular reference detected for" gradient-id)
:clj nil)
gradient-node) ;; Avoid circular references
(let [attrs (:attrs gradient-node)
href-id (or (:href attrs) (:xlink:href attrs))
href-id (when (and (string? href-id) (pos? (count href-id)))
(subs href-id 1)) ;; Remove leading #
base-gradient (when (and href-id (contains? defs href-id))
(get defs href-id))
resolved-base (when base-gradient (resolve-gradient href-id base-gradient defs (conj visited gradient-id)))]
(if resolved-base
;; Merge: base gradient attributes + referencing gradient attributes
;; Use referencing gradient's stops if present, otherwise use base stops
(let [base-attrs (:attrs resolved-base)
ref-attrs (:attrs gradient-node)
;; Start with base attributes (without id), then merge with ref attributes
;; This ensures ref attributes override base ones
base-attrs-clean (dissoc base-attrs :id)
ref-attrs-clean (dissoc ref-attrs :href :xlink:href :id)
;; Special handling for gradientTransform: if both have it, combine them
base-transform (get base-attrs :gradientTransform)
ref-transform (get ref-attrs :gradientTransform)
combined-transform (cond
(and base-transform ref-transform)
(str base-transform " " ref-transform) ;; Apply base first, then ref
:else (or ref-transform base-transform))
;; Merge attributes: base first, then ref (ref overrides)
merged-attrs (-> (d/deep-merge base-attrs-clean ref-attrs-clean)
(cond-> combined-transform
(assoc :gradientTransform combined-transform)))
;; If referencing gradient has content (stops), use it; otherwise use base content
final-content (if (seq (:content gradient-node))
(:content gradient-node)
(:content resolved-base))
;; Process stops to extract stop-color and stop-opacity from style attributes
processed-content (process-gradient-stops final-content)
result {:tag (:tag gradient-node)
:attrs (assoc merged-attrs :id gradient-id)
:content processed-content}]
result)
;; Process stops even for gradients without references to extract style attributes
(let [processed-content (process-gradient-stops (:content gradient-node))]
(assoc gradient-node :content processed-content))))))]
(let [gradient-tags #{:linearGradient :radialGradient}
result (reduce-kv
(fn [acc id node]
(if (contains? gradient-tags (:tag node))
(assoc acc id (resolve-gradient id node defs #{}))
(assoc acc id node)))
{}
defs)]
result)))
(defn create-svg-shapes
([svg-data pos objects frame-id parent-id selected center?]
(create-svg-shapes (uuid/next) svg-data pos objects frame-id parent-id selected center?))
@@ -219,9 +112,6 @@
(csvg/fix-percents)
(csvg/extract-defs))
;; Resolve gradient href references in all defs before processing shapes
def-nodes (resolve-gradient-href def-nodes)
;; In penpot groups have the size of their children. To
;; respect the imported svg size and empty space let's create
;; a transparent shape as background to respect the imported
@@ -252,23 +142,12 @@
(reduce (partial create-svg-children objects selected frame-id root-id svg-data)
[unames []]
(d/enumerate (->> (:content svg-data)
(mapv #(csvg/inherit-attributes root-attrs %)))))
(mapv #(csvg/inherit-attributes root-attrs %)))))]
;; Collect all defs from children and merge into root shape
all-defs-from-children (reduce (fn [acc child]
(if-let [child-defs (:svg-defs child)]
(merge acc child-defs)
acc))
{}
children)
;; Merge defs from svg-data and children into root shape
root-shape-with-defs (assoc root-shape :svg-defs (merge def-nodes all-defs-from-children))]
[root-shape-with-defs children])))
[root-shape children])))
(defn create-raw-svg
[name frame-id {:keys [x y width height offset-x offset-y defs] :as svg-data} {:keys [attrs] :as data}]
[name frame-id {:keys [x y width height offset-x offset-y]} {:keys [attrs] :as data}]
(let [props (csvg/attrs->props attrs)
vbox (grc/make-rect offset-x offset-y width height)]
(cts/setup-shape
@@ -281,11 +160,10 @@
:y y
:content data
:svg-attrs props
:svg-viewbox vbox
:svg-defs defs})))
:svg-viewbox vbox})))
(defn create-svg-root
[id frame-id parent-id {:keys [name x y width height offset-x offset-y attrs defs] :as svg-data}]
[id frame-id parent-id {:keys [name x y width height offset-x offset-y attrs]}]
(let [props (-> (dissoc attrs :viewBox :view-box :xmlns)
(d/without-keys csvg/inheritable-props)
(csvg/attrs->props))]
@@ -299,8 +177,7 @@
:height height
:x (+ x offset-x)
:y (+ y offset-y)
:svg-attrs props
:svg-defs defs})))
:svg-attrs props})))
(defn create-svg-children
[objects selected frame-id parent-id svg-data [unames children] [_index svg-element]]
@@ -321,7 +198,7 @@
(defn create-group
[name frame-id {:keys [x y width height offset-x offset-y defs] :as svg-data} {:keys [attrs]}]
[name frame-id {:keys [x y width height offset-x offset-y] :as svg-data} {:keys [attrs]}]
(let [transform (csvg/parse-transform (:transform attrs))
attrs (-> attrs
(d/without-keys csvg/inheritable-props)
@@ -337,8 +214,7 @@
:height height
:svg-transform transform
:svg-attrs attrs
:svg-viewbox vbox
:svg-defs defs})))
:svg-viewbox vbox})))
(defn create-path-shape [name frame-id svg-data {:keys [attrs] :as data}]
(when (and (contains? attrs :d) (seq (:d attrs)))
@@ -647,21 +523,6 @@
:else (dm/str tag))]
(dm/str "svg-" suffix)))
(defn- filter-valid-def-references
"Filters out false positive references that are not valid def IDs.
Filters out:
- Colors in style attributes (hex colors like #f9dd67)
- Style fragments that contain CSS keywords (like stop-opacity)
- References that don't exist in defs"
[ref-ids defs]
(let [is-style-fragment? (fn [ref-id]
(or (clr/hex-color-string? (str "#" ref-id))
(str/includes? ref-id ";") ;; Contains CSS separator
(str/includes? ref-id "stop-opacity") ;; CSS keyword
(str/includes? ref-id "stop-color")))] ;; CSS keyword
(->> ref-ids
(remove is-style-fragment?) ;; Filter style fragments and hex colors
(filter #(contains? defs %))))) ;; Only existing defs
(defn parse-svg-element
[frame-id svg-data {:keys [tag attrs hidden] :as element} unames]
@@ -673,11 +534,7 @@
(let [name (or (:id attrs) (tag->name tag))
att-refs (csvg/find-attr-references attrs)
defs (get svg-data :defs)
valid-refs (filter-valid-def-references att-refs defs)
all-refs (csvg/find-def-references defs valid-refs)
;; Filter the final result to ensure all references are valid defs
;; This prevents false positives from style attributes in gradient stops
references (filter-valid-def-references all-refs defs)
references (csvg/find-def-references defs att-refs)
href-id (or (:href attrs) (:xlink:href attrs) " ")
href-id (if (and (string? href-id)

View File

@@ -8,9 +8,112 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.i18n :refer [tr]]
[app.common.schema :as sm]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob]
[clojure.set :as set]
[cuerdas.core :as str]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HIGH LEVEL SCHEMAS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Token
(defn make-token-name-schema
"Dynamically generates a schema to check a token name, adding translated error messages
and two additional validations:
- Min and max length.
- Checks if other token with a path derived from the name already exists at `tokens-tree`.
e.g. it's not allowed to create a token `foo.bar` if a token `foo` already exists."
[tokens-tree]
[:and
(-> cto/schema:token-name
(sm/update-properties assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))))
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
[:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))}
#(not (ctob/token-name-path-exists? % tokens-tree))]])
(def schema:token-description
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}])
(defn make-token-schema
[tokens-tree]
(sm/merge
cto/schema:token-attrs
[:map
[:name (make-token-name-schema tokens-tree)]
[:description {:optional true} schema:token-description]]))
;; Token set
(defn make-token-set-name-schema
"Generates a dynamic schema to check a token set name:
- Validate name length.
- Checks if other token set with a path derived from the name already exists in the tokens lib."
[tokens-lib set-id]
[:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
[:fn {:error/fn #(tr "errors.token-set-already-exists" (:value %))}
(fn [name]
(let [set (ctob/get-set-by-name tokens-lib name)]
(or (nil? set) (= (ctob/get-id set) set-id))))]])
(def schema:token-set-description
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}])
(defn make-token-set-schema
[tokens-lib set-id]
(sm/merge
ctob/schema:token-set-attrs
[:map
[:name [:and (make-token-set-name-schema tokens-lib set-id)
[:fn #(ctob/normalized-set-name? %)]]]
[:description {:optional true} schema:token-set-description]]))
;; Token theme
(defn make-token-theme-group-schema
"Generates a dynamic schema to check a token theme group:
- Validate group length.
- Checks if other token theme with the same name already exists in the new group in the tokens lib."
[tokens-lib name theme-id]
[:and
[:string {:min 0 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
[:fn {:error/fn #(tr "errors.token-theme-already-exists" (:value %))}
(fn [group]
(let [theme (ctob/get-theme-by-name tokens-lib group name)]
(or (nil? theme) (= (:id theme) theme-id))))]])
(defn make-token-theme-name-schema
"Generates a dynamic schema to check a token theme name:
- Validate name length.
- Checks if other token theme with the same name already exists in the same group in the tokens lib."
[tokens-lib group theme-id]
[:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
[:fn {:error/fn #(tr "errors.token-theme-already-exists" (str group "/" (:value %)))}
(fn [name]
(let [theme (ctob/get-theme-by-name tokens-lib group name)]
(or (nil? theme) (= (:id theme) theme-id))))]])
(def schema:token-theme-description
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}])
(defn make-token-theme-schema
[tokens-lib group name theme-id]
(sm/merge
ctob/schema:token-theme-attrs
[:map
[:group (make-token-theme-group-schema tokens-lib name theme-id)] ;; TODO how to keep error-fn from here?
[:name (make-token-theme-name-schema tokens-lib group theme-id)]
[:description {:optional true} schema:token-theme-description]]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def parseable-token-value-regexp
"Regexp that can be used to parse a number value out of resolved token value.
This regexp also trims whitespace around the value."
@@ -80,56 +183,6 @@
(defn shapes-applied-all? [ids-by-attributes shape-ids attributes]
(every? #(set/superset? (get ids-by-attributes %) shape-ids) attributes))
(defn token-name->path
"Splits token-name into a path vector split by `.` characters.
Will concatenate multiple `.` characters into one."
[token-name]
(str/split token-name #"\.+"))
(defn token-name->path-selector
"Splits token-name into map with `:path` and `:selector` using `token-name->path`.
`:selector` is the last item of the names path
`:path` is everything leading up the the `:selector`."
[token-name]
(let [path-segments (token-name->path token-name)
last-idx (dec (count path-segments))
[path [selector]] (split-at last-idx path-segments)]
{:path (seq path)
:selector selector}))
(defn token-name-path-exists?
"Traverses the path from `token-name` down a `token-tree` and checks if a token at that path exists.
It's not allowed to create a token inside a token. E.g.:
Creating a token with
{:name \"foo.bar\"}
in the tokens tree:
{\"foo\" {:name \"other\"}}"
[token-name token-names-tree]
(let [{:keys [path selector]} (token-name->path-selector token-name)
path-target (reduce
(fn [acc cur]
(let [target (get acc cur)]
(cond
;; Path segment doesn't exist yet
(nil? target) (reduced false)
;; A token exists at this path
(:name target) (reduced true)
;; Continue traversing the true
:else target)))
token-names-tree path)]
(cond
(boolean? path-target) path-target
(get path-target :name) true
:else (-> (get path-target selector)
(seq)
(boolean)))))
(defn color-token? [token]
(= (:type token) :color))

View File

@@ -145,10 +145,7 @@
;; A temporal flag, enables backend code use more extensivelly
;; redis for caching data
:redis-cache
;; Activates the nitrate module
:nitrate})
:redis-cache})
(def all-flags
(set/union email login varia))

View File

@@ -0,0 +1,15 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.common.i18n
"Dummy i18n functions, to be used by code in common that needs translations.")
(defn tr
"This function will be monkeypatched at runtime with the real function in frontend i18n.
Here it just returns the key passed as argument. This way the result can be used in
unit tests or backend code for logs or error messages."
[key & _args]
key)

View File

@@ -58,7 +58,7 @@
(cto/shape-attr->token-attrs attr changed-sub-attr))]
(if (some #(contains? tokens %) token-attrs)
(pcb/update-shapes changes [shape-id] #(cto/unapply-token-id % token-attrs))
(pcb/update-shapes changes [shape-id] #(cto/unapply-tokens-from-shape % token-attrs))
changes)))
check-shape

View File

@@ -132,94 +132,3 @@ Some naming conventions:
(if-let [last-period (str/last-index-of s ".")]
[(subs s 0 (inc last-period)) (subs s (inc last-period))]
[s ""]))
;; Tree building functions --------------------------------------------------
"Build tree structure from flat list of paths"
"`build-tree-root` is the main function to build the tree."
"Receives a list of segments with 'name' properties representing paths,
and a separator string."
"E.g segments = [{... :name 'one/two/three'} {... :name 'one/two/four'} {... :name 'one/five'}]"
"Transforms into a tree structure like:
[{:name 'one'
:path 'one'
:depth 0
:leaf nil
:children-fn (fn [] [{:name 'two'
:path 'one.two'
:depth 1
:leaf nil
:children-fn (fn [] [{... :name 'three'} {... :name 'four'}])}
{:name 'five'
:path 'one.five'
:depth 1
:leaf {... :name 'five'}
...}])}]"
(defn- sort-by-children
"Sorts segments so that those with children come first."
[segments separator]
(sort-by (fn [segment]
(let [path (split-path (:name segment) :separator separator)
path-length (count path)]
(if (= path-length 1)
1
0)))
segments))
(defn- group-by-first-segment
"Groups segments by their first path segment and update segment name."
[segments separator]
(reduce (fn [acc segment]
(let [[first-segment & remaining-segments] (split-path (:name segment) :separator separator)
rest-path (when (seq remaining-segments) (join-path remaining-segments :separator separator :with-spaces? false))]
(update acc first-segment (fnil conj [])
(if rest-path
(assoc segment :name rest-path)
segment))))
{}
segments))
(defn- sort-and-group-segments
"Sorts elements and groups them by their first path segment."
[segments separator]
(let [sorted (sort-by-children segments separator)
grouped (group-by-first-segment sorted separator)]
grouped))
(defn- build-tree-node
"Builds a single tree node with lazy children."
[segment-name remaining-segments separator parent-path depth]
(let [current-path (if parent-path
(str parent-path "." segment-name)
segment-name)
is-leaf? (and (seq remaining-segments)
(every? (fn [segment]
(let [remaining-segment-name (first (split-path (:name segment) :separator separator))]
(= segment-name remaining-segment-name)))
remaining-segments))
leaf-segment (when is-leaf? (first remaining-segments))
node {:name segment-name
:path current-path
:depth depth
:leaf leaf-segment
:children-fn (when-not is-leaf?
(fn []
(let [grouped-elements (sort-and-group-segments remaining-segments separator)]
(mapv (fn [[child-segment-name remaining-child-segments]]
(build-tree-node child-segment-name remaining-child-segments separator current-path (inc depth)))
grouped-elements))))}]
node))
(defn build-tree-root
"Builds the root level of the tree."
[segments separator]
(let [grouped-elements (sort-and-group-segments segments separator)]
(mapv (fn [[segment-name remaining-segments]]
(build-tree-node segment-name remaining-segments separator nil 0))
grouped-elements)))

View File

@@ -92,6 +92,16 @@
[& items]
(apply mu/merge (map schema items)))
(defn assoc-key
"Add a key & value to a schema"
[s k v]
(mu/assoc (schema s) k v))
(defn dissoc-key
"Remove a key from a schema"
[s k]
(mu/dissoc (schema s) k))
(defn ref?
[s]
(m/-ref-schema? s))
@@ -270,6 +280,13 @@
(let [explain (fn [] (me/with-error-messages explain))]
((mdp/prettifier variant message explain default-options)))))
(defn validation-errors
"Checks a value against a schema. If valid, returns nil. If not, returns a list
of english error messages."
[value schema]
(let [explainer (explainer schema)]
(-> value explainer simplify not-empty)))
(defmacro ignoring
[expr]
(if (:ns &env)

View File

@@ -546,19 +546,9 @@
filter-values)))
(defn extract-ids [val]
;; Extract referenced ids from string values like "url(#myId)".
;; Non-string values (maps, numbers, nil, etc.) return an empty seq
;; to avoid re-seq type errors when attributes carry nested structures.
(cond
(string? val)
(when (some? val)
(->> (re-seq xml-id-regex val)
(mapv second))
(sequential? val)
(mapcat extract-ids val)
:else
[]))
(mapv second))))
(defn fix-dot-number
"Fixes decimal numbers starting in dot but without leading 0"

View File

@@ -234,15 +234,16 @@
"Calculate the boolean content from shape and objects. Returns a
packed PathData instance"
[shape objects]
(let [content (calc-bool-content* shape objects)]
(let [content (if (fn? wasm:calc-bool-content)
(wasm:calc-bool-content (get shape :bool-type)
(get shape :shapes))
(calc-bool-content* shape objects))]
(impl/path-data content)))
(defn update-bool-shape
"Calculates the selrect+points for the boolean shape"
[shape objects]
(let [content (if (fn? wasm:calc-bool-content)
(wasm:calc-bool-content shape objects)
(calc-bool-content shape objects))
(let [content (calc-bool-content shape objects)
shape (assoc shape :content content)]
(update-geometry shape)))

View File

@@ -9,13 +9,13 @@
[app.common.data :as d]
[app.common.schema :as sm]
[app.common.schema.generators :as sg]
[clojure.data :as data]
[app.common.time :as ct]
[clojure.set :as set]
[cuerdas.core :as str]
[malli.util :as mu]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS
;; GENERAL HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- schema-keys
@@ -45,32 +45,33 @@
[token-name token-value]
(let [token-references (find-token-value-references token-value)
self-reference? (get token-references token-name)]
self-reference?))
(boolean self-reference?)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SCHEMA
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn composite-token-reference?
"Predicate if a composite token is a reference value - a string pointing to another token."
[token-value]
(string? token-value))
(def token-type->dtcg-token-type
{:boolean "boolean"
:border-radius "borderRadius"
:shadow "shadow"
:color "color"
:dimensions "dimension"
:font-family "fontFamilies"
:font-size "fontSizes"
:font-weight "fontWeights"
:letter-spacing "letterSpacing"
:number "number"
:opacity "opacity"
:other "other"
:rotation "rotation"
:shadow "shadow"
:sizing "sizing"
:spacing "spacing"
:string "string"
:stroke-width "borderWidth"
:text-case "textCase"
:text-decoration "textDecoration"
:font-weight "fontWeights"
:typography "typography"})
(def dtcg-token-type->token-type
@@ -82,14 +83,13 @@
"boxShadow" :shadow)))
(def composite-token-type->dtcg-token-type
"Custom set of conversion keys for composite typography token with `:line-height` available.
(Penpot doesn't support `:line-height` token)"
"When converting the type of one element inside a composite token, an additional type
:line-height is available, that is not allowed for a standalone token."
(assoc token-type->dtcg-token-type
:line-height "lineHeights"))
(def composite-dtcg-token-type->token-type
"Custom set of conversion keys for composite typography token with `:line-height` available.
(Penpot doesn't support `:line-height` token)"
"Same as above, in the opposite direction."
(assoc dtcg-token-type->token-type
"lineHeights" :line-height
"lineHeight" :line-height))
@@ -97,93 +97,95 @@
(def token-types
(into #{} (keys token-type->dtcg-token-type)))
(def token-name-ref
[:re {:title "TokenNameRef" :gen/gen sg/text}
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SCHEMA: Token
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def schema:token-name
"A token name can contains letters, numbers, underscores the character $ and dots, but
not start with $ or end with a dot. The $ character does not have any special meaning,
but dots separate token groups (e.g. color.primary.background)."
[:re {:title "TokenName"
:gen/gen sg/text}
#"^(?!\$)([a-zA-Z0-9-$_]+\.?)*(?<!\.)$"])
(def ^:private schema:color
[:map
[:fill {:optional true} token-name-ref]
[:stroke-color {:optional true} token-name-ref]])
(def schema:token-type
[::sm/one-of {:decode/json (fn [type]
(if (string? type)
(dtcg-token-type->token-type type)
type))}
(def color-keys (schema-keys schema:color))
token-types])
(def schema:token-attrs
[:map {:title "Token"}
[:id ::sm/uuid]
[:name schema:token-name]
[:type schema:token-type]
[:value ::sm/any]
[:description {:optional true} :string]
[:modified-at {:optional true} ::ct/inst]])
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SCHEMA: Token application to shape
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; All the following schemas define the `:applied-tokens` attribute of a shape.
;; This attribute is a map <token-attribute> -> <token-name>.
;; Token attributes approximately match shape attributes, but not always.
;; For each schema there is a `*keys` set including all the possible token attributes
;; to which a token of the corresponding type can be applied.
;; Some token types can be applied to some attributes only if the shape has a
;; particular condition (i.e. has a layout itself or is a layout item).
(def ^:private schema:border-radius
[:map {:title "BorderRadiusTokenAttrs"}
[:r1 {:optional true} token-name-ref]
[:r2 {:optional true} token-name-ref]
[:r3 {:optional true} token-name-ref]
[:r4 {:optional true} token-name-ref]])
[:r1 {:optional true} schema:token-name]
[:r2 {:optional true} schema:token-name]
[:r3 {:optional true} schema:token-name]
[:r4 {:optional true} schema:token-name]])
(def border-radius-keys (schema-keys schema:border-radius))
(def ^:private schema:shadow
[:map {:title "ShadowTokenAttrs"}
[:shadow {:optional true} token-name-ref]])
(def shadow-keys (schema-keys schema:shadow))
(def ^:private schema:stroke-width
(def ^:private schema:color
[:map
[:stroke-width {:optional true} token-name-ref]])
[:fill {:optional true} schema:token-name]
[:stroke-color {:optional true} schema:token-name]])
(def stroke-width-keys (schema-keys schema:stroke-width))
(def color-keys (schema-keys schema:color))
(def ^:private schema:sizing-base
[:map {:title "SizingBaseTokenAttrs"}
[:width {:optional true} token-name-ref]
[:height {:optional true} token-name-ref]])
[:width {:optional true} schema:token-name]
[:height {:optional true} schema:token-name]])
(def ^:private schema:sizing-layout-item
[:map {:title "SizingLayoutItemTokenAttrs"}
[:layout-item-min-w {:optional true} token-name-ref]
[:layout-item-max-w {:optional true} token-name-ref]
[:layout-item-min-h {:optional true} token-name-ref]
[:layout-item-max-h {:optional true} token-name-ref]])
[:layout-item-min-w {:optional true} schema:token-name]
[:layout-item-max-w {:optional true} schema:token-name]
[:layout-item-min-h {:optional true} schema:token-name]
[:layout-item-max-h {:optional true} schema:token-name]])
(def sizing-layout-item-keys (schema-keys schema:sizing-layout-item))
(def ^:private schema:sizing
(-> (reduce mu/union [schema:sizing-base
schema:sizing-layout-item])
(mu/update-properties assoc :title "SizingTokenAttrs")))
(def sizing-layout-item-keys (schema-keys schema:sizing-layout-item))
(def sizing-keys (schema-keys schema:sizing))
(def ^:private schema:opacity
[:map {:title "OpacityTokenAttrs"}
[:opacity {:optional true} token-name-ref]])
(def opacity-keys (schema-keys schema:opacity))
(def ^:private schema:spacing-gap
[:map {:title "SpacingGapTokenAttrs"}
[:row-gap {:optional true} token-name-ref]
[:column-gap {:optional true} token-name-ref]])
[:row-gap {:optional true} schema:token-name]
[:column-gap {:optional true} schema:token-name]])
(def ^:private schema:spacing-padding
[:map {:title "SpacingPaddingTokenAttrs"}
[:p1 {:optional true} token-name-ref]
[:p2 {:optional true} token-name-ref]
[:p3 {:optional true} token-name-ref]
[:p4 {:optional true} token-name-ref]])
(def ^:private schema:spacing-margin
[:map {:title "SpacingMarginTokenAttrs"}
[:m1 {:optional true} token-name-ref]
[:m2 {:optional true} token-name-ref]
[:m3 {:optional true} token-name-ref]
[:m4 {:optional true} token-name-ref]])
(def ^:private schema:spacing
(-> (reduce mu/union [schema:spacing-gap
schema:spacing-padding
schema:spacing-margin])
(mu/update-properties assoc :title "SpacingTokenAttrs")))
(def spacing-margin-keys (schema-keys schema:spacing-margin))
(def spacing-keys (schema-keys schema:spacing))
[:p1 {:optional true} schema:token-name]
[:p2 {:optional true} schema:token-name]
[:p3 {:optional true} schema:token-name]
[:p4 {:optional true} schema:token-name]])
(def ^:private schema:spacing-gap-padding
(-> (reduce mu/union [schema:spacing-gap
@@ -192,6 +194,29 @@
(def spacing-gap-padding-keys (schema-keys schema:spacing-gap-padding))
(def ^:private schema:spacing-margin
[:map {:title "SpacingMarginTokenAttrs"}
[:m1 {:optional true} schema:token-name]
[:m2 {:optional true} schema:token-name]
[:m3 {:optional true} schema:token-name]
[:m4 {:optional true} schema:token-name]])
(def spacing-margin-keys (schema-keys schema:spacing-margin))
(def ^:private schema:spacing
(-> (reduce mu/union [schema:spacing-gap
schema:spacing-padding
schema:spacing-margin])
(mu/update-properties assoc :title "SpacingTokenAttrs")))
(def spacing-keys (schema-keys schema:spacing))
(def ^:private schema:stroke-width
[:map
[:stroke-width {:optional true} schema:token-name]])
(def stroke-width-keys (schema-keys schema:stroke-width))
(def ^:private schema:dimensions
(-> (reduce mu/union [schema:sizing
schema:spacing
@@ -201,91 +226,109 @@
(def dimensions-keys (schema-keys schema:dimensions))
(def ^:private schema:axis
[:map
[:x {:optional true} token-name-ref]
[:y {:optional true} token-name-ref]])
(def axis-keys (schema-keys schema:axis))
(def ^:private schema:rotation
[:map {:title "RotationTokenAttrs"}
[:rotation {:optional true} token-name-ref]])
(def rotation-keys (schema-keys schema:rotation))
(def ^:private schema:font-size
[:map {:title "FontSizeTokenAttrs"}
[:font-size {:optional true} token-name-ref]])
(def font-size-keys (schema-keys schema:font-size))
(def ^:private schema:letter-spacing
[:map {:title "LetterSpacingTokenAttrs"}
[:letter-spacing {:optional true} token-name-ref]])
(def letter-spacing-keys (schema-keys schema:letter-spacing))
(def ^:private schema:font-family
[:map
[:font-family {:optional true} token-name-ref]])
[:font-family {:optional true} schema:token-name]])
(def font-family-keys (schema-keys schema:font-family))
(def ^:private schema:text-case
[:map
[:text-case {:optional true} token-name-ref]])
(def ^:private schema:font-size
[:map {:title "FontSizeTokenAttrs"}
[:font-size {:optional true} schema:token-name]])
(def text-case-keys (schema-keys schema:text-case))
(def font-size-keys (schema-keys schema:font-size))
(def ^:private schema:font-weight
[:map
[:font-weight {:optional true} token-name-ref]])
[:font-weight {:optional true} schema:token-name]])
(def font-weight-keys (schema-keys schema:font-weight))
(def ^:private schema:typography
[:map
[:typography {:optional true} token-name-ref]])
(def ^:private schema:letter-spacing
[:map {:title "LetterSpacingTokenAttrs"}
[:letter-spacing {:optional true} schema:token-name]])
(def typography-token-keys (schema-keys schema:typography))
(def letter-spacing-keys (schema-keys schema:letter-spacing))
(def ^:private schema:text-decoration
[:map
[:text-decoration {:optional true} token-name-ref]])
(def ^:private schema:line-height ;; This is not available for standalone tokens, only typography
[:map {:title "LineHeightTokenAttrs"}
[:line-height {:optional true} schema:token-name]])
(def text-decoration-keys (schema-keys schema:text-decoration))
(def line-height-keys (schema-keys schema:line-height))
(def typography-keys (set/union font-size-keys
letter-spacing-keys
font-family-keys
font-weight-keys
text-case-keys
text-decoration-keys
font-weight-keys
typography-token-keys
#{:line-height}))
(def ^:private schema:rotation
[:map {:title "RotationTokenAttrs"}
[:rotation {:optional true} schema:token-name]])
(def rotation-keys (schema-keys schema:rotation))
(def ^:private schema:number
(-> (reduce mu/union [[:map [:line-height {:optional true} token-name-ref]]
(-> (reduce mu/union [schema:line-height
schema:rotation])
(mu/update-properties assoc :title "NumberTokenAttrs")))
(def number-keys (schema-keys schema:number))
(def all-keys (set/union color-keys
(def ^:private schema:opacity
[:map {:title "OpacityTokenAttrs"}
[:opacity {:optional true} schema:token-name]])
(def opacity-keys (schema-keys schema:opacity))
(def ^:private schema:shadow
[:map {:title "ShadowTokenAttrs"}
[:shadow {:optional true} schema:token-name]])
(def shadow-keys (schema-keys schema:shadow))
(def ^:private schema:text-case
[:map
[:text-case {:optional true} schema:token-name]])
(def text-case-keys (schema-keys schema:text-case))
(def ^:private schema:text-decoration
[:map
[:text-decoration {:optional true} schema:token-name]])
(def text-decoration-keys (schema-keys schema:text-decoration))
(def ^:private schema:typography
[:map
[:typography {:optional true} schema:token-name]])
(def typography-token-keys (schema-keys schema:typography))
(def typography-keys (set/union font-family-keys
font-size-keys
font-weight-keys
font-weight-keys
letter-spacing-keys
line-height-keys
text-case-keys
text-decoration-keys
typography-token-keys))
(def ^:private schema:axis
[:map
[:x {:optional true} schema:token-name]
[:y {:optional true} schema:token-name]])
(def axis-keys (schema-keys schema:axis))
(def all-keys (set/union axis-keys
border-radius-keys
shadow-keys
stroke-width-keys
sizing-keys
opacity-keys
spacing-keys
color-keys
dimensions-keys
axis-keys
number-keys
opacity-keys
rotation-keys
shadow-keys
sizing-keys
spacing-keys
stroke-width-keys
typography-keys
typography-token-keys
number-keys))
typography-token-keys))
(def ^:private schema:tokens
[:map {:title "GenericTokenAttrs"}])
@@ -306,11 +349,28 @@
schema:text-decoration
schema:dimensions])
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS for token attributes by token type
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn token-attr?
[attr]
(contains? all-keys attr))
(defn token-attr->shape-attr
"Returns the actual shape attribute affected when a token have been applied
to a given `token-attr`."
[token-attr]
(case token-attr
:fill :fills
:stroke-color :strokes
:stroke-width :strokes
token-attr))
(defn shape-attr->token-attrs
"Returns the token-attr affected when a given attribute in a shape is changed.
The sub-attr is for attributes that may have multiple values, like strokes
(may be width or color) and layout padding & margin (may have 4 edges)."
([shape-attr] (shape-attr->token-attrs shape-attr nil))
([shape-attr changed-sub-attr]
(cond
@@ -352,21 +412,13 @@
(number-keys shape-attr) #{shape-attr}
(axis-keys shape-attr) #{shape-attr})))
(defn token-attr->shape-attr
[token-attr]
(case token-attr
:fill :fills
:stroke-color :strokes
:stroke-width :strokes
token-attr))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TOKEN SHAPE ATTRIBUTES
;; HELPERS for token attributes by shape type
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def position-attributes #{:x :y})
(def ^:private position-attributes #{:x :y})
(def generic-attributes
(def ^:private generic-attributes
(set/union color-keys
stroke-width-keys
rotation-keys
@@ -375,20 +427,22 @@
shadow-keys
position-attributes))
(def rect-attributes
(def ^:private rect-attributes
(set/union generic-attributes
border-radius-keys))
(def frame-with-layout-attributes
(def ^:private frame-with-layout-attributes
(set/union rect-attributes
spacing-gap-padding-keys))
(def text-attributes
(def ^:private text-attributes
(set/union generic-attributes
typography-keys
number-keys))
(defn shape-type->attributes
"Returns what token attributes may be applied to a shape depending on its type
and if it is a frame with a layout."
[type is-layout]
(case type
:bool generic-attributes
@@ -404,12 +458,14 @@
nil))
(defn appliable-attrs-for-shape
"Returns intersection of shape `attributes` for `shape-type`."
"Returns which ones of the given `attributes` can be applied to a shape
of type `shape-type` and `is-layout`."
[attributes shape-type is-layout]
(set/intersection attributes (shape-type->attributes shape-type is-layout)))
(defn any-appliable-attr-for-shape?
"Checks if `token-type` supports given shape `attributes`."
"Returns if any of the given `attributes` can be applied to a shape
of type `shape-type` and `is-layout`."
[attributes token-type is-layout]
(d/not-empty? (appliable-attrs-for-shape attributes token-type is-layout)))
@@ -420,42 +476,6 @@
typography-keys
#{:fill}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TOKENS IN SHAPES
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- toggle-or-apply-token
"Remove any shape attributes from token if they exists.
Othewise apply token attributes."
[shape token]
(let [[shape-leftover token-leftover _matching] (data/diff (:applied-tokens shape) token)]
(merge {} shape-leftover token-leftover)))
(defn- token-from-attributes [token attributes]
(->> (map (fn [attr] [attr (:name token)]) attributes)
(into {})))
(defn- apply-token-to-attributes [{:keys [shape token attributes]}]
(let [token (token-from-attributes token attributes)]
(toggle-or-apply-token shape token)))
(defn apply-token-to-shape
[{:keys [shape token attributes] :as _props}]
(let [applied-tokens (apply-token-to-attributes {:shape shape
:token token
:attributes attributes})]
(update shape :applied-tokens #(merge % applied-tokens))))
(defn unapply-token-id [shape attributes]
(update shape :applied-tokens d/without-keys attributes))
(defn unapply-layout-item-tokens
"Unapplies all layout item related tokens from shape."
[shape]
(let [layout-item-attrs (set/union sizing-layout-item-keys
spacing-margin-keys)]
(unapply-token-id shape layout-item-attrs)))
(def tokens-by-input
"A map from input name to applicable token for that input."
{:width #{:sizing :dimensions}
@@ -481,7 +501,48 @@
:stroke-color #{:color}})
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TYPOGRAPHY
;; HELPERS for tokens application
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TODO it seems that this function is redundant, maybe?
;; (defn- toggle-or-apply-token
;; "Remove any shape attributes from token if they exists.
;; Othewise apply token attributes."
;; [shape token]
;; (let [[only-in-shape only-in-token _matching] (data/diff (:applied-tokens shape) token)]
;; (merge {} only-in-shape only-in-token)))
(defn- generate-attr-map [token attributes]
(->> (map (fn [attr] [attr (:name token)]) attributes)
(into {})))
(defn apply-token-to-shape
"Applies the token to the given attributes in the shape."
[{:keys [shape token attributes] :as _props}]
(let [map-to-apply (generate-attr-map token attributes)]
(update shape :applied-tokens #(merge % map-to-apply))))
;; (defn apply-token-to-shape
;; [{:keys [shape token attributes] :as _props}]
;; (let [map-to-apply (generate-attr-map token attributes)
;; applied-tokens (toggle-or-apply-token shape map-to-apply)]
;; (update shape :applied-tokens #(merge % applied-tokens))))
(defn unapply-tokens-from-shape
"Removes any token applied to the given attributes in the shape."
[shape attributes]
(update shape :applied-tokens d/without-keys attributes))
(defn unapply-layout-item-tokens
"Unapplies all layout item related tokens from shape."
[shape]
(let [layout-item-attrs (set/union sizing-layout-item-keys
spacing-margin-keys)]
(unapply-tokens-from-shape shape layout-item-attrs)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS for typography tokens
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn split-font-family
@@ -544,17 +605,3 @@
(when (font-weight-values weight)
(cond-> {:weight weight}
italic? (assoc :style "italic")))))
(defn typography-composite-token-reference?
"Predicate if a typography composite token is a reference value - a string pointing to another reference token."
[token-value]
(string? token-value))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SHADOW
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn shadow-composite-token-reference?
"Predicate if a shadow composite token is a reference value - a string pointing to another reference token."
[token-value]
(string? token-value))

View File

@@ -114,25 +114,19 @@
[o]
(instance? Token o))
(def schema:token-attrs
[:map {:title "Token"}
[:id ::sm/uuid]
[:name cto/token-name-ref]
[:type [::sm/one-of cto/token-types]]
[:value ::sm/any]
[:description {:optional true} :string]
[:modified-at {:optional true} ::ct/inst]])
(declare make-token)
(def schema:token
[:and {:gen/gen (->> (sg/generator schema:token-attrs)
[:and {:gen/gen (->> (sg/generator cto/schema:token-attrs)
(sg/fmap #(make-token %)))}
(sm/required-keys schema:token-attrs)
(sm/required-keys cto/schema:token-attrs)
[:fn token?]])
(def ^:private check-token-attrs
(sm/check-fn schema:token-attrs :hint "expected valid params for token"))
(sm/check-fn cto/schema:token-attrs :hint "expected valid params for token"))
(def decode-token-attrs
(sm/lazy-decoder cto/schema:token-attrs sm/json-transformer))
(def check-token
(sm/check-fn schema:token :hint "expected valid token"))
@@ -317,10 +311,13 @@
[o]
(instance? TokenSetLegacy o))
(declare make-token-set)
(declare normalized-set-name?)
(def schema:token-set-attrs
[:map {:title "TokenSet"}
[:id ::sm/uuid]
[:name :string]
[:name [:and :string [:fn #(normalized-set-name? %)]]]
[:description {:optional true} :string]
[:modified-at {:optional true} ::ct/inst]
[:tokens {:optional true
@@ -342,8 +339,6 @@
:string schema:token]
[:fn d/ordered-map?]]]])
(declare make-token-set)
(def schema:token-set
[:schema {:gen/gen (->> (sg/generator schema:token-set-attrs)
(sg/fmap #(make-token-set %)))}
@@ -404,12 +399,25 @@
(split-set-name name))
(cpn/join-path :separator set-separator :with-spaces? false))))
(defn normalized-set-name?
"Check if a set name is normalized (no extra spaces)."
[name]
(= name (normalize-set-name name)))
(defn replace-last-path-name
"Replaces the last element in a `path` vector with `name`."
[path name]
(-> (into [] (drop-last path))
(conj name)))
(defn make-child-name
"Generate the name of a set child of `parent-set` adding the name `name`."
[parent-set name]
(if-let [parent-path (get-set-path parent-set)]
(->> (concat parent-path (split-set-name name))
(join-set-path))
(normalize-set-name name)))
;; The following functions will be removed after refactoring the internal structure of TokensLib,
;; since we'll no longer need group prefixes to differentiate between sets and set-groups.
@@ -1365,10 +1373,13 @@ Will return a value that matches this schema:
(def ^:private check-tokens-lib-map
(sm/check-fn schema:tokens-lib-map :hint "invalid tokens-lib internal data structure"))
(defn tokens-lib?
[o]
(instance? TokensLib o))
(defn valid-tokens-lib?
[o]
(and (instance? TokensLib o)
(valid? o)))
(and (tokens-lib? o) (valid? o)))
(defn- ensure-hidden-theme
"A helper that is responsible to ensure that the hidden theme always
@@ -1430,6 +1441,50 @@ Will return a value that matches this schema:
(rename copy-name)
(reid (uuid/next))))))
(defn- token-name->path-selector
"Splits token-name into map with `:path` and `:selector` using `token-name->path`.
`:selector` is the last item of the names path
`:path` is everything leading up the the `:selector`."
[token-name]
(let [path-segments (get-token-path {:name token-name})
last-idx (dec (count path-segments))
[path [selector]] (split-at last-idx path-segments)]
{:path (seq path)
:selector selector}))
(defn token-name-path-exists?
"Traverses the path from `token-name` down a `tokens-tree` and checks if a token at that path exists.
It's not allowed to create a token inside a token. E.g.:
Creating a token with
{:name \"foo.bar\"}
in the tokens tree:
{\"foo\" {:name \"other\"}}"
[token-name tokens-tree]
(let [{:keys [path selector]} (token-name->path-selector token-name)
path-target (reduce
(fn [acc cur]
(let [target (get acc cur)]
(cond
;; Path segment doesn't exist yet
(nil? target) (reduced false)
;; A token exists at this path
(:name target) (reduced true)
;; Continue traversing the true
:else target)))
tokens-tree
path)]
(cond
(boolean? path-target) path-target
(get path-target :name) true
:else (-> (get path-target selector)
(seq)
(boolean)))))
;; === Import / Export from JSON format
;; Supported formats:

View File

@@ -6,34 +6,34 @@
(ns common-tests.files.tokens-test
(:require
[app.common.files.tokens :as cft]
[app.common.files.tokens :as cfo]
[clojure.test :as t]))
(t/deftest test-parse-token-value
(t/testing "parses double from a token value"
(t/is (= {:value 100.1 :unit nil} (cft/parse-token-value "100.1")))
(t/is (= {:value -9.0 :unit nil} (cft/parse-token-value "-9"))))
(t/is (= {:value 100.1 :unit nil} (cfo/parse-token-value "100.1")))
(t/is (= {:value -9.0 :unit nil} (cfo/parse-token-value "-9"))))
(t/testing "trims white-space"
(t/is (= {:value -1.3 :unit nil} (cft/parse-token-value " -1.3 "))))
(t/is (= {:value -1.3 :unit nil} (cfo/parse-token-value " -1.3 "))))
(t/testing "parses unit: px"
(t/is (= {:value 70.3 :unit "px"} (cft/parse-token-value " 70.3px "))))
(t/is (= {:value 70.3 :unit "px"} (cfo/parse-token-value " 70.3px "))))
(t/testing "parses unit: %"
(t/is (= {:value -10.0 :unit "%"} (cft/parse-token-value "-10%"))))
(t/is (= {:value -10.0 :unit "%"} (cfo/parse-token-value "-10%"))))
(t/testing "parses unit: px")
(t/testing "returns nil for any invalid characters"
(t/is (nil? (cft/parse-token-value " -1.3a "))))
(t/is (nil? (cfo/parse-token-value " -1.3a "))))
(t/testing "doesnt accept invalid double"
(t/is (nil? (cft/parse-token-value ".3")))))
(t/is (nil? (cfo/parse-token-value ".3")))))
(t/deftest token-applied-test
(t/testing "matches passed token with `:token-attributes`"
(t/is (true? (cft/token-applied? {:name "a"} {:applied-tokens {:x "a"}} #{:x}))))
(t/is (true? (cfo/token-applied? {:name "a"} {:applied-tokens {:x "a"}} #{:x}))))
(t/testing "doesn't match empty token"
(t/is (nil? (cft/token-applied? {} {:applied-tokens {:x "a"}} #{:x}))))
(t/is (nil? (cfo/token-applied? {} {:applied-tokens {:x "a"}} #{:x}))))
(t/testing "does't match passed token `:id`"
(t/is (nil? (cft/token-applied? {:name "b"} {:applied-tokens {:x "a"}} #{:x}))))
(t/is (nil? (cfo/token-applied? {:name "b"} {:applied-tokens {:x "a"}} #{:x}))))
(t/testing "doesn't match passed `:token-attributes`"
(t/is (nil? (cft/token-applied? {:name "a"} {:applied-tokens {:x "a"}} #{:y})))))
(t/is (nil? (cfo/token-applied? {:name "a"} {:applied-tokens {:x "a"}} #{:y})))))
(t/deftest shapes-ids-by-applied-attributes
(t/testing "Returns set of matched attributes that fit the applied token"
@@ -54,7 +54,7 @@
shape-applied-x-y
shape-applied-all
shape-applied-none]
expected (cft/shapes-ids-by-applied-attributes {:name "1"} shapes attributes)]
expected (cfo/shapes-ids-by-applied-attributes {:name "1"} shapes attributes)]
(t/is (= (:x expected) (shape-ids shape-applied-x
shape-applied-x-y
shape-applied-all)))
@@ -62,34 +62,21 @@
shape-applied-x-y
shape-applied-all)))
(t/is (= (:z expected) (shape-ids shape-applied-all)))
(t/is (true? (cft/shapes-applied-all? expected (shape-ids shape-applied-all) attributes)))
(t/is (false? (cft/shapes-applied-all? expected (apply shape-ids shapes) attributes)))
(t/is (true? (cfo/shapes-applied-all? expected (shape-ids shape-applied-all) attributes)))
(t/is (false? (cfo/shapes-applied-all? expected (apply shape-ids shapes) attributes)))
(shape-ids shape-applied-x
shape-applied-x-y
shape-applied-all))))
(t/deftest tokens-applied-test
(t/testing "is true when single shape matches the token and attributes"
(t/is (true? (cft/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "a"}}
(t/is (true? (cfo/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "a"}}
{:applied-tokens {:x "b"}}]
#{:x}))))
(t/testing "is false when no shape matches the token or attributes"
(t/is (nil? (cft/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "b"}}
(t/is (nil? (cfo/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "b"}}
{:applied-tokens {:x "b"}}]
#{:x})))
(t/is (nil? (cft/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "a"}}
(t/is (nil? (cfo/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "a"}}
{:applied-tokens {:x "a"}}]
#{:y})))))
(t/deftest name->path-test
(t/is (= ["foo" "bar" "baz"] (cft/token-name->path "foo.bar.baz")))
(t/is (= ["foo" "bar" "baz"] (cft/token-name->path "foo..bar.baz")))
(t/is (= ["foo" "bar" "baz"] (cft/token-name->path "foo..bar.baz...."))))
(t/deftest token-name-path-exists?-test
(t/is (true? (cft/token-name-path-exists? "border-radius" {"border-radius" {"sm" {:name "sm"}}})))
(t/is (true? (cft/token-name-path-exists? "border-radius" {"border-radius" {:name "sm"}})))
(t/is (true? (cft/token-name-path-exists? "border-radius.sm" {"border-radius" {:name "sm"}})))
(t/is (true? (cft/token-name-path-exists? "border-radius.sm.x" {"border-radius" {:name "sm"}})))
(t/is (false? (cft/token-name-path-exists? "other" {"border-radius" {:name "sm"}})))
(t/is (false? (cft/token-name-path-exists? "dark.border-radius.md" {"dark" {"border-radius" {"sm" {:name "sm"}}}}))))

View File

@@ -255,28 +255,28 @@
(cls/generate-update-shapes [(:id frame1)]
(fn [shape]
(-> shape
(cto/unapply-token-id [:r1 :r2 :r3 :r4])
(cto/unapply-token-id [:rotation])
(cto/unapply-token-id [:opacity])
(cto/unapply-token-id [:stroke-width])
(cto/unapply-token-id [:stroke-color])
(cto/unapply-token-id [:fill])
(cto/unapply-token-id [:width :height])))
(cto/unapply-tokens-from-shape [:r1 :r2 :r3 :r4])
(cto/unapply-tokens-from-shape [:rotation])
(cto/unapply-tokens-from-shape [:opacity])
(cto/unapply-tokens-from-shape [:stroke-width])
(cto/unapply-tokens-from-shape [:stroke-color])
(cto/unapply-tokens-from-shape [:fill])
(cto/unapply-tokens-from-shape [:width :height])))
(:objects page)
{})
(cls/generate-update-shapes [(:id text1)]
(fn [shape]
(-> shape
(cto/unapply-token-id [:font-size])
(cto/unapply-token-id [:letter-spacing])
(cto/unapply-token-id [:font-family])))
(cto/unapply-tokens-from-shape [:font-size])
(cto/unapply-tokens-from-shape [:letter-spacing])
(cto/unapply-tokens-from-shape [:font-family])))
(:objects page)
{})
(cls/generate-update-shapes [(:id circle1)]
(fn [shape]
(-> shape
(cto/unapply-token-id [:layout-item-max-h :layout-item-min-h :layout-item-max-w :layout-item-min-w])
(cto/unapply-token-id [:m1 :m2 :m3 :m4])))
(cto/unapply-tokens-from-shape [:layout-item-max-h :layout-item-min-h :layout-item-max-w :layout-item-min-w])
(cto/unapply-tokens-from-shape [:m1 :m2 :m3 :m4])))
(:objects page)
{}))

View File

@@ -8,20 +8,19 @@
(:require
[app.common.schema :as sm]
[app.common.types.token :as cto]
[app.common.uuid :as uuid]
[clojure.test :as t]))
(t/deftest test-valid-token-name-schema
;; Allow regular namespace token names
(t/is (true? (sm/validate cto/token-name-ref "Foo")))
(t/is (true? (sm/validate cto/token-name-ref "foo")))
(t/is (true? (sm/validate cto/token-name-ref "FOO")))
(t/is (true? (sm/validate cto/token-name-ref "Foo.Bar.Baz")))
(t/is (true? (sm/validate cto/schema:token-name "Foo")))
(t/is (true? (sm/validate cto/schema:token-name "foo")))
(t/is (true? (sm/validate cto/schema:token-name "FOO")))
(t/is (true? (sm/validate cto/schema:token-name "Foo.Bar.Baz")))
;; Disallow trailing tokens
(t/is (false? (sm/validate cto/token-name-ref "Foo.Bar.Baz....")))
(t/is (false? (sm/validate cto/schema:token-name "Foo.Bar.Baz....")))
;; Disallow multiple separator dots
(t/is (false? (sm/validate cto/token-name-ref "Foo..Bar.Baz")))
(t/is (false? (sm/validate cto/schema:token-name "Foo..Bar.Baz")))
;; Disallow any special characters
(t/is (false? (sm/validate cto/token-name-ref "Hey Foo.Bar")))
(t/is (false? (sm/validate cto/token-name-ref "Hey😈Foo.Bar")))
(t/is (false? (sm/validate cto/token-name-ref "Hey%Foo.Bar"))))
(t/is (false? (sm/validate cto/schema:token-name "Hey Foo.Bar")))
(t/is (false? (sm/validate cto/schema:token-name "Hey😈Foo.Bar")))
(t/is (false? (sm/validate cto/schema:token-name "Hey%Foo.Bar"))))

View File

@@ -678,35 +678,35 @@
(t/deftest list-active-themes-tokens-bug-taiga-10617
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :name "Mode / Dark"
(ctob/add-set (ctob/make-token-set :name "Mode/Dark"
:tokens {"red"
(ctob/make-token :name "red"
:type :color
:value "#700000")}))
(ctob/add-set (ctob/make-token-set :name "Mode / Light"
(ctob/add-set (ctob/make-token-set :name "Mode/Light"
:tokens {"red"
(ctob/make-token :name "red"
:type :color
:value "#ff0000")}))
(ctob/add-set (ctob/make-token-set :name "Device / Desktop"
(ctob/add-set (ctob/make-token-set :name "Device/Desktop"
:tokens {"border1"
(ctob/make-token :name "border1"
:type :border-radius
:value 30)}))
(ctob/add-set (ctob/make-token-set :name "Device / Mobile"
(ctob/add-set (ctob/make-token-set :name "Device/Mobile"
:tokens {"border1"
(ctob/make-token :name "border1"
:type :border-radius
:value 50)}))
(ctob/add-theme (ctob/make-token-theme :group "App"
:name "Mobile"
:sets #{"Mode / Dark" "Device / Mobile"}))
:sets #{"Mode/Dark" "Device/Mobile"}))
(ctob/add-theme (ctob/make-token-theme :group "App"
:name "Web"
:sets #{"Mode / Dark" "Mode / Light" "Device / Desktop"}))
:sets #{"Mode/Dark" "Mode/Light" "Device/Desktop"}))
(ctob/add-theme (ctob/make-token-theme :group "Brand"
:name "Brand A"
:sets #{"Mode / Dark" "Mode / Light" "Device / Desktop" "Device / Mobile"}))
:sets #{"Mode/Dark" "Mode/Light" "Device/Desktop" "Device/Mobile"}))
(ctob/add-theme (ctob/make-token-theme :group "Brand"
:name "Brand B"
:sets #{}))
@@ -2013,3 +2013,11 @@
(t/is (some? imported-ref))
(t/is (= (:type original-ref) (:type imported-ref)))
(t/is (= (:value imported-ref) (:value original-ref))))))))
(t/deftest token-name-path-exists?-test
(t/is (true? (ctob/token-name-path-exists? "border-radius" {"border-radius" {"sm" {:name "sm"}}})))
(t/is (true? (ctob/token-name-path-exists? "border-radius" {"border-radius" {:name "sm"}})))
(t/is (true? (ctob/token-name-path-exists? "border-radius.sm" {"border-radius" {:name "sm"}})))
(t/is (true? (ctob/token-name-path-exists? "border-radius.sm.x" {"border-radius" {:name "sm"}})))
(t/is (false? (ctob/token-name-path-exists? "other" {"border-radius" {:name "sm"}})))
(t/is (false? (ctob/token-name-path-exists? "dark.border-radius.md" {"dark" {"border-radius" {"sm" {:name "sm"}}}}))))

View File

@@ -223,19 +223,15 @@ http {
add_header X-Cache-Status $upstream_cache_status;
}
location ~* \.(jpg|png|svg|ttf|woff|woff2)$ {
location ~* \.(js|css|jpg|png|svg|ttf|woff|woff2|wasm)$ {
add_header Cache-Control "public, max-age=604800" always; # 7 days
}
location ~* \.(js|css|wasm)$ {
add_header Cache-Control "no-store" always;
}
location ~ ^/[^/]+/(.*)$ {
return 301 " /404";
}
add_header Cache-Control "no-store" always;
add_header Cache-Control "no-store, no-cache, max-age=0" always;
try_files $uri /index.html$is_args$args /index.html =404;
}
}

View File

@@ -50,9 +50,4 @@ tmux select-window -t penpot:4
tmux send-keys -t penpot 'cd penpot/backend' enter C-l
tmux send-keys -t penpot './scripts/start-dev' enter
tmux new-window -t penpot:5 -n 'nitrate'
tmux select-window -t penpot:5
tmux send-keys -t penpot 'cd penpot/penpot-nitrate' enter C-l
tmux send-keys -t penpot 'pnpm dev --host' enter
tmux -2 attach-session -t penpot

View File

@@ -29,9 +29,8 @@ update_flags /var/www/app/js/config.js
export PENPOT_BACKEND_URI=${PENPOT_BACKEND_URI:-http://penpot-backend:6060}
export PENPOT_EXPORTER_URI=${PENPOT_EXPORTER_URI:-http://penpot-exporter:6061}
export PENPOT_NITRATE_URI=${PENPOT_NITRATE_URI:-http://penpot-nitrate:3000}
export PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE=${PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE:-367001600} # Default to 350MiB
envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_NITRATE_URI,\$PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE" \
envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE" \
< /tmp/nginx.conf.template > /etc/nginx/nginx.conf
PENPOT_DEFAULT_INTERNAL_RESOLVER="$(awk 'BEGIN{ORS=" "} $1=="nameserver" { sub(/%.*$/,"",$2); print ($2 ~ ":")? "["$2"]": $2}' /etc/resolv.conf)"

View File

@@ -139,14 +139,6 @@ http {
proxy_pass $PENPOT_BACKEND_URI/ws/notifications;
}
location /control-center {
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $http_cf_connecting_ip;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass $PENPOT_NITRATE_URI$request_uri;
}
include /etc/nginx/overrides/server.d/*.conf;
location / {

View File

@@ -21,7 +21,6 @@
"raw-body": "^3.0.1",
"source-map-support": "^0.5.21",
"svgo": "penpot/svgo#v3.1",
"undici": "^7.16.0",
"xml-js": "^1.6.11",
"xregexp": "^5.1.2"
},

View File

@@ -7,4 +7,5 @@ bb -i '(babashka.wait/wait-for-port "localhost" 9630)';
bb -i '(babashka.wait/wait-for-path "target/app.js")';
sleep 2;
export NODE_TLS_REJECT_UNAUTHORIZED=0
exec node target/app.js

View File

@@ -107,12 +107,12 @@
:on-progress on-progress)
append (fn [{:keys [filename path] :as resource}]
(rsc/add-to-zip zip path (str/replace filename sanitize-file-regex "_")))
(rsc/add-to-zip! zip path (str/replace filename sanitize-file-regex "_")))
proc (->> exports
(map (fn [export] (rd/render export append)))
(p/all)
(p/mcat (fn [_] (rsc/close-zip zip)))
(p/fnly (fn [_] (.finalize zip)))
(p/fmap (constantly resource))
(p/mcat (partial rsc/upload-resource auth-token))
(p/fmap (fn [resource]

View File

@@ -11,7 +11,6 @@
["node:fs" :as fs]
["node:fs/promises" :as fsp]
["node:path" :as path]
["undici" :as http]
[app.common.exceptions :as ex]
[app.common.transit :as t]
[app.common.uri :as u]
@@ -54,40 +53,30 @@
(.pipe zip out)
zip))
(defn add-to-zip
(defn add-to-zip!
[zip path name]
(.file ^js zip path #js {:name name}))
(defn close-zip
(defn close-zip!
[zip]
(p/create (fn [resolve]
(.on ^js zip "close" resolve)
(.finalize ^js zip))))
(.finalize ^js zip))
(defn upload-resource
[auth-token resource]
(->> (fsp/readFile (:path resource))
(p/fmap (fn [buffer]
(js/console.log buffer)
(new js/Blob #js [buffer] #js {:type (:mtype resource)})))
(p/mcat (fn [blob]
(let [fdata (new http/FormData)
agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}})
headers #js {"X-Shared-Key" cf/management-key
"Authorization" (str "Bearer " auth-token)}
request #js {:headers headers
:method "POST"
:body fdata
:dispatcher agent}
uri (-> (cf/get :public-uri)
(u/ensure-path-slash)
(u/join "api/management/methods/upload-tempfile")
(str))]
(let [fdata (new js/FormData)
uri (-> (cf/get :public-uri)
(u/ensure-path-slash)
(u/join "api/management/methods/upload-tempfile")
(str))]
(.append fdata "content" blob (:filename resource))
(http/fetch uri request))))
(js/fetch uri #js {:headers #js {"X-Shared-Key" cf/management-key
"Authorization" (str "Bearer " auth-token)}
:method "POST"
:body fdata}))))
(p/mcat (fn [response]
(if (not= (.-status response) 200)
(ex/raise :type :internal

View File

@@ -75,8 +75,7 @@
[path]
(->> (.stat fs/promises path)
(p/fmap (fn [data]
{:path path
:created-at (inst-ms (.-ctime ^js data))
{:created-at (inst-ms (.-ctime ^js data))
:size (.-size data)}))
(p/merr (fn [_cause]
(p/resolved nil)))))

View File

@@ -582,7 +582,6 @@ __metadata:
raw-body: "npm:^3.0.1"
source-map-support: "npm:^0.5.21"
svgo: "penpot/svgo#v3.1"
undici: "npm:^7.16.0"
ws: "npm:^8.18.3"
xml-js: "npm:^1.6.11"
xregexp: "npm:^5.1.2"
@@ -1514,13 +1513,6 @@ __metadata:
languageName: node
linkType: hard
"undici@npm:^7.16.0":
version: 7.16.0
resolution: "undici@npm:7.16.0"
checksum: 10c0/efd867792e9f233facf9efa0a087e2d9c3e4415c0b234061b9b40307ca4fa01d945fee4d43c7b564e1b80e0d519bcc682f9f6e0de13c717146c00a80e2f1fb0f
languageName: node
linkType: hard
"unique-filename@npm:^4.0.0":
version: 4.0.0
resolution: "unique-filename@npm:4.0.0"

View File

@@ -50,8 +50,5 @@
:shadow-cljs
{:main-opts ["-m" "shadow.cljs.devtools.cli"]
:jvm-opts ["--sun-misc-unsafe-memory-access=allow"
"-Dpenpot.wasm.profile-marks=true"
"-XX:+UnlockExperimentalVMOptions"
"-XX:CompileCommand=blackhole,criterium.blackhole.Blackhole::consume"]}
:jvm-opts ["--sun-misc-unsafe-memory-access=allow" "-Dpenpot.wasm.profile-marks=true"]}
}}

View File

@@ -32,8 +32,8 @@
"e2e:server": "node ./scripts/e2e-server.js",
"fmt:clj": "cljfmt fix --parallel=true src/ test/",
"fmt:clj:check": "cljfmt check --parallel=false src/ test/",
"fmt:js": "yarn run prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -c text-editor/**/*.js -w",
"fmt:js:check": "yarn run prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js text-editor/**/*.js",
"fmt:js": "yarn run prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -w",
"fmt:js:check": "yarn run prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js",
"lint:clj": "clj-kondo --parallel --lint src/",
"lint:scss": "yarn run prettier -c resources/styles -c src/**/*.scss",
"lint:scss:fix": "yarn run prettier -c resources/styles -c src/**/*.scss -w",
@@ -106,7 +106,7 @@
"@penpot/hljs": "portal:./vendor/hljs",
"@penpot/mousetrap": "portal:./vendor/mousetrap",
"@penpot/plugins-runtime": "1.3.2",
"@penpot/svgo": "penpot/svgo#v3.2",
"@penpot/svgo": "penpot/svgo#v3.1",
"@penpot/text-editor": "portal:./text-editor",
"@tokens-studio/sd-transforms": "1.2.11",
"@zip.js/zip.js": "patch:@zip.js/zip.js@npm%3A2.7.60#~/.yarn/patches/@zip.js-zip.js-npm-2.7.60-b6b814410b.patch",

View File

@@ -1,58 +0,0 @@
{
"~:features": {
"~#set": [
"layout/grid",
"styles/v2",
"fdata/pointer-map",
"fdata/objects-map",
"components/v2",
"fdata/shape-data-type"
]
},
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "New File 1",
"~:revn": 11,
"~:modified-at": "~m1713873823633",
"~:id": "~uc7ce0794-0992-8105-8004-38f280443849",
"~:is-shared": false,
"~:version": 46,
"~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b",
"~:created-at": "~m1713536343369",
"~:data": {
"~:pages": [
"~u66697432-c33d-8055-8006-2c62cc084cad"
],
"~:pages-index": {
"~u66697432-c33d-8055-8006-2c62cc084cad": {
"~#penpot/pointer": [
"~ude58c8f6-c5c2-8196-8004-3df9e2e52d88",
{
"~:created-at": "~m1713873823636"
}
]
}
},
"~:id": "~uc7ce0794-0992-8105-8004-38f280443849",
"~:options": {
"~:components-v2": true
},
"~:recent-colors": [
{
"~:color": "#0000ff",
"~:opacity": 1,
"~:id": null,
"~:file-id": null,
"~:image": null
}
]
}
}

View File

@@ -1,345 +0,0 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"plugins/runtime",
"design-tokens/v1",
"layout/grid",
"styles/v2",
"fdata/pointer-map",
"fdata/objects-map",
"components/v2",
"fdata/shape-data-type",
"text-editor/v2"
]
},
"~:team-id": "~u9e6e22b2-db76-81d6-8006-75d7cdbb8bad",
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "Bug 11552",
"~:revn": 3,
"~:modified-at": "~m1753957736516",
"~:vern": 0,
"~:id": "~u238a17e0-75ff-8075-8006-934586ea2230",
"~:is-shared": false,
"~:migrations": {
"~#ordered-set": [
"legacy-2",
"legacy-3",
"legacy-5",
"legacy-6",
"legacy-7",
"legacy-8",
"legacy-9",
"legacy-10",
"legacy-11",
"legacy-12",
"legacy-13",
"legacy-14",
"legacy-16",
"legacy-17",
"legacy-18",
"legacy-19",
"legacy-25",
"legacy-26",
"legacy-27",
"legacy-28",
"legacy-29",
"legacy-31",
"legacy-32",
"legacy-33",
"legacy-34",
"legacy-36",
"legacy-37",
"legacy-38",
"legacy-39",
"legacy-40",
"legacy-41",
"legacy-42",
"legacy-43",
"legacy-44",
"legacy-45",
"legacy-46",
"legacy-47",
"legacy-48",
"legacy-49",
"legacy-50",
"legacy-51",
"legacy-52",
"legacy-53",
"legacy-54",
"legacy-55",
"legacy-56",
"legacy-57",
"legacy-59",
"legacy-62",
"legacy-65",
"legacy-66",
"legacy-67",
"0001-remove-tokens-from-groups",
"0002-normalize-bool-content-v2",
"0002-clean-shape-interactions",
"0003-fix-root-shape",
"0003-convert-path-content-v2",
"0004-clean-shadow-color",
"0005-deprecate-image-type",
"0006-fix-old-texts-fills",
"0007-clear-invalid-strokes-and-fills-v2",
"0008-fix-library-colors-v4",
"0009-clean-library-colors",
"0009-add-partial-text-touched-flags"
]
},
"~:version": 67,
"~:project-id": "~u9e6e22b2-db76-81d6-8006-75d7cdc30669",
"~:created-at": "~m1753957644225",
"~:data": {
"~:pages": ["~u238a17e0-75ff-8075-8006-934586ea2231"],
"~:pages-index": {
"~u238a17e0-75ff-8075-8006-934586ea2231": {
"~:objects": {
"~u00000000-0000-0000-0000-000000000000": {
"~#shape": {
"~:y": 0,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:name": "Root Frame",
"~:width": 0.01,
"~:type": "~:frame",
"~:points": [
{
"~#point": {
"~:x": 0.0,
"~:y": 0.0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0.0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0.01
}
},
{
"~#point": {
"~:x": 0.0,
"~:y": 0.01
}
}
],
"~:r2": 0,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:r3": 0,
"~:r1": 0,
"~:id": "~u00000000-0000-0000-0000-000000000000",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 0,
"~:proportion": 1.0,
"~:r4": 0,
"~:selrect": {
"~#rect": {
"~:x": 0,
"~:y": 0,
"~:width": 0.01,
"~:height": 0.01,
"~:x1": 0,
"~:y1": 0,
"~:x2": 0.01,
"~:y2": 0.01
}
},
"~:fills": [
{
"~:fill-color": "#FFFFFF",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 0.01,
"~:flip-y": null,
"~:shapes": ["~ucc6f0580-449c-8019-8006-9345db077fa0"]
}
},
"~ucc6f0580-449c-8019-8006-9345db077fa0": {
"~#shape": {
"~:y": 438,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:grow-type": "~:auto-width",
"~:content": {
"~:type": "root",
"~:key": "1s4am1jl24s",
"~:children": [
{
"~:type": "paragraph-set",
"~:children": [
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:children": [
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:font-id": "sourcesanspro",
"~:key": "13p0zwl2yhc",
"~:font-size": "14",
"~:font-weight": "400",
"~:typography-ref-file": null,
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro",
"~:text": "Lorem ipsum"
}
],
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:key": "20hf3kmyoub",
"~:font-size": "14",
"~:font-weight": "400",
"~:typography-ref-file": null,
"~:text-direction": "ltr",
"~:type": "paragraph",
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro"
}
]
}
],
"~:vertical-align": "top"
},
"~:hide-in-viewer": false,
"~:name": "Lorem ipsum",
"~:width": 77,
"~:type": "~:text",
"~:points": [
{
"~#point": {
"~:x": 404,
"~:y": 438
}
},
{
"~#point": {
"~:x": 481,
"~:y": 438
}
},
{
"~#point": {
"~:x": 481,
"~:y": 455
}
},
{
"~#point": {
"~:x": 404,
"~:y": 455
}
}
],
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:id": "~ucc6f0580-449c-8019-8006-9345db077fa0",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:x": 404,
"~:selrect": {
"~#rect": {
"~:x": 404,
"~:y": 438,
"~:width": 77,
"~:height": 17,
"~:x1": 404,
"~:y1": 438,
"~:x2": 481,
"~:y2": 455
}
},
"~:flip-x": null,
"~:height": 17,
"~:flip-y": null
}
}
},
"~:id": "~u238a17e0-75ff-8075-8006-934586ea2231",
"~:name": "Page 1"
}
},
"~:id": "~u238a17e0-75ff-8075-8006-934586ea2230",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
}
}
}

View File

@@ -1 +1,5 @@
w
{
"~:revn": 2,
"~:lagged": []
}

View File

@@ -1,4 +0,0 @@
{
"~:revn": 2,
"~:lagged": []
}

View File

@@ -1,9 +0,0 @@
[
{
"~:id": "~u088df3d4-d383-80f6-8004-527e50ea4f1f",
"~:revn": 21,
"~:file-id": "~uc7ce0794-0992-8105-8004-38f280443849",
"~:session-id": "~u1dc6d4fa-7bd3-803a-8004-527dd9df2c62",
"~:changes": []
}
]

View File

@@ -1,36 +0,0 @@
export class Clipboard {
static Permission = {
ONLY_READ: ["clipboard-read"],
ONLY_WRITE: ["clipboard-write"],
ALL: ["clipboard-read", "clipboard-write"],
};
static enable(context, permissions) {
return context.grantPermissions(permissions);
}
static writeText(page, text) {
return page.evaluate((text) => navigator.clipboard.writeText(text), text);
}
static readText(page) {
return page.evaluate(() => navigator.clipboard.readText());
}
constructor(page, context) {
this.page = page;
this.context = context;
}
enable(permissions) {
return Clipboard.enable(this.context, permissions);
}
writeText(text) {
return Clipboard.writeText(this.page, text);
}
readText() {
return Clipboard.readText(this.page);
}
}

View File

@@ -1,28 +0,0 @@
export class Transit {
static parse(value) {
if (typeof value !== "string") return value;
if (value.startsWith("~")) return value.slice(2);
return value;
}
static get(object, ...path) {
let aux = object;
for (const name of path) {
if (typeof name !== "string") {
if (!(name in aux)) {
return undefined;
}
aux = aux[name];
} else {
const transitName = `~:${name}`;
if (!(transitName in aux)) {
return undefined;
}
aux = aux[transitName];
}
}
return this.parse(aux);
}
}

View File

@@ -1,27 +1,4 @@
export class BasePage {
/**
* Mocks multiple RPC calls in a single call.
*
* @param {Page} page
* @param {object<string, string>} paths
* @param {*} options
* @returns {Promise<void>}
*/
static async mockRPCs(page, paths, options) {
for (const [path, jsonFilename] of Object.entries(paths)) {
await this.mockRPC(page, path, jsonFilename, options);
}
}
/**
* Mocks an RPC call using a file.
*
* @param {Page} page
* @param {string} path
* @param {string} jsonFilename
* @param {*} options
* @returns {Promise<void>}
*/
static async mockRPC(page, path, jsonFilename, options) {
if (!page) {
throw new TypeError("Invalid page argument. Must be a Playwright page.");
@@ -116,10 +93,6 @@ export class BasePage {
return this.#page;
}
async mockRPCs(paths, options) {
return BasePage.mockRPCs(this.page, paths, options);
}
async mockRPC(path, jsonFilename, options) {
return BasePage.mockRPC(this.page, path, jsonFilename, options);
}

View File

@@ -1,146 +1,7 @@
import { expect } from "@playwright/test";
import { readFile } from "node:fs/promises";
import { BaseWebSocketPage } from "./BaseWebSocketPage";
import { Transit } from "../../helpers/Transit";
export class WorkspacePage extends BaseWebSocketPage {
static TextEditor = class TextEditor {
constructor(workspacePage) {
this.workspacePage = workspacePage;
// locators.
this.fontSize = this.workspacePage.rightSidebar.getByRole("textbox", {
name: "Font Size",
});
this.lineHeight = this.workspacePage.rightSidebar.getByRole("textbox", {
name: "Line Height",
});
this.letterSpacing = this.workspacePage.rightSidebar.getByRole(
"textbox",
{
name: "Letter Spacing",
},
);
}
get page() {
return this.workspacePage.page;
}
async waitForStyle(locator, styleName) {
return locator.evaluate(
(element, styleName) => element.style.getPropertyValue(styleName),
styleName,
);
}
async waitForEditor() {
return this.page.waitForSelector('[data-itype="editor"]');
}
async waitForRoot() {
return this.page.waitForSelector('[data-itype="root"]');
}
async waitForParagraph(nth) {
if (!nth) {
return this.page.waitForSelector('[data-itype="paragraph"]');
}
return this.page.waitForSelector(
`[data-itype="paragraph"]:nth-child(${nth})`,
);
}
async waitForParagraphStyle(nth, styleName) {
const paragraph = await this.waitForParagraph(nth);
return this.waitForStyle(paragraph, styleName);
}
async waitForTextSpan(nth = 0) {
if (!nth) {
return this.page.waitForSelector('[data-itype="inline"]');
}
return this.page.waitForSelector(
`[data-itype="inline"]:nth-child(${nth})`,
);
}
async waitForTextSpanContent(nth = 0) {
const textSpan = await this.waitForTextSpan(nth);
const textContent = await textSpan.textContent();
return textContent;
}
async waitForTextSpanStyle(nth, styleName) {
const textSpan = await this.waitForTextSpan(nth);
return this.waitForStyle(textSpan, styleName);
}
async startEditing() {
await this.page.keyboard.press("Enter");
return this.waitForEditor();
}
stopEditing() {
return this.page.keyboard.press("Escape");
}
async moveToLeft(amount = 0) {
for (let i = 0; i < amount; i++) {
await this.page.keyboard.press("ArrowLeft");
}
}
async moveToRight(amount = 0) {
for (let i = 0; i < amount; i++) {
await this.page.keyboard.press("ArrowRight");
}
}
async moveFromStart(offset = 0) {
await this.page.keyboard.press("ArrowLeft");
await this.moveToRight(offset);
}
async moveFromEnd(offset = 0) {
await this.page.keyboard.press("ArrowRight");
await this.moveToLeft(offset);
}
async selectFromStart(length, offset = 0) {
await this.moveFromStart(offset);
await this.page.keyboard.down("Shift");
await this.moveToRight(length);
await this.page.keyboard.up("Shift");
}
async selectFromEnd(length, offset = 0) {
await this.moveFromEnd(offset);
await this.page.keyboard.down("Shift");
await this.moveToLeft(length);
await this.page.keyboard.up("Shift");
}
async changeNumericInput(locator, newValue) {
await expect(locator).toBeVisible();
await locator.focus();
await locator.fill(`${newValue}`);
await locator.blur();
}
changeFontSize(newValue) {
return this.changeNumericInput(this.fontSize, newValue);
}
changeLineHeight(newValue) {
return this.changeNumericInput(this.lineHeight, newValue);
}
changeLetterSpacing(newValue) {
return this.changeNumericInput(this.letterSpacing, newValue);
}
};
/**
* This should be called on `test.beforeEach`.
*
@@ -150,21 +11,50 @@ export class WorkspacePage extends BaseWebSocketPage {
static async init(page) {
await BaseWebSocketPage.initWebSockets(page);
await BaseWebSocketPage.mockRPCs(page, {
"get-profile": "logged-in-user/get-profile-logged-in.json",
"get-team-users?file-id=*":
"logged-in-user/get-team-users-single-user.json",
"get-comment-threads?file-id=*":
"workspace/get-comment-threads-empty.json",
"get-project?id=*": "workspace/get-project-default.json",
"get-team?id=*": "workspace/get-team-default.json",
"get-teams": "get-teams.json",
"get-team-members?team-id=*":
"logged-in-user/get-team-members-your-penpot.json",
"get-profiles-for-file-comments?file-id=*":
"workspace/get-profile-for-file-comments.json",
"update-profile-props": "workspace/update-profile-empty.json",
});
await BaseWebSocketPage.mockRPC(
page,
"get-profile",
"logged-in-user/get-profile-logged-in.json",
);
await BaseWebSocketPage.mockRPC(
page,
"get-team-users?file-id=*",
"logged-in-user/get-team-users-single-user.json",
);
await BaseWebSocketPage.mockRPC(
page,
"get-comment-threads?file-id=*",
"workspace/get-comment-threads-empty.json",
);
await BaseWebSocketPage.mockRPC(
page,
"get-project?id=*",
"workspace/get-project-default.json",
);
await BaseWebSocketPage.mockRPC(
page,
"get-team?id=*",
"workspace/get-team-default.json",
);
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams.json");
await BaseWebSocketPage.mockRPC(
page,
"get-team-members?team-id=*",
"logged-in-user/get-team-members-your-penpot.json",
);
await BaseWebSocketPage.mockRPC(
page,
"get-profiles-for-file-comments?file-id=*",
"workspace/get-profile-for-file-comments.json",
);
await BaseWebSocketPage.mockRPC(
page,
"update-profile-props",
"workspace/update-profile-empty.json",
);
}
static anyTeamId = "c7ce0794-0992-8105-8004-38e630f7920a";
@@ -172,20 +62,9 @@ export class WorkspacePage extends BaseWebSocketPage {
static anyFileId = "c7ce0794-0992-8105-8004-38f280443849";
static anyPageId = "c7ce0794-0992-8105-8004-38f28044384a";
/**
* WebSocket mock
*
* @type {MockWebSocketHelper}
*/
#ws = null;
/**
* Constructor
*
* @param {Page} page
* @param {} [options]
*/
constructor(page, options) {
constructor(page) {
super(page);
this.pageName = page.getByTestId("page-name");
@@ -233,14 +112,11 @@ export class WorkspacePage extends BaseWebSocketPage {
"tokens-context-menu-for-set",
);
this.contextMenuForShape = page.getByTestId("context-menu");
if (options?.textEditor) {
this.textEditor = new WorkspacePage.TextEditor(this);
}
}
async goToWorkspace({
fileId = this.fileId ?? WorkspacePage.anyFileId,
pageId = this.pageId ?? WorkspacePage.anyPageId,
fileId = WorkspacePage.anyFileId,
pageId = WorkspacePage.anyPageId,
} = {}) {
await this.page.goto(
`/#/workspace?team-id=${WorkspacePage.anyTeamId}&file-id=${fileId}&page-id=${pageId}`,
@@ -265,59 +141,48 @@ export class WorkspacePage extends BaseWebSocketPage {
}
async setupEmptyFile() {
await this.mockRPCs({
"get-profile": "logged-in-user/get-profile-logged-in.json",
"get-team-users?file-id=*":
"logged-in-user/get-team-users-single-user.json ",
"get-comment-threads?file-id=*":
"workspace/get-comment-threads-empty.json",
"get-project?id=*": "workspace/get-project-default.json",
"get-team?id=*": "workspace/get-team-default.json",
"get-profiles-for-file-comments?file-id=*":
"workspace/get-profile-for-file-comments.json",
"get-file-object-thumbnails?file-id=*":
"workspace/get-file-object-thumbnails-blank.json",
"get-font-variants?team-id=*": "workspace/get-font-variants-empty.json",
"get-file-fragment?file-id=*": "workspace/get-file-fragment-blank.json",
"get-file-libraries?file-id=*": "workspace/get-file-libraries-empty.json",
});
if (this.textEditor) {
await this.mockRPC("update-file?id=*", "text-editor/update-file.json");
}
// by default we mock the blank file.
await this.mockGetFile("workspace/get-file-blank.json");
await this.mockRPC(
"get-profile",
"logged-in-user/get-profile-logged-in.json",
);
await this.mockRPC(
"get-team-users?file-id=*",
"logged-in-user/get-team-users-single-user.json",
);
await this.mockRPC(
"get-comment-threads?file-id=*",
"workspace/get-comment-threads-empty.json",
);
await this.mockRPC(
"get-project?id=*",
"workspace/get-project-default.json",
);
await this.mockRPC("get-team?id=*", "workspace/get-team-default.json");
await this.mockRPC(
"get-profiles-for-file-comments?file-id=*",
"workspace/get-profile-for-file-comments.json",
);
await this.mockRPC(/get\-file\?/, "workspace/get-file-blank.json");
await this.mockRPC(
"get-file-object-thumbnails?file-id=*",
"workspace/get-file-object-thumbnails-blank.json",
);
await this.mockRPC(
"get-font-variants?team-id=*",
"workspace/get-font-variants-empty.json",
);
await this.mockRPC(
"get-file-fragment?file-id=*",
"workspace/get-file-fragment-blank.json",
);
await this.mockRPC(
"get-file-libraries?file-id=*",
"workspace/get-file-libraries-empty.json",
);
}
async mockGetFile(jsonFilename, options) {
const page = this.page;
const jsonPath = `playwright/data/${jsonFilename}`;
const body = await readFile(jsonPath, "utf-8");
const payload = JSON.parse(body);
const fileId = Transit.get(payload, "id");
const pageId = Transit.get(payload, "data", "pages", 0);
const teamId = Transit.get(payload, "team-id");
this.fileId = fileId ?? this.anyFileId;
this.pageId = pageId ?? this.anyPageId;
this.teamId = teamId ?? this.anyTeamId;
const path = /get\-file\?/;
const url = typeof path === "string" ? `**/api/main/methods/${path}` : path;
const interceptConfig = {
status: 200,
contentType: "application/transit+json",
...options,
};
return page.route(url, (route) =>
route.fulfill({
...interceptConfig,
body,
}),
);
// await this.mockRPC(/get\-file\?/, jsonFile);
async mockGetFile(jsonFile) {
await this.mockRPC(/get\-file\?/, jsonFile);
}
async mockGetAsset(regex, asset) {
@@ -325,15 +190,22 @@ export class WorkspacePage extends BaseWebSocketPage {
}
async setupFileWithComments() {
await this.mockRPCs({
"get-comment-threads?file-id=*":
"workspace/get-comment-threads-unread.json",
"get-file-fragment?file-id=*&fragment-id=*":
"viewer/get-file-fragment-single-board.json",
"get-comments?thread-id=*": "workspace/get-thread-comments.json",
"update-comment-thread-status":
"workspace/update-comment-thread-status.json",
});
await this.mockRPC(
"get-comment-threads?file-id=*",
"workspace/get-comment-threads-unread.json",
);
await this.mockRPC(
"get-file-fragment?file-id=*&fragment-id=*",
"viewer/get-file-fragment-single-board.json",
);
await this.mockRPC(
"get-comments?thread-id=*",
"workspace/get-thread-comments.json",
);
await this.mockRPC(
"update-comment-thread-status",
"workspace/update-comment-thread-status.json",
);
}
async clickWithDragViewportAt(x, y, width, height) {
@@ -351,67 +223,6 @@ export class WorkspacePage extends BaseWebSocketPage {
await this.page.mouse.up();
}
/**
* Clicks and moves from the coordinates x1,y1 to x2,y2
*
* @param {number} x1
* @param {number} y1
* @param {number} x2
* @param {number} y2
*/
async clickAndMove(x1, y1, x2, y2) {
await this.page.waitForTimeout(100);
await this.viewport.hover({ position: { x: x1, y: y1 } });
await this.page.mouse.down();
await this.viewport.hover({ position: { x: x2, y: y2 } });
await this.page.mouse.up();
}
/**
* Creates a new Text Shape in the specified coordinates
* with an initial text.
*
* @param {number} x1
* @param {number} y1
* @param {number} x2
* @param {number} y2
* @param {string} initialText
* @param {*} [options]
*/
async createTextShape(x1, y1, x2, y2, initialText, options) {
const timeToWait = options?.timeToWait ?? 100;
await this.page.keyboard.press("T");
await this.page.waitForTimeout(timeToWait);
await this.clickAndMove(x1, y1, x2, y2);
await this.page.waitForTimeout(timeToWait);
if (initialText) {
await this.page.keyboard.type(initialText);
}
}
/**
* Copies the selected element into the clipboard.
*
* @returns {Promise<void>}
*/
async copy() {
return this.page.keyboard.press("Control+C");
}
/**
* Pastes something from the clipboard.
*
* @param {"keyboard"|"context-menu"} [kind="keyboard"]
* @returns {Promise<void>}
*/
async paste(kind = "keyboard") {
if (kind === "context-menu") {
await this.viewport.click({ button: "right" });
return this.page.getByText("PasteCtrlV").click();
}
return this.page.keyboard.press("Control+V");
}
async panOnViewportAt(x, y, width, height) {
await this.page.waitForTimeout(100);
await this.viewport.hover({ position: { x, y } });
@@ -439,15 +250,10 @@ export class WorkspacePage extends BaseWebSocketPage {
await this.page.waitForTimeout(500);
}
async doubleClickLeafLayer(name, clickOptions = {}) {
await this.clickLeafLayer(name, clickOptions);
await this.clickLeafLayer(name, clickOptions);
}
async clickToggableLayer(name, clickOptions = {}) {
const layer = this.layers
.getByTestId("layer-row")
.filter({ hasText: name });
.getByTestId("layer-row")
.filter({ hasText: name });
const button = layer.getByRole("button");
await button.waitFor();

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -360,7 +360,7 @@ test("Renders a file with texts with paragraphs and breaking lines", async ({
id: "a5f238bd-dd8a-8164-8007-1bc3481eaf05",
pageId: "a5f238bd-dd8a-8164-8007-1bc3481eaf06",
});
await workspace.waitForFirstRenderWithoutUI();
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});

View File

@@ -18,10 +18,6 @@ const setupFile = async (workspacePage) => {
fileId: "7b2da435-6186-815a-8007-0daa95d2f26d",
pageId: "ce79274b-11ab-8088-8007-0487ad43f789",
});
await workspacePage.mockRPC(
"update-file?id=*",
"workspace/update-file-empty.json",
);
};
const shapeToLayerName = {

View File

@@ -51,7 +51,7 @@ test.skip("BUG 12164 - Crash when trying to fetch a missing font", async ({
pageId: "2b7f0188-51a1-8193-8006-e05bad87b74d",
});
await workspacePage.page.waitForTimeout(1000);
await workspacePage.page.waitForTimeout(1000)
await workspacePage.waitForFirstRender();
await expect(

View File

@@ -1,323 +1,12 @@
import { test, expect } from "@playwright/test";
import { Clipboard } from "../../helpers/Clipboard";
import { WorkspacePage } from "../pages/WorkspacePage";
const timeToWait = 100;
test.beforeEach(async ({ page, context }) => {
await Clipboard.enable(context, Clipboard.Permission.ONLY_WRITE);
test.beforeEach(async ({ page }) => {
await WorkspacePage.init(page);
await WorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-v2"]);
});
test.afterEach(async ({ context }) => {
context.clearPermissions();
});
test("Create a new text shape", async ({ page }) => {
const initialText = "Lorem ipsum";
const workspace = new WorkspacePage(page, {
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.goToWorkspace();
await workspace.createTextShape(190, 150, 300, 200, initialText);
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe(initialText);
await workspace.textEditor.stopEditing();
});
test("Create a new text shape from pasting text", async ({ page, context }) => {
const textToPaste = "Lorem ipsum";
const workspace = new WorkspacePage(page, {
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.mockRPC("update-file?id=*", "text-editor/update-file.json");
await workspace.goToWorkspace();
await Clipboard.writeText(page, textToPaste);
await workspace.clickAt(190, 150);
await workspace.paste("keyboard");
await page.waitForTimeout(timeToWait);
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe(textToPaste);
await workspace.textEditor.stopEditing();
});
test("Create a new text shape from pasting text using context menu", async ({
page,
context,
}) => {
const textToPaste = "Lorem ipsum";
const workspace = new WorkspacePage(page, {
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.goToWorkspace();
await Clipboard.writeText(page, textToPaste);
await workspace.clickAt(190, 150);
await workspace.paste("context-menu");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe(textToPaste);
await workspace.textEditor.stopEditing();
});
test("Update an already created text shape by appending text", async ({
page,
}) => {
const workspace = new WorkspacePage(page, {
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
await workspace.goToWorkspace();
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing();
await workspace.textEditor.moveFromEnd(0);
await page.keyboard.type(" dolor sit amet");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe("Lorem ipsum dolor sit amet");
await workspace.textEditor.stopEditing();
});
test("Update an already created text shape by prepending text", async ({
page,
}) => {
const workspace = new WorkspacePage(page, {
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
await workspace.goToWorkspace();
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing();
await workspace.textEditor.moveFromStart(0);
await page.keyboard.type("Dolor sit amet ");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe("Dolor sit amet Lorem ipsum");
await workspace.textEditor.stopEditing();
});
test("Update an already created text shape by inserting text in between", async ({
page,
}) => {
const workspace = new WorkspacePage(page, {
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
await workspace.goToWorkspace();
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing();
await workspace.textEditor.moveFromStart(5);
await page.keyboard.type(" dolor sit amet");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe("Lorem dolor sit amet ipsum");
await workspace.textEditor.stopEditing();
});
test("Update a new text shape appending text by pasting text", async ({
page,
context,
}) => {
const textToPaste = " dolor sit amet";
const workspace = new WorkspacePage(page, {
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
await workspace.goToWorkspace();
await Clipboard.writeText(page, textToPaste);
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing();
await workspace.textEditor.moveFromEnd();
await workspace.paste("keyboard");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe("Lorem ipsum dolor sit amet");
await workspace.textEditor.stopEditing();
});
test("Update a new text shape prepending text by pasting text", async ({
page,
context,
}) => {
const textToPaste = "Dolor sit amet ";
const workspace = new WorkspacePage(page, {
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
await workspace.goToWorkspace();
await Clipboard.writeText(page, textToPaste);
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing();
await workspace.textEditor.moveFromStart();
await workspace.paste("keyboard");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe("Dolor sit amet Lorem ipsum");
await workspace.textEditor.stopEditing();
});
test("Update a new text shape replacing (starting) text with pasted text", async ({
page,
}) => {
const textToPaste = "Dolor sit amet";
const workspace = new WorkspacePage(page, {
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
await workspace.goToWorkspace();
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing();
await workspace.textEditor.selectFromStart(5);
await Clipboard.writeText(page, textToPaste);
await workspace.paste("keyboard");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe("Dolor sit amet ipsum");
await workspace.textEditor.stopEditing();
});
test("Update a new text shape replacing (ending) text with pasted text", async ({
page,
}) => {
const textToPaste = "dolor sit amet";
const workspace = new WorkspacePage(page, {
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
await workspace.goToWorkspace();
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing();
await workspace.textEditor.selectFromEnd(5);
await Clipboard.writeText(page, textToPaste);
await workspace.paste("keyboard");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe("Lorem dolor sit amet");
await workspace.textEditor.stopEditing();
});
test("Update a new text shape replacing (in between) text with pasted text", async ({
page,
}) => {
const textToPaste = "dolor sit amet";
const workspace = new WorkspacePage(page, {
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
await workspace.goToWorkspace();
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing();
await workspace.textEditor.selectFromStart(5, 3);
await Clipboard.writeText(page, textToPaste);
await workspace.paste("keyboard");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe("Lordolor sit ametsum");
await workspace.textEditor.stopEditing();
});
test("Update text font size selecting a part of it (starting)", async ({
page,
}) => {
const workspace = new WorkspacePage(page, {
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
await workspace.mockRPC("update-file?id=*", "text-editor/update-file.json");
await workspace.goToWorkspace();
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing();
await workspace.textEditor.selectFromStart(5);
await workspace.textEditor.changeFontSize(36);
const textContent1 = await workspace.textEditor.waitForTextSpanContent(1);
expect(textContent1).toBe("Lorem");
const textContent2 = await workspace.textEditor.waitForTextSpanContent(2);
expect(textContent2).toBe(" ipsum");
await workspace.textEditor.stopEditing();
});
test.skip("Update text line height selecting a part of it (starting)", async ({
page,
}) => {
const workspace = new WorkspacePage(page, {
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
await workspace.mockRPC("update-file?id=*", "text-editor/update-file.json");
await workspace.goToWorkspace();
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing();
await workspace.textEditor.selectFromStart(5);
await workspace.textEditor.changeLineHeight(1.4);
const lineHeight = await workspace.textEditor.waitForParagraphStyle(
1,
"line-height",
);
expect(lineHeight).toBe("1.4");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe("Lorem ipsum");
await workspace.textEditor.stopEditing();
});
test.skip("Update text letter spacing selecting a part of it (starting)", async ({
page,
}) => {
const workspace = new WorkspacePage(page, {
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
await workspace.mockRPC("update-file?id=*", "text-editor/update-file.json");
await workspace.goToWorkspace();
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing();
await workspace.textEditor.selectFromStart(5);
await workspace.textEditor.changeLetterSpacing(10);
const textContent1 = await workspace.textEditor.waitForTextSpanContent(1);
expect(textContent1).toBe("Lorem");
const textContent2 = await workspace.textEditor.waitForTextSpanContent(2);
expect(textContent2).toBe(" ipsum");
await workspace.textEditor.stopEditing();
});
test("BUG 11552 - Apply styles to the current caret", async ({ page }) => {
test.skip("BUG 11552 - Apply styles to the current caret", async ({ page }) => {
const workspace = new WorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-11552.json");
@@ -325,16 +14,21 @@ test("BUG 11552 - Apply styles to the current caret", async ({ page }) => {
"update-file?id=*",
"text-editor/update-file-11552.json",
);
await workspace.goToWorkspace();
await workspace.doubleClickLeafLayer("Lorem ipsum");
await workspace.goToWorkspace({
fileId: "238a17e0-75ff-8075-8006-934586ea2230",
pageId: "238a17e0-75ff-8075-8006-934586ea2231",
});
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.clickLeafLayer("Lorem ipsum");
const fontSizeInput = workspace.rightSidebar.getByRole("textbox", {
name: "Font Size",
});
await expect(fontSizeInput).toBeVisible();
await page.keyboard.press("Enter");
await page.keyboard.press("ArrowRight");
await workspace.page.keyboard.press("Enter");
await workspace.page.keyboard.press("ArrowRight");
await fontSizeInput.fill("36");

View File

File diff suppressed because it is too large Load Diff

View File

@@ -181,8 +181,8 @@ export async function watch(baseDir, predicate, callback) {
});
}
async function readManifestFile(resource) {
const manifestPath = "resources/public/" + resource;
async function readManifestFile() {
const manifestPath = "resources/public/js/manifest.json";
let content = await fs.readFile(manifestPath, { encoding: "utf8" });
return JSON.parse(content);
}

View File

@@ -20,26 +20,27 @@ echo $PATH
set -ex
corepack enable;
corepack install;
corepack install || exit 1;
yarn install || exit 1;
rm -rf target/dist;
rm -rf resources/public;
rm -rf target/dist;
mkdir -p resources/public;
mkdir -p target/dist;
pushd ../render-wasm;
./build
popd
yarn run build:app:main $EXTRA_PARAMS || exit 1
yarn run build:app:main $EXTRA_PARAMS;
yarn run build:app:libs;
yarn run build:app:assets;
if [ "$INCLUDE_WASM" = "yes" ]; then
yarn run build:wasm || exit 1;
fi
sed -i "s/\.\/render.js/.\/render.js?version=$CURRENT_VERSION/g" resources/public/js/worker/main*.js
yarn run build:app:libs || exit 1;
yarn run build:app:assets || exit 1;
rsync -avr resources/public/ target/dist/
sed -i "s/render-wasm.js/render-wasm.js?version=$CURRENT_VERSION/g" ./resources/public/js/worker/main.js;
rsync -avr resources/public/ target/dist/;
if [ "$INCLUDE_STORYBOOK" = "yes" ]; then
# build storybook

View File

@@ -81,7 +81,7 @@
:source-map-detail-level :all}}}
:worker
{:target :browser
{:target :esm
:output-dir "resources/public/js/worker/"
:asset-path "/js/worker"
:devtools {:browser-inject :main
@@ -92,7 +92,6 @@
{:main
{:entries [app.worker]
:web-worker true
:prepend-js "importScripts('./render.js');"
:depends-on #{}}}
:js-options

View File

@@ -126,7 +126,7 @@
public-uri))
(def worker-uri
(obj/get global "penpotWorkerURI" "/js/worker/main.js"))
(obj/get global "penpotWorkerURI" "/js/worker.js"))
(defn external-feature-flag
[flag value]
@@ -188,11 +188,6 @@
(true? thumbnail?) (u/join (dm/str id "/thumbnail"))
(false? thumbnail?) (u/join (dm/str id)))))))
(defn resolve-href
[resource]
(let [version (get version :full)
href (-> public-uri
(u/ensure-path-slash)
(u/join resource)
(get :path))]
(str href "?version=" version)))
(defn resolve-static-asset
[path]
(u/join public-uri path))

View File

@@ -76,7 +76,7 @@
(map :page-id))
(defn- apply-changes-localy
[{:keys [file-id redo-changes ignore-wasm?] :as commit} pending]
[{:keys [file-id redo-changes] :as commit} pending]
(ptk/reify ::apply-changes-localy
ptk/UpdateEvent
(update [_ state]
@@ -103,7 +103,7 @@
pids (into #{} xf:map-page-id redo-changes)]
(reduce #(ctst/update-object-indices %1 %2) fdata pids)))]
(if (and (not ignore-wasm?) (features/active-feature? state "render-wasm/v1"))
(if (features/active-feature? state "render-wasm/v1")
;; Update the wasm model
(let [shape-changes (volatile! {})
@@ -122,7 +122,7 @@
(defn commit
"Create a commit event instance"
[{:keys [commit-id redo-changes undo-changes origin save-undo? features
file-id file-revn file-vern undo-group tags stack-undo? source ignore-wasm?]}]
file-id file-revn file-vern undo-group tags stack-undo? source]}]
(assert (cpc/check-changes redo-changes)
"expect valid vector of changes for redo-changes")
@@ -147,8 +147,7 @@
:save-undo? save-undo?
:undo-group undo-group
:tags tags
:stack-undo? stack-undo?
:ignore-wasm? ignore-wasm?}]
:stack-undo? stack-undo?}]
(ptk/reify ::commit
cljs.core/IDeref

View File

@@ -649,22 +649,10 @@
(rx/of (dcm/change-team-role params)
(modal/hide)))))
(defn handle-change-team-org
[{:keys [team-id organization-id organization-name] :as message}]
(ptk/reify ::handle-change-team-org
ptk/UpdateEvent
(update [_ state]
(if (contains? (:teams state) team-id)
(-> state
(assoc-in [:teams team-id :organization-id] organization-id)
(assoc-in [:teams team-id :organization-name] organization-name))
state))))
(defn- process-message
[{:keys [type] :as msg}]
(case type
:notification (dcm/handle-notification msg)
:team-role-change (handle-change-team-role msg)
:team-membership-change (dcm/team-membership-change msg)
:team-org-change (handle-change-team-org msg)
nil))

View File

@@ -24,8 +24,6 @@
(def revn-data (atom {}))
(def queue-conj (fnil conj #queue []))
(def force-persist? #(= % ::force-persist))
(defn- update-status
[status]
(ptk/reify ::update-status

View File

@@ -8,7 +8,7 @@
(:require
["@tokens-studio/sd-transforms" :as sd-transforms]
["style-dictionary$default" :as sd]
[app.common.files.tokens :as cft]
[app.common.files.tokens :as cfo]
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.time :as ct]
@@ -83,7 +83,7 @@
[value]
(let [number? (or (number? value)
(numeric-string? value))
parsed-value (cft/parse-token-value value)
parsed-value (cfo/parse-token-value value)
out-of-bounds (or (>= (:value parsed-value) sm/max-safe-int)
(<= (:value parsed-value) sm/min-safe-int))]
@@ -109,7 +109,7 @@
"Parses `value` of a number `sd-token` into a map like `{:value 1 :unit \"px\"}`.
If the `value` is not parseable and/or has missing references returns a map with `:errors`."
[value]
(let [parsed-value (cft/parse-token-value value)
(let [parsed-value (cfo/parse-token-value value)
out-of-bounds (or (>= (:value parsed-value) sm/max-safe-int)
(<= (:value parsed-value) sm/min-safe-int))]
(if (and parsed-value (not out-of-bounds))
@@ -127,7 +127,7 @@
If the `value` is parseable but is out of range returns a map with `warnings`."
[value]
(let [missing-references? (seq (seq (cto/find-token-value-references value)))
parsed-value (cft/parse-token-value value)
parsed-value (cfo/parse-token-value value)
out-of-scope (not (<= 0 (:value parsed-value) 1))
references (seq (cto/find-token-value-references value))]
(cond (and parsed-value (not out-of-scope))
@@ -151,7 +151,7 @@
If the `value` is parseable but is out of range returns a map with `warnings`."
[value]
(let [missing-references? (seq (cto/find-token-value-references value))
parsed-value (cft/parse-token-value value)
parsed-value (cfo/parse-token-value value)
out-of-scope (< (:value parsed-value) 0)
references (seq (cto/find-token-value-references value))]
(cond
@@ -250,7 +250,7 @@
:font-size-value font-size-value})]
(or error
(try
(when-let [{:keys [unit value]} (cft/parse-token-value line-height-value)]
(when-let [{:keys [unit value]} (cfo/parse-token-value line-height-value)]
(case unit
"%" (/ value 100)
"px" (/ value font-size-value)
@@ -261,19 +261,14 @@
(defn- parse-sd-token-font-family-value
[value]
(let [value (-> (js->clj value) (flatten))
valid-font-family (or (string? value) (every? string? value))
missing-references (seq (some cto/find-token-value-references value))]
(let [missing-references (seq (some cto/find-token-value-references value))]
(cond
(not valid-font-family)
{:errors [(wte/error-with-value :error.style-dictionary/invalid-token-value-font-family value)]}
missing-references
{:errors [(wte/error-with-value :error.style-dictionary/missing-reference missing-references)]
:references missing-references}
:else
{:value value})))
{:value (-> (js->clj value) (flatten))})))
(defn parse-atomic-typography-value [token-type token-value]
(case token-type

View File

@@ -32,7 +32,7 @@
[app.main.data.helpers :as dsh]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.data.persistence :as dps]
[app.main.data.persistence :as-alias dps]
[app.main.data.plugins :as dp]
[app.main.data.profile :as du]
[app.main.data.project :as dpj]
@@ -67,7 +67,6 @@
[app.main.errors]
[app.main.features :as features]
[app.main.features.pointer-map :as fpmap]
[app.main.refs :as refs]
[app.main.repo :as rp]
[app.main.router :as rt]
[app.render-wasm :as wasm]
@@ -380,59 +379,6 @@
(->> (rx/from added)
(rx/map process-wasm-object)))))))
(when render-wasm?
(let [local-commits-s
(->> stream
(rx/filter dch/commit?)
(rx/map deref)
(rx/filter #(and (= :local (:source %))
(not (contains? (:tags %) :position-data))))
(rx/filter (complement empty?)))
notifier-s
(rx/merge
(->> local-commits-s (rx/debounce 1000))
(->> stream (rx/filter dps/force-persist?)))
objects-s
(rx/from-atom refs/workspace-page-objects {:emit-current-value? true})
current-page-id-s
(rx/from-atom refs/current-page-id {:emit-current-value? true})]
(->> local-commits-s
(rx/buffer-until notifier-s)
(rx/with-latest-from objects-s)
(rx/map
(fn [[commits objects]]
(->> commits
(mapcat :redo-changes)
(filter #(contains? #{:mod-obj :add-obj} (:type %)))
(filter #(cfh/text-shape? objects (:id %)))
(map #(vector
(:id %)
(wasm.api/calculate-position-data (get objects (:id %))))))))
(rx/with-latest-from current-page-id-s)
(rx/map
(fn [[text-position-data page-id]]
(let [changes
(->> text-position-data
(mapv (fn [[id position-data]]
{:type :mod-obj
:id id
:page-id page-id
:operations
[{:type :set
:attr :position-data
:val position-data
:ignore-touched true
:ignore-geometry true}]})))]
(dch/commit-changes
{:redo-changes changes :undo-changes []
:save-undo? false
:tags #{:position-data}})))))))
(->> stream
(rx/filter dch/commit?)
(rx/map deref)

View File

@@ -102,8 +102,7 @@
{:origin it
:redo-changes changes
:undo-changes []
:save-undo? false
:ignore-wasm? true})))))))
:save-undo? false})))))))
;; FIXME: would be nice to not execute this code twice per page in the
;; same working session, maybe some local memoization can improve that
@@ -120,5 +119,4 @@
{:origin it
:redo-changes changes
:undo-changes []
:save-undo? false
:ignore-wasm? true})))))))
:save-undo? false})))))))

View File

@@ -649,7 +649,7 @@
(propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state))
ids
(into (set (keys modif-tree)) xf:without-uuid-zero (keys transforms))
(into [] xf:without-uuid-zero (keys transforms))
update-shape
(fn [shape]

View File

@@ -831,8 +831,7 @@
(effect [_ state _]
(when (features/active-feature? state "text-editor/v2")
(let [instance (:workspace-editor state)
attrs-to-override (some-> (editor.v2/getCurrentStyle instance)
(styles/get-styles-from-style-declaration))
attrs-to-override (some-> (editor.v2/getCurrentStyle instance) (styles/get-styles-from-style-declaration))
overriden-attrs (merge attrs-to-override attrs)
styles (styles/attrs->styles overriden-attrs)]
(editor.v2/applyStylesToSelection instance styles))))))

View File

@@ -7,7 +7,7 @@
(ns app.main.data.workspace.tokens.application
(:require
[app.common.data :as d]
[app.common.files.tokens :as cft]
[app.common.files.tokens :as cfo]
[app.common.types.component :as ctk]
[app.common.types.shape.layout :as ctsl]
[app.common.types.shape.radius :as ctsr]
@@ -525,8 +525,8 @@
shape-ids (d/nilv (keys shapes) [])
any-variant? (->> shapes vals (some ctk/is-variant?) boolean)
resolved-value (get-in resolved-tokens [(cft/token-identifier token) :resolved-value])
tokenized-attributes (cft/attributes-map attributes token)
resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value])
tokenized-attributes (cfo/attributes-map attributes token)
type (:type token)]
(rx/concat
(rx/of
@@ -585,7 +585,7 @@
ptk/WatchEvent
(watch [_ _ _]
(rx/of
(let [remove-token #(when % (cft/remove-attributes-for-token attributes token %))]
(let [remove-token #(when % (cfo/remove-attributes-for-token attributes token %))]
(dwsh/update-shapes
shape-ids
(fn [shape]
@@ -613,7 +613,7 @@
(get token-properties (:type token))
unapply-tokens?
(cft/shapes-token-applied? token shapes (or attrs all-attributes attributes))
(cfo/shapes-token-applied? token shapes (or attrs all-attributes attributes))
shape-ids (map :id shapes)]

View File

@@ -6,7 +6,7 @@
(ns app.main.data.workspace.tokens.color
(:require
[app.common.files.tokens :as cft]
[app.common.files.tokens :as cfo]
[app.main.data.tinycolor :as tinycolor]))
(defn color-bullet-color [token-color-value]
@@ -17,5 +17,5 @@
(tinycolor/->hex-string tc))))
(defn resolved-token-bullet-color [{:keys [resolved-value] :as token}]
(when (and resolved-value (cft/color-token? token))
(when (and resolved-value (cfo/color-token? token))
(color-bullet-color resolved-value)))

View File

@@ -88,10 +88,6 @@
{:error/code :error.style-dictionary/invalid-token-value-font-weight
:error/fn #(tr "workspace.tokens.invalid-font-weight-token-value" %)}
:error.style-dictionary/invalid-token-value-font-family
{:error/code :error.style-dictionary/invalid-token-value-font-family
:error/fn #(tr "workspace.tokens.invalid-font-family-token-value" %)}
:error.style-dictionary/invalid-token-value-typography
{:error/code :error.style-dictionary/invalid-token-value-typography
:error/fn #(tr "workspace.tokens.invalid-token-value-typography" %)}

View File

@@ -147,27 +147,30 @@
(defn create-token-set
[token-set]
(assert (ctob/token-set? token-set) "a token set is required") ;; TODO should check token-set-schema?
(ptk/reify ::create-token-set
ptk/UpdateEvent
(update [_ state]
;; Clear possible local state
(update state :workspace-tokens dissoc :token-set-new-path))
ptk/WatchEvent
(watch [it state _]
(let [data (dsh/lookup-file-data state)
tokens-lib (get data :tokens-lib)
token-set (ctob/rename token-set (ctob/normalize-set-name (ctob/get-name token-set)))]
(if (and tokens-lib (ctob/get-set-by-name tokens-lib (ctob/get-name token-set)))
(rx/of (ntf/show {:content (tr "errors.token-set-already-exists")
:type :toast
:level :error
:timeout 9000}))
(let [changes (-> (pcb/empty-changes it)
(pcb/with-library-data data)
(pcb/set-token-set (ctob/get-id token-set) token-set))]
(rx/of (set-selected-token-set-id (ctob/get-id token-set))
(dch/commit-changes changes))))))))
(let [data (dsh/lookup-file-data state)
changes (-> (pcb/empty-changes it)
(pcb/with-library-data data)
(pcb/set-token-set (ctob/get-id token-set) token-set))]
(rx/of (set-selected-token-set-id (ctob/get-id token-set))
(dch/commit-changes changes))))))
(defn rename-token-set
[token-set new-name]
(assert (ctob/token-set? token-set) "a token set is required") ;; TODO should check token-set-schema after renaming?
(assert (string? new-name) "a new name is required") ;; TODO should assert normalized-set-name?
(ptk/reify ::update-token-set
ptk/WatchEvent
(watch [it state _]
(let [data (dsh/lookup-file-data state)
changes (-> (pcb/empty-changes it)
(pcb/with-library-data data)
(pcb/rename-token-set (ctob/get-id token-set) new-name))]
(rx/of (set-selected-token-set-id (ctob/get-id token-set))
(dch/commit-changes changes))))))
(defn rename-token-set-group
[set-group-path set-group-fname]
@@ -179,26 +182,6 @@
(rx/of
(dch/commit-changes changes))))))
(defn update-token-set
[token-set name]
(ptk/reify ::update-token-set
ptk/WatchEvent
(watch [it state _]
(let [data (dsh/lookup-file-data state)
name (ctob/normalize-set-name name (ctob/get-name token-set))
tokens-lib (get data :tokens-lib)]
(if (ctob/get-set-by-name tokens-lib name)
(rx/of (ntf/show {:content (tr "errors.token-set-already-exists")
:type :toast
:level :error
:timeout 9000}))
(let [changes (-> (pcb/empty-changes it)
(pcb/with-library-data data)
(pcb/rename-token-set (ctob/get-id token-set) name))]
(rx/of (set-selected-token-set-id (ctob/get-id token-set))
(dch/commit-changes changes))))))))
(defn duplicate-token-set
[id]
(ptk/reify ::duplicate-token-set
@@ -448,7 +431,7 @@
(ctob/get-id token-set)
token-id)]
(let [tokens (vals (ctob/get-tokens tokens-lib (ctob/get-id token-set)))
unames (map :name tokens)
unames (map :name tokens) ;; TODO: add function duplicate-token in tokens-lib
suffix (tr "workspace.tokens.duplicate-suffix")
copy-name (cfh/generate-unique-name (:name token) unames :suffix suffix)
new-token (-> token

View File

@@ -26,7 +26,7 @@
(log/set-level! :warn)
(def google-fonts
(preload-gfonts "fonts/gfonts.2025.11.28.json"))
(preload-gfonts "fonts/gfonts.2025.05.19.json"))
(def local-fonts
[{:id "sourcesanspro"
@@ -342,8 +342,8 @@
(fn [result {:keys [font-id] :as node}]
(let [current-font
(if (some? font-id)
(select-keys node [:font-id :font-variant-id :font-weight :font-style])
(select-keys txt/default-typography [:font-id :font-variant-id :font-weight :font-style]))]
(select-keys node [:font-id :font-variant-id])
(select-keys txt/default-typography [:font-id :font-variant-id]))]
(conj result current-font)))
#{})))

View File

@@ -30,9 +30,6 @@
(def profile
(l/derived (l/key :profile) st/state))
(def current-page-id
(l/derived (l/key :current-page-id) st/state))
(def team
(l/derived (fn [state]
(let [team-id (:current-team-id state)
@@ -375,9 +372,6 @@
(def workspace-modifiers
(l/derived :workspace-modifiers st/state))
(def workspace-wasm-modifiers
(l/derived :workspace-wasm-modifiers st/state))
(def ^:private workspace-modifiers-with-objects
(l/derived
(fn [state]

View File

@@ -60,7 +60,6 @@
current-id (get state :id)
current-value (get state :current-value)
current-label (get label-index current-value)
is-open? (get state :is-open?)
node-ref (mf/use-ref nil)

View File

@@ -30,7 +30,6 @@
(def current-zoom (mf/create-context nil))
(def workspace-read-only? (mf/create-context nil))
(def is-render? (mf/create-context false))
(def is-component? (mf/create-context false))
(def sidebar

View File

@@ -280,8 +280,8 @@
(mf/defc teams-selector-dropdown*
{::mf/private true}
[{:keys [team profile teams show-default-team allow-create-teams allow-create-org] :rest props}]
(let [on-create-team-click
[{:keys [team profile teams] :rest props}]
(let [on-create-click
(mf/use-fn #(st/emit! (modal/show :team-form {})))
on-team-click
@@ -290,25 +290,18 @@
(let [team-id (-> (dom/get-current-target event)
(dom/get-data "value")
(uuid/parse))]
(st/emit! (dcm/go-to-dashboard-recent :team-id team-id)))))
on-create-org-click
(mf/use-fn
(fn []
;; TODO update when org creation route is ready
(dom/open-new-window "localhost:3000/org/create")))]
(st/emit! (dcm/go-to-dashboard-recent :team-id team-id)))))]
[:> dropdown-menu* props
(when show-default-team
[:> dropdown-menu-item* {:on-click on-team-click
:data-value (:default-team-id profile)
:class (stl/css :team-dropdown-item)}
[:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon]
[:> dropdown-menu-item* {:on-click on-team-click
:data-value (:default-team-id profile)
:class (stl/css :team-dropdown-item)}
[:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon]
[:span {:class (stl/css :team-text)} (tr "dashboard.your-penpot")]
(when (= (:default-team-id profile) (:id team))
tick-icon)])
[:span {:class (stl/css :team-text)} (tr "dashboard.your-penpot")]
(when (= (:default-team-id profile) (:id team))
tick-icon)]
(for [team-item (remove :is-default (vals teams))]
[:> dropdown-menu-item* {:on-click on-team-click
@@ -329,19 +322,11 @@
(when (= (:id team-item) (:id team))
tick-icon)])
(when allow-create-teams
[:hr {:role "separator" :class (stl/css :team-separator)}]
[:> dropdown-menu-item* {:on-click on-create-team-click
:class (stl/css :team-dropdown-item :action)}
[:span {:class (stl/css :icon-wrapper)} add-icon]
[:span {:class (stl/css :team-text)} (tr "dashboard.create-new-team")]])
(when allow-create-org
[:hr {:role "separator" :class (stl/css :team-separator)}]
[:> dropdown-menu-item* {:on-click on-create-org-click
:class (stl/css :team-dropdown-item :action)}
[:span {:class (stl/css :icon-wrapper)} add-icon]
[:span {:class (stl/css :team-text)} (tr "dashboard.create-new-org")]])]))
[:hr {:role "separator" :class (stl/css :team-separator)}]
[:> dropdown-menu-item* {:on-click on-create-click
:class (stl/css :team-dropdown-item :action)}
[:span {:class (stl/css :icon-wrapper)} add-icon]
[:span {:class (stl/css :team-text)} (tr "dashboard.create-new-team")]]]))
(mf/defc team-options-dropdown*
{::mf/private true}
@@ -491,80 +476,9 @@
:data-testid "delete-team"}
(tr "dashboard.delete-team")])]))
(mf/defc sidebar-org-switch*
[{:keys [team profile]}]
(let [teams (->> (mf/deref refs/teams)
vals
(group-by :organization-id)
(map (fn [[_group entries]] (first entries)))
vec
(d/index-by :id))
teams (update-vals teams
(fn [t]
(assoc t :name (str "ORG: " (:organization-name t)))))
team (assoc team :name (str "ORG: " (:organization-name team)))
show-teams-menu*
(mf/use-state false)
show-teams-menu?
(deref show-teams-menu*)
on-show-teams-click
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(swap! show-teams-menu* not)))
on-show-teams-keydown
(mf/use-fn
(fn [event]
(when (or (kbd/space? event)
(kbd/enter? event))
(dom/prevent-default event)
(dom/stop-propagation event)
(some-> (dom/get-current-target event)
(dom/click!)))))
close-teams-menu
(mf/use-fn #(reset! show-teams-menu* false))]
[:div {:class (stl/css :sidebar-team-switch)}
[:div {:class (stl/css :switch-content)}
[:button {:class (stl/css :current-team)
:on-click on-show-teams-click
:on-key-down on-show-teams-keydown}
[:div {:class (stl/css :team-name)}
[:img {:src (cf/resolve-team-photo-url team)
:class (stl/css :team-picture)
:alt (:name team)}]
[:span {:class (stl/css :team-text) :title (:name team)} (:name team)]]
arrow-icon]]
;; Teams Dropdown
[:> teams-selector-dropdown* {:show show-teams-menu?
:on-close close-teams-menu
:id "organizations-list"
:class (stl/css :dropdown :teams-dropdown)
:team team
:profile profile
:teams teams
:show-default-team false
:allow-create-teams false
:allow-create-org true}]]))
(mf/defc sidebar-team-switch*
[{:keys [team profile]}]
(let [nitrate? (contains? cf/flags :nitrate)
org-id (when nitrate? (:organization-id team))
teams (cond->> (mf/deref refs/teams)
nitrate?
(filter #(= (-> % val :organization-id) org-id)))
(let [teams (mf/deref refs/teams)
subscription
(get team :subscription)
@@ -672,10 +586,7 @@
:class (stl/css :dropdown :teams-dropdown)
:team team
:profile profile
:teams teams
:show-default-team true
:allow-create-teams true
:allow-create-org false}]
:teams teams}]
[:> team-options-dropdown* {:show show-team-options-menu?
:on-close close-team-options-menu
@@ -792,8 +703,6 @@
[:*
[:div {:class (stl/css-case :sidebar-content true)
:ref container}
(when (contains? cf/flags :nitrate)
[:> sidebar-org-switch* {:team team :profile profile}])
[:> sidebar-team-switch* {:team team :profile profile}]
[:> sidebar-search* {:search-term search-term

View File

@@ -45,7 +45,7 @@
.element-list {
@include t.use-typography("body-large");
color: var(--modal-text-foreground-color);
overflow-y: auto;
overflow-y: scroll;
margin-block: 0;
}

View File

@@ -1,49 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.ds.layers.layer-button
(:require-macros
[app.main.style :as stl])
(:require
[app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]]
[rumext.v2 :as mf]))
(def ^:private schema:layer-button
[:map
[:label :string]
[:description {:optional true} [:maybe :string]]
[:class {:optional true} :string]
[:expandable {:optional true} :boolean]
[:expanded {:optional true} :boolean]
[:icon {:optional true} :string]
[:on-toggle-expand fn?]])
(mf/defc layer-button*
{::mf/schema schema:layer-button}
[{:keys [label description class is-expandable expanded icon on-toggle-expand children] :rest props}]
(let [button-props (mf/spread-props props
{:class [class (stl/css-case :layer-button true
:layer-button--expandable is-expandable
:layer-button--expanded expanded)]
:type "button"
:on-click on-toggle-expand})]
[:div {:class (stl/css :layer-button-wrapper)}
[:> "button" button-props
[:div {:class (stl/css :layer-button-content)}
(when is-expandable
(if expanded
[:> icon* {:icon-id i/arrow-down :class (stl/css :folder-node-icon)}]
[:> icon* {:icon-id i/arrow-right :class (stl/css :folder-node-icon)}]))
(when icon
[:> icon* {:icon-id icon :class (stl/css :layer-button-icon)}])
[:span {:class (stl/css :layer-button-name)}
label]
(when description
[:span {:class (stl/css :layer-button-description)}
description])
[:span {:class (stl/css :layer-button-quantity)}]]]
[:div {:class (stl/css :layer-button-actions)}
children]]))

View File

@@ -1,56 +0,0 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/typography.scss" as *;
@use "ds/colors.scss" as *;
.layer-button-wrapper {
--layer-button-block-size: #{$sz-32};
--layer-button-background: var(--color-background-primary);
--layer-button-text: var(--color-foreground-secondary);
display: flex;
justify-content: space-between;
block-size: var(--layer-button-block-size);
background: var(--layer-button-background);
color: var(--layer-button-text);
}
.layer-button {
@include use-typography("body-small");
appearance: none;
flex: 1;
display: flex;
align-items: center;
border: none;
background: none;
color: inherit;
}
.layer-button--expanded {
& .layer-button-name {
color: var(--color-foreground-primary);
}
}
.layer-button-content {
display: flex;
align-items: center;
gap: var(--sp-xs);
}
.layer-button-description {
padding: var(--sp-xs);
background-color: var(--color-background-tertiary);
border-radius: $br-6;
}

View File

@@ -223,30 +223,24 @@
circ (* 2 Math/PI 12)
pct (- circ (* circ (/ progress total)))
pwidth
(if error?
280
(/ (* progress 280) total))
pwidth (if error?
280
(/ (* progress 280) total))
color (cond
error? clr/new-danger
healthy? (if is-default-theme?
clr/new-primary
clr/new-primary-light)
(not healthy?) clr/new-warning)
color
(cond
error? clr/new-danger
healthy? (if is-default-theme?
clr/new-primary
clr/new-primary-light)
(not healthy?) clr/new-warning)
background-clr
(if is-default-theme?
clr/background-quaternary
clr/background-quaternary-light)
title
(cond
error? (tr "workspace.options.exporting-object-error")
complete? (tr "workspace.options.exporting-complete")
healthy? (tr "workspace.options.exporting-object")
(not healthy?) (tr "workspace.options.exporting-object-slow"))
background-clr (if is-default-theme?
clr/background-quaternary
clr/background-quaternary-light)
title (cond
error? (tr "workspace.options.exporting-object-error")
complete? (tr "workspace.options.exporting-complete")
healthy? (tr "workspace.options.exporting-object")
(not healthy?) (tr "workspace.options.exporting-object-slow"))
retry-last-export
(mf/use-fn #(st/emit! (de/retry-last-export)))
@@ -290,7 +284,7 @@
:on-click retry-last-export}
(tr "workspace.options.retry")]
[:span {:class (stl/css :progress)}
[:p {:class (stl/css :progress)}
(dm/str progress " / " total)])]
[:button {:class (stl/css :progress-close-button)

View File

@@ -7,7 +7,6 @@
(ns app.main.ui.flex-controls.gap
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt]
[app.common.geom.shapes :as gsh]
@@ -17,8 +16,6 @@
[app.common.types.shape.layout :as ctl]
[app.main.data.helpers :as dsh]
[app.main.data.workspace.modifiers :as dwm]
[app.main.data.workspace.transforms :as dwt]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.css-cursors :as cur]
@@ -30,11 +27,10 @@
(mf/defc gap-display
[{:keys [frame-id zoom gap-type gap on-pointer-enter on-pointer-leave
rect-data hover? selected? mouse-pos hover-value
on-move-selected on-context-menu on-change]}]
on-move-selected on-context-menu]}]
(let [resizing (mf/use-var nil)
start (mf/use-var nil)
original-value (mf/use-var 0)
last-pos (mf/use-var nil)
negate? (:resize-negate? rect-data)
axis (:resize-axis rect-data)
@@ -47,55 +43,32 @@
(reset! start (dom/get-client-position event))
(reset! original-value (:initial-value rect-data))))
calc-modifiers
(mf/use-fn
(mf/deps frame-id gap-type gap)
(fn [pos]
(let [delta
(-> (gpt/to-vec @start pos)
(cond-> negate? gpt/negate)
(get axis))
val
(int (max (+ @original-value (/ delta zoom)) 0))
layout-gap (assoc gap gap-type val)]
[val
(dwm/create-modif-tree
[frame-id]
(ctm/change-property (ctm/empty) :layout-gap layout-gap))])))
on-lost-pointer-capture
(mf/use-fn
(mf/deps calc-modifiers)
(mf/deps frame-id gap-type gap)
(fn [event]
(dom/release-pointer event)
(when (and (features/active-feature? @st/state "render-wasm/v1") (= @resizing gap-type))
(let [[_ modifiers] (calc-modifiers @last-pos)]
(st/emit! (dwm/apply-wasm-modifiers modifiers)
(dwt/finish-transform))))
(reset! resizing nil)
(reset! start nil)
(reset! original-value 0)
(when (not (features/active-feature? @st/state "render-wasm/v1"))
(st/emit! (dwm/apply-modifiers)))))
(st/emit! (dwm/apply-modifiers))))
on-pointer-move
(mf/use-fn
(mf/deps calc-modifiers on-change)
(mf/deps frame-id gap-type gap)
(fn [event]
(let [pos (dom/get-client-position event)]
(reset! last-pos pos)
(reset! mouse-pos (point->viewport pos))
(when (= @resizing gap-type)
(let [[val modifiers] (calc-modifiers pos)]
(let [delta (-> (gpt/to-vec @start pos)
(cond-> negate? gpt/negate)
(get axis))
val (int (max (+ @original-value (/ delta zoom)) 0))
layout-gap (assoc gap gap-type val)
modifiers (dwm/create-modif-tree [frame-id] (ctm/change-property (ctm/empty) :layout-gap layout-gap))]
(reset! hover-value val)
(if (features/active-feature? @st/state "render-wasm/v1")
(st/emit! (dwm/set-wasm-modifiers modifiers))
(st/emit! (dwm/set-modifiers modifiers)))
(when on-change
(on-change modifiers)))))))]
(st/emit! (dwm/set-modifiers modifiers)))))))]
[:g.gap-rect
[:rect.info-area
@@ -147,17 +120,10 @@
pill-width (/ fcc/flex-display-pill-width zoom)
pill-height (/ fcc/flex-display-pill-height zoom)
workspace-modifiers (mf/deref refs/workspace-modifiers)
workspace-wasm-modifiers (mf/deref refs/workspace-wasm-modifiers)
gap-selected (mf/deref refs/workspace-gap-selected)
hover (mf/use-state nil)
hover-value (mf/use-state 0)
mouse-pos (mf/use-state nil)
current-modifiers (mf/use-state nil)
frame
(ctm/apply-structure-modifiers frame (dm/get-in @current-modifiers [frame-id :modifiers]))
padding (:layout-padding frame)
gap (:layout-gap frame)
{:keys [width height x1 y1]} (:selrect frame)
@@ -166,12 +132,6 @@
(reset! hover-value val))
on-pointer-leave #(reset! hover nil)
on-change
(mf/use-fn
(fn [modifiers]
(reset! current-modifiers modifiers)))
negate {:column-gap (if flip-x true false)
:row-gap (if flip-y true false)}
@@ -183,16 +143,8 @@
(= :column-reverse saved-dir))
(drop-last children)
(rest children))
children-to-display
(if (features/active-feature? @st/state "render-wasm/v1")
(let [modifiers (into {} workspace-wasm-modifiers)]
(->> children-to-display
;;(map #(gsh/transform-shape % (get-in workspace-modifiers [(:id %) :modifiers])))
(map (fn [shape]
(gsh/apply-transform shape (get modifiers (:id shape)))))))
(->> children-to-display
(map #(gsh/transform-shape % (get-in workspace-modifiers [(:id %) :modifiers])))))
children-to-display (->> children-to-display
(map #(gsh/transform-shape % (get-in workspace-modifiers [(:id %) :modifiers]))))
wrap-blocks
(let [block-children (->> children
@@ -320,22 +272,20 @@
[:g.gaps {:pointer-events "visible"}
(for [[index display-item] (d/enumerate (concat display-blocks display-children))]
(let [gap-type (:gap-type display-item)]
[:& gap-display
{:key (str frame-id index)
:frame-id frame-id
:zoom zoom
:gap-type gap-type
:gap gap
:on-pointer-enter (partial on-pointer-enter gap-type (get gap gap-type))
:on-pointer-leave on-pointer-leave
:on-move-selected on-move-selected
:on-context-menu on-context-menu
:on-change on-change
:rect-data display-item
:hover? (= @hover gap-type)
:selected? (= gap-selected gap-type)
:mouse-pos mouse-pos
:hover-value hover-value}]))
[:& gap-display {:key (str frame-id index)
:frame-id frame-id
:zoom zoom
:gap-type gap-type
:gap gap
:on-pointer-enter (partial on-pointer-enter gap-type (get gap gap-type))
:on-pointer-leave on-pointer-leave
:on-move-selected on-move-selected
:on-context-menu on-context-menu
:rect-data display-item
:hover? (= @hover gap-type)
:selected? (= gap-selected gap-type)
:mouse-pos mouse-pos
:hover-value hover-value}]))
(when @hover
[:& fcc/flex-display-pill

View File

@@ -6,12 +6,9 @@
(ns app.main.ui.flex-controls.margin
(:require
[app.common.data.macros :as dm]
[app.common.geom.point :as gpt]
[app.common.types.modifiers :as ctm]
[app.main.data.workspace.modifiers :as dwm]
[app.main.data.workspace.transforms :as dwt]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.css-cursors :as cur]
@@ -20,14 +17,11 @@
[app.util.dom :as dom]
[rumext.v2 :as mf]))
(mf/defc margin-display
[{:keys [shape-id zoom hover-all? hover-v? hover-h? margin-num margin
on-pointer-enter on-pointer-leave on-change
rect-data hover? selected? mouse-pos hover-value]}]
(mf/defc margin-display [{:keys [shape-id zoom hover-all? hover-v? hover-h? margin-num margin on-pointer-enter on-pointer-leave
rect-data hover? selected? mouse-pos hover-value]}]
(let [resizing? (mf/use-var false)
start (mf/use-var nil)
original-value (mf/use-var 0)
last-pos (mf/use-var nil)
negate? (true? (:resize-negate? rect-data))
axis (:resize-axis rect-data)
@@ -40,69 +34,39 @@
(reset! start (dom/get-client-position event))
(reset! original-value (:initial-value rect-data))))
calc-modifiers
(mf/use-fn
(mf/deps shape-id margin-num margin hover-all? hover-v? hover-h?)
(fn [pos]
(let [delta
(-> (gpt/to-vec @start pos)
(cond-> negate? gpt/negate)
(get axis))
val
(int (max (+ @original-value (/ delta zoom)) 0))
layout-item-margin
(cond
hover-all? (assoc margin :m1 val :m2 val :m3 val :m4 val)
hover-v? (assoc margin :m1 val :m3 val)
hover-h? (assoc margin :m2 val :m4 val)
:else (assoc margin margin-num val))
layout-item-margin-type
(if (= (:m1 margin) (:m2 margin) (:m3 margin) (:m4 margin)) :simple :multiple)]
[val
(dwm/create-modif-tree
[shape-id]
(-> (ctm/empty)
(ctm/change-property :layout-item-margin layout-item-margin)
(ctm/change-property :layout-item-margin-type layout-item-margin-type)))])))
on-lost-pointer-capture
(mf/use-fn
(mf/deps calc-modifiers)
(mf/deps shape-id margin-num margin)
(fn [event]
(dom/release-pointer event)
(when (features/active-feature? @st/state "render-wasm/v1")
(let [[_ modifiers] (calc-modifiers @last-pos)]
(st/emit! (dwm/apply-wasm-modifiers modifiers)
(dwt/finish-transform))))
(reset! resizing? false)
(reset! start nil)
(reset! original-value 0)
(when (not (features/active-feature? @st/state "render-wasm/v1"))
(st/emit! (dwm/apply-modifiers)))))
(st/emit! (dwm/apply-modifiers))))
on-pointer-move
(mf/use-fn
(mf/deps calc-modifiers on-change)
(mf/deps shape-id margin-num margin hover-all? hover-v? hover-h?)
(fn [event]
(let [pos (dom/get-client-position event)]
(reset! mouse-pos (point->viewport pos))
(reset! last-pos pos)
(when @resizing?
(let [[val modifiers] (calc-modifiers pos)]
(let [delta (-> (gpt/to-vec @start pos)
(cond-> negate? gpt/negate)
(get axis))
val (int (max (+ @original-value (/ delta zoom)) 0))
layout-item-margin (cond
hover-all? (assoc margin :m1 val :m2 val :m3 val :m4 val)
hover-v? (assoc margin :m1 val :m3 val)
hover-h? (assoc margin :m2 val :m4 val)
:else (assoc margin margin-num val))
layout-item-margin-type (if (= (:m1 margin) (:m2 margin) (:m3 margin) (:m4 margin)) :simple :multiple)
modifiers (dwm/create-modif-tree [shape-id]
(-> (ctm/empty)
(ctm/change-property :layout-item-margin layout-item-margin)
(ctm/change-property :layout-item-margin-type layout-item-margin-type)))]
(reset! hover-value val)
(if (features/active-feature? @st/state "render-wasm/v1")
(st/emit! (dwm/set-wasm-modifiers modifiers))
(st/emit! (dwm/set-modifiers modifiers)))
(when on-change
(on-change modifiers)))))))]
(st/emit! (dwm/set-modifiers modifiers)))))))]
[:rect.margin-rect
{:x (:x rect-data)
@@ -125,11 +89,6 @@
pill-width (/ fcc/flex-display-pill-width zoom)
pill-height (/ fcc/flex-display-pill-height zoom)
margins-selected (mf/deref refs/workspace-margins-selected)
current-modifiers (mf/use-state nil)
shape
(ctm/apply-structure-modifiers shape (dm/get-in @current-modifiers [shape-id :modifiers]))
hover-value (mf/use-state 0)
mouse-pos (mf/use-state nil)
hover (mf/use-state nil)
@@ -138,67 +97,50 @@
hover-h? (and (or (= @hover :m2) (= @hover :m4)) shift?)
margin (:layout-item-margin shape)
{:keys [width height x1 x2 y1 y2]} (:selrect shape)
on-pointer-enter
(mf/use-fn
(fn [hover-type val]
(reset! hover hover-type)
(reset! hover-value val)))
on-pointer-leave
(mf/use-fn
(fn []
(reset! hover nil)))
on-change
(mf/use-fn
(fn [modifiers]
(reset! current-modifiers modifiers)))
hover?
(fn [value]
(or hover-all?
(and (or (= value :m1) (= value :m3)) hover-v?)
(and (or (= value :m2) (= value :m4)) hover-h?)
(= @hover value)))
margin-display-data
{:m1 {:key (str shape-id "-m1")
:x x1
:y (if (:flip-y frame) y2 (- y1 (:m1 margin)))
:width width
:height (:m1 margin)
:initial-value (:m1 margin)
:resize-type :top
:resize-axis :y
:resize-negate? (:flip-y frame)}
:m2 {:key (str shape-id "-m2")
:x (if (:flip-x frame) (- x1 (:m2 margin)) x2)
:y y1
:width (:m2 margin)
:height height
:initial-value (:m2 margin)
:resize-type :left
:resize-axis :x
:resize-negate? (:flip-x frame)}
:m3 {:key (str shape-id "-m3")
:x x1
:y (if (:flip-y frame) (- y1 (:m3 margin)) y2)
:width width
:height (:m3 margin)
:initial-value (:m3 margin)
:resize-type :top
:resize-axis :y
:resize-negate? (:flip-y frame)}
:m4 {:key (str shape-id "-m4")
:x (if (:flip-x frame) x2 (- x1 (:m4 margin)))
:y y1
:width (:m4 margin)
:height height
:initial-value (:m4 margin)
:resize-type :left
:resize-axis :x
:resize-negate? (:flip-x frame)}}]
on-pointer-enter (fn [hover-type val]
(reset! hover hover-type)
(reset! hover-value val))
on-pointer-leave #(reset! hover nil)
hover? #(or hover-all?
(and (or (= % :m1) (= % :m3)) hover-v?)
(and (or (= % :m2) (= % :m4)) hover-h?)
(= @hover %))
margin-display-data {:m1 {:key (str shape-id "-m1")
:x x1
:y (if (:flip-y frame) y2 (- y1 (:m1 margin)))
:width width
:height (:m1 margin)
:initial-value (:m1 margin)
:resize-type :top
:resize-axis :y
:resize-negate? (:flip-y frame)}
:m2 {:key (str shape-id "-m2")
:x (if (:flip-x frame) (- x1 (:m2 margin)) x2)
:y y1
:width (:m2 margin)
:height height
:initial-value (:m2 margin)
:resize-type :left
:resize-axis :x
:resize-negate? (:flip-x frame)}
:m3 {:key (str shape-id "-m3")
:x x1
:y (if (:flip-y frame) (- y1 (:m3 margin)) y2)
:width width
:height (:m3 margin)
:initial-value (:m3 margin)
:resize-type :top
:resize-axis :y
:resize-negate? (:flip-y frame)}
:m4 {:key (str shape-id "-m4")
:x (if (:flip-x frame) x2 (- x1 (:m4 margin)))
:y y1
:width (:m4 margin)
:height height
:initial-value (:m4 margin)
:resize-type :left
:resize-axis :x
:resize-negate? (:flip-x frame)}}]
[:g.margins {:pointer-events "visible"}
(for [[margin-num rect-data] margin-display-data]
@@ -213,7 +155,6 @@
:margin margin
:on-pointer-enter (partial on-pointer-enter margin-num (get margin margin-num))
:on-pointer-leave on-pointer-leave
:on-change on-change
:rect-data rect-data
:hover? (hover? margin-num)
:selected? (get margins-selected margin-num)

View File

@@ -6,12 +6,9 @@
(ns app.main.ui.flex-controls.padding
(:require
[app.common.data.macros :as dm]
[app.common.geom.point :as gpt]
[app.common.types.modifiers :as ctm]
[app.main.data.workspace.modifiers :as dwm]
[app.main.data.workspace.transforms :as dwt]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.css-cursors :as cur]
@@ -21,13 +18,11 @@
[rumext.v2 :as mf]))
(mf/defc padding-display
[{:keys [frame-id zoom hover-all? hover-v? hover-h? padding-num padding on-pointer-enter
on-pointer-leave rect-data hover? selected? mouse-pos hover-value on-move-selected
on-context-menu on-change]}]
[{:keys [frame-id zoom hover-all? hover-v? hover-h? padding-num padding on-pointer-enter on-pointer-leave
rect-data hover? selected? mouse-pos hover-value on-move-selected on-context-menu]}]
(let [resizing? (mf/use-var false)
start (mf/use-var nil)
original-value (mf/use-var 0)
last-pos (mf/use-var nil)
negate? (true? (:resize-negate? rect-data))
axis (:resize-axis rect-data)
@@ -40,69 +35,41 @@
(reset! start (dom/get-client-position event))
(reset! original-value (:initial-value rect-data))))
calc-modifiers
(mf/use-fn
(mf/deps frame-id padding-num padding hover-all? hover-v? hover-h?)
(fn [pos]
(let [delta
(-> (gpt/to-vec @start pos)
(cond-> negate? gpt/negate)
(get axis))
val
(int (max (+ @original-value (/ delta zoom)) 0))
layout-padding
(cond
hover-all? (assoc padding :p1 val :p2 val :p3 val :p4 val)
hover-v? (assoc padding :p1 val :p3 val)
hover-h? (assoc padding :p2 val :p4 val)
:else (assoc padding padding-num val))
layout-padding-type
(if (= (:p1 padding) (:p2 padding) (:p3 padding) (:p4 padding)) :simple :multiple)]
[val
(dwm/create-modif-tree
[frame-id]
(-> (ctm/empty)
(ctm/change-property :layout-padding layout-padding)
(ctm/change-property :layout-padding-type layout-padding-type)))])))
on-lost-pointer-capture
(mf/use-fn
(mf/deps calc-modifiers)
(mf/deps frame-id padding-num padding)
(fn [event]
(dom/release-pointer event)
(when (features/active-feature? @st/state "render-wasm/v1")
(let [[_ modifiers] (calc-modifiers @last-pos)]
(st/emit! (dwm/apply-wasm-modifiers modifiers)
(dwt/finish-transform))))
(reset! resizing? false)
(reset! start nil)
(reset! original-value 0)
(when (not (features/active-feature? @st/state "render-wasm/v1"))
(st/emit! (dwm/apply-modifiers)))))
(st/emit! (dwm/apply-modifiers))))
on-pointer-move
(mf/use-fn
(mf/deps calc-modifiers on-change)
(mf/deps frame-id padding-num padding hover-all? hover-v? hover-h?)
(fn [event]
(let [pos (dom/get-client-position event)]
(reset! mouse-pos (point->viewport pos))
(reset! last-pos pos)
(when @resizing?
(let [[val modifiers] (calc-modifiers pos)]
(reset! hover-value val)
(if (features/active-feature? @st/state "render-wasm/v1")
(st/emit! (dwm/set-wasm-modifiers modifiers))
(st/emit! (dwm/set-modifiers modifiers)))
(let [delta (-> (gpt/to-vec @start pos)
(cond-> negate? gpt/negate)
(get axis))
val (int (max (+ @original-value (/ delta zoom)) 0))
layout-padding (cond
hover-all? (assoc padding :p1 val :p2 val :p3 val :p4 val)
hover-v? (assoc padding :p1 val :p3 val)
hover-h? (assoc padding :p2 val :p4 val)
:else (assoc padding padding-num val))
(when on-change
(on-change modifiers)))))))]
layout-padding-type (if (= (:p1 padding) (:p2 padding) (:p3 padding) (:p4 padding)) :simple :multiple)
modifiers (dwm/create-modif-tree [frame-id]
(-> (ctm/empty)
(ctm/change-property :layout-padding layout-padding)
(ctm/change-property :layout-padding-type layout-padding-type)))]
(reset! hover-value val)
(st/emit! (dwm/set-modifiers modifiers)))))))]
[:g.padding-rect
[:rect.info-area
@@ -138,108 +105,77 @@
:on-lost-pointer-capture on-lost-pointer-capture
:on-pointer-move on-pointer-move
:on-context-menu on-context-menu
:class
(when (or hover? selected?)
(if (= (:resize-axis rect-data) :x)
(cur/get-dynamic "resize-ew" 0)
(cur/get-dynamic "resize-ew" 90)))
:style
{:fill (if (or hover? selected?) fcc/distance-color "none")
:opacity (if selected? 0 1)}}])]))
:class (when (or hover? selected?)
(if (= (:resize-axis rect-data) :x) (cur/get-dynamic "resize-ew" 0) (cur/get-dynamic "resize-ew" 90)))
:style {:fill (if (or hover? selected?) fcc/distance-color "none")
:opacity (if selected? 0 1)}}])]))
(mf/defc padding-rects
[{:keys [frame zoom alt? shift? on-move-selected on-context-menu]}]
(let [frame-id (:id frame)
paddings-selected (mf/deref refs/workspace-paddings-selected)
current-modifiers (mf/use-state nil)
frame
(ctm/apply-structure-modifiers frame (dm/get-in @current-modifiers [frame-id :modifiers]))
hover-value (mf/use-state 0)
mouse-pos (mf/use-state nil)
hover (mf/use-state nil)
hover-all? (and (not (nil? @hover)) alt?)
hover-v? (and (or (= @hover :p1) (= @hover :p3)) shift?)
hover-h? (and (or (= @hover :p2) (= @hover :p4)) shift?)
padding (:layout-padding frame)
{:keys [width height x1 x2 y1 y2]} (:selrect frame)
on-pointer-enter (fn [hover-type val]
(reset! hover hover-type)
(reset! hover-value val))
on-pointer-leave #(reset! hover nil)
pill-width (/ fcc/flex-display-pill-width zoom)
pill-height (/ fcc/flex-display-pill-height zoom)
hover? #(or hover-all?
(and (or (= % :p1) (= % :p3)) hover-v?)
(and (or (= % :p2) (= % :p4)) hover-h?)
(= @hover %))
negate {:p1 (if (:flip-y frame) true false)
:p2 (if (:flip-x frame) true false)
:p3 (if (:flip-y frame) true false)
:p4 (if (:flip-x frame) true false)}
negate (cond-> negate
(not= :auto (:layout-item-h-sizing frame)) (assoc :p2 (not (:p2 negate)))
(not= :auto (:layout-item-v-sizing frame)) (assoc :p3 (not (:p3 negate))))
negate
{:p1 (if (:flip-y frame) true false)
:p2 (if (:flip-x frame) true false)
:p3 (if (:flip-y frame) true false)
:p4 (if (:flip-x frame) true false)}
negate
(cond-> negate
(not= :auto (:layout-item-h-sizing frame)) (assoc :p2 (not (:p2 negate)))
(not= :auto (:layout-item-v-sizing frame)) (assoc :p3 (not (:p3 negate))))
padding-rect-data
{:p1 {:key (str frame-id "-p1")
:x x1
:y (if (:flip-y frame) (- y2 (:p1 padding)) y1)
:width width
:height (:p1 padding)
:initial-value (:p1 padding)
:resize-type (if (:flip-y frame) :bottom :top)
:resize-axis :y
:resize-negate? (:p1 negate)}
:p2 {:key (str frame-id "-p2")
:x (if (:flip-x frame) x1 (- x2 (:p2 padding)))
:y y1
:width (:p2 padding)
:height height
:initial-value (:p2 padding)
:resize-type :left
:resize-axis :x
:resize-negate? (:p2 negate)}
:p3 {:key (str frame-id "-p3")
:x x1
:y (if (:flip-y frame) y1 (- y2 (:p3 padding)))
:width width
:height (:p3 padding)
:initial-value (:p3 padding)
:resize-type :bottom
:resize-axis :y
:resize-negate? (:p3 negate)}
:p4 {:key (str frame-id "-p4")
:x (if (:flip-x frame) (- x2 (:p4 padding)) x1)
:y y1
:width (:p4 padding)
:height height
:initial-value (:p4 padding)
:resize-type (if (:flip-x frame) :right :left)
:resize-axis :x
:resize-negate? (:p4 negate)}}
on-pointer-enter
(mf/use-fn
(fn [hover-type val]
(reset! hover hover-type)
(reset! hover-value val)))
on-pointer-leave
(mf/use-fn
(fn []
(reset! hover nil)))
on-change
(mf/use-fn
(fn [modifiers]
(reset! current-modifiers modifiers)))
hover?
(fn [value]
(or hover-all?
(and (or (= value :p1) (= value :p3)) hover-v?)
(and (or (= value :p2) (= value :p4)) hover-h?)
(= @hover value)))]
padding-rect-data {:p1 {:key (str frame-id "-p1")
:x x1
:y (if (:flip-y frame) (- y2 (:p1 padding)) y1)
:width width
:height (:p1 padding)
:initial-value (:p1 padding)
:resize-type (if (:flip-y frame) :bottom :top)
:resize-axis :y
:resize-negate? (:p1 negate)}
:p2 {:key (str frame-id "-p2")
:x (if (:flip-x frame) x1 (- x2 (:p2 padding)))
:y y1
:width (:p2 padding)
:height height
:initial-value (:p2 padding)
:resize-type :left
:resize-axis :x
:resize-negate? (:p2 negate)}
:p3 {:key (str frame-id "-p3")
:x x1
:y (if (:flip-y frame) y1 (- y2 (:p3 padding)))
:width width
:height (:p3 padding)
:initial-value (:p3 padding)
:resize-type :bottom
:resize-axis :y
:resize-negate? (:p3 negate)}
:p4 {:key (str frame-id "-p4")
:x (if (:flip-x frame) (- x2 (:p4 padding)) x1)
:y y1
:width (:p4 padding)
:height height
:initial-value (:p4 padding)
:resize-type (if (:flip-x frame) :right :left)
:resize-axis :x
:resize-negate? (:p4 negate)}}]
[:g.paddings {:pointer-events "visible"}
(for [[padding-num rect-data] padding-rect-data]
@@ -258,11 +194,9 @@
:on-pointer-leave on-pointer-leave
:on-move-selected on-move-selected
:on-context-menu on-context-menu
:on-change on-change
:hover? (hover? padding-num)
:selected? (get paddings-selected padding-num)
:rect-data rect-data}])
(when @hover
[:& fcc/flex-display-pill
{:height pill-height

View File

@@ -77,7 +77,7 @@
[:button {:class (stl/css :cta-button :bottom-link)
:on-click cta-link-trial} cta-text-trial])])
(defn- make-management-form-schema [min-editors]
(defn schema:seats-form [min-editors]
[:map {:title "SeatsForm"}
[:min-members [::sm/number {:min min-editors
:max 9999}]]
@@ -87,6 +87,7 @@
{::mf/register modal/components
::mf/register-as :management-dialog}
[{:keys [subscription-type current-subscription editors subscribe-to-trial]}]
(let [unlimited-modal-step*
(mf/use-state 1)
@@ -111,12 +112,9 @@
{:min-members min-editors
:redirect-to-payment-details false})
schema
(mf/with-memo [min-editors]
(make-management-form-schema min-editors))
form
(fm/use-form :schema schema :initial initial)
(fm/use-form :schema (schema:seats-form min-editors)
:initial initial)
submit-in-progress
(mf/use-ref false)
@@ -336,15 +334,11 @@
[:> raw-svg* {:id (if (= "light" (:theme profile)) "logo-subscription-light" "logo-subscription")}]]
[:div {:class (stl/css :modal-end)}
[:div {:class (stl/css :modal-title)}
(tr "subscription.settings.sucess.dialog.title" subscription-name)]
[:div {:class (stl/css :modal-title)} (tr "subscription.settings.sucess.dialog.title" subscription-name)]
(when (not= subscription-name "professional")
[:p {:class (stl/css :modal-text-large)}
(tr "subscription.settings.success.dialog.thanks" subscription-name)])
[:p {:class (stl/css :modal-text-large)}
(tr "subscription.settings.success.dialog.description")]
[:p {:class (stl/css :modal-text-large)}
(tr "subscription.settings.sucess.dialog.footer")]
[:p {:class (stl/css :modal-text-large)} (tr "subscription.settings.success.dialog.thanks" subscription-name)])
[:p {:class (stl/css :modal-text-large)} (tr "subscription.settings.success.dialog.description")]
[:p {:class (stl/css :modal-text-large)} (tr "subscription.settings.sucess.dialog.footer")]
[:div {:class (stl/css :success-action-buttons)}
[:input
@@ -424,11 +418,7 @@
(mf/with-effect []
(dom/set-html-title (tr "subscription.labels")))
(mf/with-effect [authenticated?
show-subscription-success-modal?
show-trial-subscription-modal?
success-modal-is-trial?
subscription]
(mf/with-effect [authenticated? show-subscription-success-modal? show-trial-subscription-modal? success-modal-is-trial? subscription]
(when ^boolean authenticated?
(cond
^boolean show-trial-subscription-modal?

View File

@@ -28,7 +28,6 @@
{::mf/wrap-props false}
[props]
(let [{:keys [position-data content] :as shape} (obj/get props "shape")
is-render? (mf/use-ctx ctx/is-render?)
is-component? (mf/use-ctx ctx/is-component?)]
(mf/with-memo [content]
@@ -42,5 +41,5 @@
;; Only use this for component preview, otherwise the dashboard thumbnails
;; will give a tainted canvas error because the `foreignObject` cannot be
;; rendered.
(and (nil? position-data) (or is-component? is-render?))
(and (nil? position-data) is-component?)
[:> fo/text-shape props])))

View File

@@ -12,20 +12,18 @@
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
[app.render-wasm.api :as wasm.api]
[rumext.v2 :as mf]))
(mf/defc text-edition-outline
[{:keys [shape zoom modifiers]}]
(if (features/active-feature? @st/state "render-wasm/v1")
(let [{:keys [width height]} (wasm.api/get-text-dimensions (:id shape))
selrect-transform (mf/deref refs/workspace-selrect)
[selrect transform] (dsh/get-selrect selrect-transform shape)]
(let [selrect-transform (mf/deref refs/workspace-selrect)
[{:keys [x y width height]} transform] (dsh/get-selrect selrect-transform shape)]
[:rect.main.viewport-selrect
{:x (:x selrect)
:y (:y selrect)
:width (max width (:width selrect))
:height (max height (:height selrect))
{:x x
:y y
:width width
:height height
:transform transform
:style {:stroke "var(--color-accent-tertiary)"
:stroke-width (/ 1 zoom)

View File

@@ -320,12 +320,10 @@
[{:keys [x y width height]} transform]
(if render-wasm?
(let [{:keys [width height]} (wasm.api/get-text-dimensions shape-id)
(let [{:keys [height]} (wasm.api/get-text-dimensions shape-id)
selrect-transform (mf/deref refs/workspace-selrect)
[selrect transform] (dsh/get-selrect selrect-transform shape)
selrect-height (:height selrect)
selrect-width (:width selrect)
max-width (max width selrect-width)
max-height (max height selrect-height)
valign (-> shape :content :vertical-align)
y (:y selrect)
@@ -333,9 +331,9 @@
(case valign
"bottom" (- y (- height selrect-height))
"center" (- y (/ (- height selrect-height) 2))
y)
"top" y)
y)]
[(assoc selrect :y y :width max-width :height max-height) transform])
[(assoc selrect :y y :width (:width selrect) :height max-height) transform])
(let [bounds (gst/shape->rect shape)
x (mth/min (dm/get-prop bounds :x)
@@ -354,7 +352,7 @@
(obj/merge!
#js {"--editor-container-width" (dm/str width "px")
"--editor-container-height" (dm/str height "px")
"--fallback-families" (if (seq fallback-families) (dm/str (str/join ", " fallback-families)) "sourcesanspro")})
"--fallback-families" (dm/str (str/join ", " fallback-families))})
(not render-wasm?)
(obj/merge!

View File

@@ -5,7 +5,6 @@
// Copyright (c) KALEIDOS INC
@use "refactor/common-refactor.scss" as deprecated;
@use "ds/_utils.scss" as *;
.layer-row {
--layer-indentation-size: calc(#{deprecated.$s-4} * 6);
@@ -88,7 +87,7 @@
height: deprecated.$s-32;
width: calc(100% - (var(--depth) * var(--layer-indentation-size)));
cursor: pointer;
min-width: px2rem(140);
&.filtered {
width: calc(100% - deprecated.$s-12);
}

View File

@@ -211,7 +211,9 @@
overflow-x: auto;
overflow-y: overlay;
scrollbar-gutter: stable;
}
.element-list {
display: grid;
.element-list {
width: var(--left-sidebar-width);
display: grid;
}
}

View File

@@ -102,7 +102,7 @@
[:> deprecated-input/numeric-input*
{:placeholder (cond
(not all-equal?)
(tr "settings.multiple")
"Mixed"
(= :multiple (:r1 values))
(tr "settings.multiple")
:else

View File

@@ -16,7 +16,6 @@
[app.main.ui.components.numeric-input :as deprecated-input]
[app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]]
[app.main.ui.components.title-bar :refer [title-bar*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.icons :as deprecated-icon]
[app.main.ui.workspace.sidebar.options.menus.layout-container :refer [get-layout-flex-icon]]
[app.util.dom :as dom]
@@ -235,20 +234,20 @@
[:& radio-button
{:value "fix"
:icon i/fixed-width
:icon deprecated-icon/fixed-width
:title "Fix width"
:id "behaviour-h-fix"}]
(when has-fill
[:& radio-button
{:value "fill"
:icon i/fill-content
:icon deprecated-icon/fill-content
:title "Width 100%"
:id "behaviour-h-fill"}])
(when is-auto
[:& radio-button
{:value "auto"
:icon i/hug-content
:icon deprecated-icon/hug-content
:title "Fit content (Horizontal)"
:id "behaviour-h-auto"}])]])
@@ -269,7 +268,7 @@
[:& radio-button
{:value "fix"
:icon i/fixed-width
:icon deprecated-icon/fixed-width
:icon-class (stl/css :rotated)
:title "Fix height"
:id "behaviour-v-fix"}]
@@ -277,14 +276,14 @@
(when has-fill
[:& radio-button
{:value "fill"
:icon i/fill-content
:icon deprecated-icon/fill-content
:icon-class (stl/css :rotated)
:title "Height 100%"
:id "behaviour-v-fill"}])
(when is-auto
[:& radio-button
{:value "auto"
:icon i/hug-content
:icon deprecated-icon/hug-content
:icon-class (stl/css :rotated)
:title "Fit content (Vertical)"
:id "behaviour-v-auto"}])]])

View File

@@ -265,13 +265,11 @@
(mf/deps font on-change)
(fn [new-variant-id]
(let [variant (d/seek #(= new-variant-id (:id %)) (:variants font))]
(when-not (nil? variant)
(on-change {:font-id (:id font)
:font-family (:family font)
:font-variant-id new-variant-id
:font-weight (:weight variant)
:font-style (:style variant)}))
(on-change {:font-id (:id font)
:font-family (:family font)
:font-variant-id new-variant-id
:font-weight (:weight variant)
:font-style (:style variant)})
(dom/blur! (dom/get-target new-variant-id)))))
on-font-select
@@ -344,13 +342,12 @@
{:value (:id variant)
:key (pr-str variant)
:label (:name variant)})))
variant-options (if (= font-variant-id :multiple)
variant-options (if (= font-size :multiple)
(conj basic-variant-options
{:value ""
{:value :multiple
:key :multiple-variants
:label "--"})
basic-variant-options)]
;; TODO Add disabled mode
[:& select
{:class (stl/css :font-variant-select)
@@ -382,7 +379,6 @@
:step 0.1
:default-value "1.2"
:class (stl/css :line-height-input)
:aria-label (tr "inspect.attributes.typography.line-height")
:value (attr->string line-height)
:placeholder (if (= :multiple line-height) (tr "settings.multiple") "--")
:nillable (= :multiple line-height)
@@ -401,7 +397,6 @@
:step 0.1
:default-value "0"
:class (stl/css :letter-spacing-input)
:aria-label (tr "inspect.attributes.typography.letter-spacing")
:value (attr->string letter-spacing)
:placeholder (if (= :multiple letter-spacing) (tr "settings.multiple") "--")
:on-change #(handle-change % :letter-spacing)

View File

@@ -68,7 +68,7 @@
(mf/defc color-token-row*
{::mf/private true}
[{:keys [active-tokens applied-token-name color on-swatch-click-token detach-token open-modal-from-token]}]
[{:keys [active-tokens color-token color on-swatch-click-token detach-token open-modal-from-token]}]
(let [;; `active-tokens` may be provided as a `delay` (lazy computation).
;; In that case we must deref it (`@active-tokens`) to force evaluation
;; and obtain the actual value. If its already realized (not a delay),
@@ -77,22 +77,21 @@
@active-tokens
active-tokens)
active-color-tokens (:color active-tokens)
color-tokens (:color active-tokens)
token (some #(when (= (:name %) applied-token-name) %) active-color-tokens)
token (some #(when (= (:name %) color-token) %) color-tokens)
on-detach-token
(mf/use-fn
(mf/deps detach-token token applied-token-name)
(mf/deps detach-token token color-token)
(fn []
(let [token (or token applied-token-name)]
(let [token (or token color-token)]
(detach-token token))))
has-errors (some? (:errors token))
token-name (:name token)
resolved (:resolved-value token)
not-active (and (empty? active-tokens)
(nil? token))
not-active (and (some? active-tokens) (nil? token))
id (dm/str (:id token) "-name")
swatch-tooltip-content (cond
not-active
@@ -110,7 +109,7 @@
#(mf/html
[:div
[:span (dm/str (tr "workspace.tokens.token-name") ": ")]
[:span {:class (stl/css :token-name-tooltip)} applied-token-name]]))]
[:span {:class (stl/css :token-name-tooltip)} color-token]]))]
[:div {:class (stl/css :color-info)}
[:div {:class (stl/css-case :token-color-wrapper true
@@ -129,7 +128,7 @@
:class (stl/css :token-tooltip)}
[:div {:class (stl/css :token-name)
:aria-labelledby id}
(or token-name applied-token-name)]]
(or token-name color-token)]]
[:div {:class (stl/css :token-actions)}
[:> icon-button*
{:variant "action"
@@ -147,11 +146,7 @@
on-change on-reorder on-detach on-open on-close on-remove origin on-detach-token
disable-drag on-focus on-blur select-only select-on-focus on-token-change applied-token]}]
(let [;; TODO: Remove this workaround fixing `get-attrs*` fn on sidebar/options/shapes/multiple.cljs
applied-token (if (= :multiple applied-token)
nil
applied-token)
token-color (contains? cfg/flags :token-color)
(let [token-color (contains? cfg/flags :token-color)
libraries (mf/deref refs/files)
color-without-hash (mf/use-memo
@@ -182,6 +177,7 @@
(-> (deref active-tokens*)
(select-keys (get tk/tokens-by-input origin))
(not-empty)))))
on-focus'
(mf/use-fn
(mf/deps on-focus)
@@ -356,7 +352,7 @@
(cond
(and token-color applied-token)
[:> color-token-row* {:active-tokens tokens
:applied-token-name applied-token
:color-token applied-token
:color (dissoc color :ref-id :ref-file)
:on-swatch-click-token on-swatch-click-token
:detach-token detach-token

View File

@@ -63,8 +63,7 @@
:data {:index index})
[nil nil])
stroke-color-token
(:stroke-color applied-tokens)
stroke-color-token (:stroke-color applied-tokens)
on-color-change-refactor
(mf/use-fn

View File

@@ -268,8 +268,8 @@
:on-click modal/hide!}
(tr "labels.cancel")]
[:> import-type-dropdown*
{:options [{:label (tr "workspace.tokens.import-menu-json-option") :value :file}
{:label (tr "workspace.tokens.import-menu-zip-option") :value :zip}
{:options [{:label (tr "workspace.tokens.import-menu-zip-option") :value :zip}
{:label (tr "workspace.tokens.import-menu-json-option") :value :file}
{:label (tr "workspace.tokens.import-menu-folder-option") :value :folder}]
:on-click handle-import-action
:text-render render-button-text

View File

@@ -44,39 +44,6 @@
[(seq (array/sort! empty))
(seq (array/sort! filled))]))))
(mf/defc selected-set-info*
{::mf/private true}
[{:keys [tokens-lib selected-token-set-id]}]
(let [selected-token-set
(mf/with-memo [tokens-lib]
(when selected-token-set-id
(some-> tokens-lib (ctob/get-set selected-token-set-id))))
active-token-sets-names
(mf/with-memo [tokens-lib]
(some-> tokens-lib (ctob/get-active-themes-set-names)))
token-set-active?
(mf/use-fn
(mf/deps active-token-sets-names)
(fn [name]
(contains? active-token-sets-names name)))]
[:div {:class (stl/css :sets-header-container)}
[:> text* {:as "span"
:typography "headline-small"
:class (stl/css :sets-header)}
(tr "workspace.tokens.tokens-section-title" (ctob/get-name selected-token-set))]
[:div {:class (stl/css :sets-header-status) :title (tr "workspace.tokens.inactive-set-description")}
;; NOTE: when no set in tokens-lib, the selected-token-set-id
;; will be `nil`, so for properly hide the inactive message we
;; check that at least `selected-token-set-id` has a value
(when (and (some? selected-token-set-id)
(not (token-set-active? (ctob/get-name selected-token-set))))
[:*
[:> icon* {:class (stl/css :sets-header-status-icon) :icon-id i/eye-off}]
[:> text* {:as "span" :typography "body-small" :class (stl/css :sets-header-status-text)}
(tr "workspace.tokens.inactive-set")]])]]))
(mf/defc tokens-section*
{::mf/private true}
[{:keys [tokens-lib active-tokens resolved-active-tokens]}]
@@ -98,7 +65,9 @@
selected-token-set-id
(mf/deref refs/selected-token-set-id)
selected-token-set
(when selected-token-set-id
(some-> tokens-lib (ctob/get-set selected-token-set-id)))
;; If we have not selected any set explicitly we just
;; select the first one from the list of sets
@@ -123,9 +92,15 @@
tokens)]
(ctob/group-by-type tokens)))
active-token-sets-names
(mf/with-memo [tokens-lib]
(some-> tokens-lib (ctob/get-active-themes-set-names)))
token-set-active?
(mf/use-fn
(mf/deps active-token-sets-names)
(fn [name]
(contains? active-token-sets-names name)))
[empty-group filled-group]
(mf/with-memo [tokens-by-type]
@@ -143,27 +118,34 @@
[:*
[:& token-context-menu]
[:& selected-set-info* {:tokens-lib tokens-lib
:selected-token-set-id selected-token-set-id}]
[:div {:class (stl/css :sets-header-container)}
[:> text* {:as "span" :typography "headline-small" :class (stl/css :sets-header)} (tr "workspace.tokens.tokens-section-title" (ctob/get-name selected-token-set))]
[:div {:class (stl/css :sets-header-status) :title (tr "workspace.tokens.inactive-set-description")}
;; NOTE: when no set in tokens-lib, the selected-token-set-id
;; will be `nil`, so for properly hide the inactive message we
;; check that at least `selected-token-set-id` has a value
(when (and (some? selected-token-set-id)
(not (token-set-active? (ctob/get-name selected-token-set))))
[:*
[:> icon* {:class (stl/css :sets-header-status-icon) :icon-id i/eye-off}]
[:> text* {:as "span" :typography "body-small" :class (stl/css :sets-header-status-text)}
(tr "workspace.tokens.inactive-set")]])]]
(for [type filled-group]
(let [tokens (get tokens-by-type type)]
[:> token-group* {:key (name type)
:tokens tokens
:is-expanded (get open-status type false)
:is-open (get open-status type false)
:type type
:selected-ids selected
:selected-shapes selected-shapes
:is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens resolved-active-tokens
:tokens-lib tokens-lib
:selected-token-set-id selected-token-set-id}]))
:tokens tokens}]))
(for [type empty-group]
[:> token-group* {:key (name type)
:tokens []
:type type
:selected-shapes selected-shapes
:is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens resolved-active-tokens}])]))
:is-selected-inside-layout :is-selected-inside-layout
:active-theme-tokens resolved-active-tokens
:tokens []}])]))

View File

@@ -9,7 +9,7 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.tokens :as cft]
[app.common.files.tokens :as cfo]
[app.common.types.shape.layout :as ctsl]
[app.common.types.token :as ctt]
[app.main.data.modal :as modal]
@@ -47,9 +47,9 @@
;; Actions ---------------------------------------------------------------------
(defn attribute-actions [token selected-shapes attributes]
(let [ids-by-attributes (cft/shapes-ids-by-applied-attributes token selected-shapes attributes)
(let [ids-by-attributes (cfo/shapes-ids-by-applied-attributes token selected-shapes attributes)
shape-ids (into #{} (map :id selected-shapes))]
{:all-selected? (cft/shapes-applied-all? ids-by-attributes shape-ids attributes)
{:all-selected? (cfo/shapes-applied-all? ids-by-attributes shape-ids attributes)
:shape-ids shape-ids
:selected-pred #(seq (% ids-by-attributes))}))

View File

@@ -0,0 +1,223 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.workspace.tokens.management.create.color
(:require-macros [app.main.style :as stl])
(:require
[app.common.schema :as sm]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob]
[app.main.constants :refer [max-input-length]]
[app.main.data.modal :as modal]
[app.main.data.workspace.tokens.application :as dwta]
[app.main.data.workspace.tokens.library-edit :as dwtl]
[app.main.data.workspace.tokens.propagation :as dwtp]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
[app.main.ui.forms :as fc]
[app.main.ui.workspace.tokens.management.create.form-color-input-token :refer [form-color-input-token*]]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :refer [tr]]
[app.util.keyboard :as k]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(defn- token-value-error-fn
[{:keys [value]}]
(when (or (str/empty? value)
(str/blank? value))
(tr "workspace.tokens.empty-input")))
(defn- make-schema
[tokens-tree]
(sm/schema
[:and
[:map
[:name
[:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
(sm/update-properties cto/schema:token-name assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error")))
[:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))}
#(not (ctob/token-name-path-exists? % tokens-tree))]]]
[:value [::sm/text {:error/fn token-value-error-fn}]]
[:description {:optional true}
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]]]
[:fn {:error/field :value
:error/fn #(tr "workspace.tokens.self-reference")}
(fn [{:keys [name value]}]
(when (and name value)
(not (cto/token-value-self-reference? name value))))]]))
(mf/defc form*
[{:keys [token validate-token action is-create selected-token-set-id tokens-tree-in-selected-set] :as props}]
(let [token
(mf/with-memo [token]
(or token {:type :color}))
token-type
(get token :type)
token-properties
(dwta/get-token-properties token)
token-title (str/lower (:title token-properties))
tokens
(mf/deref refs/workspace-active-theme-sets-tokens)
tokens
(mf/with-memo [tokens token]
;; Ensure that the resolved value uses the currently editing token
;; even if the name has been overriden by a token with the same name
;; in another set below.
(cond-> tokens
(and (:name token) (:value token))
(assoc (:name token) token)))
schema
(mf/with-memo [tokens-tree-in-selected-set]
(make-schema tokens-tree-in-selected-set))
initial
(mf/with-memo [token]
{:name (:name token "")
:value (:value token "")
:description (:description token "")})
form
(fm/use-form :schema schema
:initial initial)
warning-name-change?
(not= (get-in @form [:data :name])
(:name initial))
on-cancel
(mf/use-fn
(fn [e]
(dom/prevent-default e)
(modal/hide!)))
on-delete-token
(mf/use-fn
(mf/deps selected-token-set-id token)
(fn [e]
(dom/prevent-default e)
(modal/hide!)
(st/emit! (dwtl/delete-token selected-token-set-id (:id token)))))
handle-key-down-delete
(mf/use-fn
(mf/deps on-delete-token)
(fn [e]
(when (or (k/enter? e) (k/space? e))
(on-delete-token e))))
handle-key-down-cancel
(mf/use-fn
(mf/deps on-cancel)
(fn [e]
(when (or (k/enter? e) (k/space? e))
(on-cancel e))))
on-submit
(mf/use-fn
(mf/deps validate-token token tokens token-type)
(fn [form _event]
(let [name (get-in @form [:clean-data :name])
description (get-in @form [:clean-data :description])
value (get-in @form [:clean-data :value])]
(->> (validate-token {:token-value value
:token-name name
:token-description description
:prev-token token
:tokens tokens})
(rx/subs!
(fn [valid-token]
(st/emit!
(if is-create
(dwtl/create-token (ctob/make-token {:name name
:type token-type
:value (:value valid-token)
:description description}))
(dwtl/update-token (:id token)
{:name name
:value (:value valid-token)
:description description}))
(dwtp/propagate-workspace-tokens)
(modal/hide))))))))]
[:> fc/form* {:class (stl/css :form-wrapper)
:form form
:on-submit on-submit}
[:div {:class (stl/css :token-rows)}
[:> heading* {:level 2 :typography "headline-medium" :class (stl/css :form-modal-title)}
(tr "workspace.tokens.create-token" token-type)]
[:div {:class (stl/css :input-row)}
[:> fc/form-input* {:id "token-name"
:name :name
:label (tr "workspace.tokens.token-name")
:placeholder (tr "workspace.tokens.enter-token-name" token-title)
:max-length max-input-length
:variant "comfortable"
:auto-focus true}]
(when (and warning-name-change? (= action "edit"))
[:div {:class (stl/css :warning-name-change-notification-wrapper)}
[:> context-notification*
{:level :warning :appearance :ghost} (tr "workspace.tokens.warning-name-change")]])]
[:div {:class (stl/css :input-row)}
[:> form-color-input-token*
{:placeholder (tr "workspace.tokens.token-value-enter")
:label (tr "workspace.tokens.token-value")
:name :value
:token token
:tokens tokens}]]
[:div {:class (stl/css :input-row)}
[:> fc/form-input* {:id "token-description"
:name :description
:label (tr "workspace.tokens.token-description")
:placeholder (tr "workspace.tokens.token-description")
:max-length max-input-length
:variant "comfortable"
:is-optional true}]]
[:div {:class (stl/css-case :button-row true
:with-delete (= action "edit"))}
(when (= action "edit")
[:> button* {:on-click on-delete-token
:on-key-down handle-key-down-delete
:class (stl/css :delete-btn)
:type "button"
:icon i/delete
:variant "secondary"}
(tr "labels.delete")])
[:> button* {:on-click on-cancel
:on-key-down handle-key-down-cancel
:type "button"
:id "token-modal-cancel"
:variant "secondary"}
(tr "labels.cancel")]
[:> fc/form-submit* {:variant "primary"
:on-submit on-submit}
(tr "labels.save")]]]]))

View File

@@ -0,0 +1,222 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.workspace.tokens.management.create.dimensions
(:require-macros [app.main.style :as stl])
(:require
[app.common.schema :as sm]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob]
[app.main.constants :refer [max-input-length]]
[app.main.data.modal :as modal]
[app.main.data.workspace.tokens.application :as dwta]
[app.main.data.workspace.tokens.library-edit :as dwtl]
[app.main.data.workspace.tokens.propagation :as dwtp]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
[app.main.ui.forms :as fc]
[app.main.ui.workspace.tokens.management.create.form-input-token :refer [form-input-token*]]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :refer [tr]]
[app.util.keyboard :as k]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(defn- token-value-error-fn
[{:keys [value]}]
(when (or (str/empty? value)
(str/blank? value))
(tr "workspace.tokens.empty-input")))
(defn- make-schema
[tokens-tree]
(sm/schema
[:and
[:map
[:name
[:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
(sm/update-properties cto/schema:token-name assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error")))
[:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))}
#(not (ctob/token-name-path-exists? % tokens-tree))]]]
[:value [::sm/text {:error/fn token-value-error-fn}]]
[:description {:optional true}
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]]]
[:fn {:error/field :value
:error/fn #(tr "workspace.tokens.self-reference")}
(fn [{:keys [name value]}]
(when (and name value)
(not (cto/token-value-self-reference? name value))))]]))
(mf/defc form*
[{:keys [token validate-token action is-create selected-token-set-id tokens-tree-in-selected-set] :as props}]
(let [token
(mf/with-memo [token]
(or token {:type :dimensions}))
token-type
(get token :type)
token-properties
(dwta/get-token-properties token)
token-title (str/lower (:title token-properties))
tokens
(mf/deref refs/workspace-active-theme-sets-tokens)
tokens
(mf/with-memo [tokens token]
;; Ensure that the resolved value uses the currently editing token
;; even if the name has been overriden by a token with the same name
;; in another set below.
(cond-> tokens
(and (:name token) (:value token))
(assoc (:name token) token)))
schema
(mf/with-memo [tokens-tree-in-selected-set]
(make-schema tokens-tree-in-selected-set))
initial
(mf/with-memo [token]
{:name (:name token "")
:value (:value token "")
:description (:description token "")})
form
(fm/use-form :schema schema
:initial initial)
warning-name-change?
(not= (get-in @form [:data :name])
(:name initial))
on-cancel
(mf/use-fn
(fn [e]
(dom/prevent-default e)
(modal/hide!)))
on-delete-token
(mf/use-fn
(mf/deps selected-token-set-id token)
(fn [e]
(dom/prevent-default e)
(modal/hide!)
(st/emit! (dwtl/delete-token selected-token-set-id (:id token)))))
handle-key-down-delete
(mf/use-fn
(mf/deps on-delete-token)
(fn [e]
(when (or (k/enter? e) (k/space? e))
(on-delete-token e))))
handle-key-down-cancel
(mf/use-fn
(mf/deps on-cancel)
(fn [e]
(when (or (k/enter? e) (k/space? e))
(on-cancel e))))
on-submit
(mf/use-fn
(mf/deps validate-token token tokens token-type)
(fn [form _event]
(let [name (get-in @form [:clean-data :name])
description (get-in @form [:clean-data :description])
value (get-in @form [:clean-data :value])]
(->> (validate-token {:token-value value
:token-name name
:token-description description
:prev-token token
:tokens tokens})
(rx/subs!
(fn [valid-token]
(st/emit!
(if is-create
(dwtl/create-token (ctob/make-token {:name name
:type token-type
:value (:value valid-token)
:description description}))
(dwtl/update-token (:id token)
{:name name
:value (:value valid-token)
:description description}))
(dwtp/propagate-workspace-tokens)
(modal/hide))))))))]
[:> fc/form* {:class (stl/css :form-wrapper)
:form form
:on-submit on-submit}
[:div {:class (stl/css :token-rows)}
[:> heading* {:level 2 :typography "headline-medium" :class (stl/css :form-modal-title)}
(tr "workspace.tokens.create-token" token-type)]
[:div {:class (stl/css :input-row)}
[:> fc/form-input* {:id "token-name"
:name :name
:label (tr "workspace.tokens.token-name")
:placeholder (tr "workspace.tokens.enter-token-name" token-title)
:max-length max-input-length
:variant "comfortable"
:auto-focus true}]
(when (and warning-name-change? (= action "edit"))
[:div {:class (stl/css :warning-name-change-notification-wrapper)}
[:> context-notification*
{:level :warning :appearance :ghost} (tr "workspace.tokens.warning-name-change")]])]
[:div {:class (stl/css :input-row)}
[:> form-input-token*
{:placeholder (tr "workspace.tokens.token-value-enter")
:label (tr "workspace.tokens.token-value")
:name :value
:token token
:tokens tokens}]]
[:div {:class (stl/css :input-row)}
[:> fc/form-input* {:id "token-description"
:name :description
:label (tr "workspace.tokens.token-description")
:placeholder (tr "workspace.tokens.token-description")
:max-length max-input-length
:variant "comfortable"
:is-optional true}]]
[:div {:class (stl/css-case :button-row true
:with-delete (= action "edit"))}
(when (= action "edit")
[:> button* {:on-click on-delete-token
:on-key-down handle-key-down-delete
:class (stl/css :delete-btn)
:type "button"
:icon i/delete
:variant "secondary"}
(tr "labels.delete")])
[:> button* {:on-click on-cancel
:on-key-down handle-key-down-cancel
:type "button"
:id "token-modal-cancel"
:variant "secondary"}
(tr "labels.cancel")]
[:> fc/form-submit* {:variant "primary"
:on-submit on-submit}
(tr "labels.save")]]]]))

View File

@@ -0,0 +1,220 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.workspace.tokens.management.create.font-family
(:require-macros [app.main.style :as stl])
(:require
[app.common.schema :as sm]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob]
[app.main.constants :refer [max-input-length]]
[app.main.data.modal :as modal]
[app.main.data.workspace.tokens.application :as dwta]
[app.main.data.workspace.tokens.library-edit :as dwtl]
[app.main.data.workspace.tokens.propagation :as dwtp]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
[app.main.ui.forms :as fc]
[app.main.ui.workspace.tokens.management.create.combobox-token-fonts :refer [font-picker-combobox*]]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :refer [tr]]
[app.util.keyboard :as k]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(defn- make-schema
[tokens-tree]
(sm/schema
[:and
[:map
[:name
[:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
(sm/update-properties cto/schema:token-name assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error")))
[:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))}
#(not (ctob/token-name-path-exists? % tokens-tree))]]]
[:value ::sm/text]
[:description {:optional true}
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]]]
[:fn {:error/field :value
:error/fn #(tr "workspace.tokens.self-reference")}
(fn [{:keys [name value]}]
(when (and name value)
(not (cto/token-value-self-reference? name value))))]]))
(mf/defc form*
[{:keys [token validate-token action is-create selected-token-set-id tokens-tree-in-selected-set] :as props}]
(let [token
(mf/with-memo [token]
(if token
(update token :value cto/join-font-family)
{:type :font-family}))
token-type
(get token :type)
token-properties
(dwta/get-token-properties token)
token-title (str/lower (:title token-properties))
tokens
(mf/deref refs/workspace-active-theme-sets-tokens)
tokens
(mf/with-memo [tokens token]
;; Ensure that the resolved value uses the currently editing token
;; even if the name has been overriden by a token with the same name
;; in another set below.
(cond-> tokens
(and (:name token) (:value token))
(assoc (:name token) token)))
schema
(mf/with-memo [tokens-tree-in-selected-set]
(make-schema tokens-tree-in-selected-set))
initial
(mf/with-memo [token]
{:name (:name token "")
:value (:value token "")
:description (:description token "")})
form
(fm/use-form :schema schema
:initial initial)
warning-name-change?
(not= (get-in @form [:data :name])
(:name initial))
on-cancel
(mf/use-fn
(fn [e]
(dom/prevent-default e)
(modal/hide!)))
on-delete-token
(mf/use-fn
(mf/deps selected-token-set-id token)
(fn [e]
(dom/prevent-default e)
(modal/hide!)
(st/emit! (dwtl/delete-token selected-token-set-id (:id token)))))
handle-key-down-delete
(mf/use-fn
(mf/deps on-delete-token)
(fn [e]
(when (or (k/enter? e) (k/space? e))
(on-delete-token e))))
handle-key-down-cancel
(mf/use-fn
(mf/deps on-cancel)
(fn [e]
(when (or (k/enter? e) (k/space? e))
(on-cancel e))))
on-submit
(mf/use-fn
(mf/deps validate-token token tokens token-type)
(fn [form _event]
(let [name (get-in @form [:clean-data :name])
description (get-in @form [:clean-data :description])
value (get-in @form [:clean-data :value])]
(->> (validate-token {:token-value value
:token-name name
:token-description description
:prev-token token
:tokens tokens})
(rx/subs!
(fn [valid-token]
(st/emit!
(if is-create
(dwtl/create-token (ctob/make-token {:name name
:type token-type
:value (:value valid-token)
:description description}))
(dwtl/update-token (:id token)
{:name name
:value (:value valid-token)
:description description}))
(dwtp/propagate-workspace-tokens)
(modal/hide))))))))]
[:> fc/form* {:class (stl/css :form-wrapper)
:form form
:on-submit on-submit}
[:div {:class (stl/css :token-rows)}
[:> heading* {:level 2 :typography "headline-medium" :class (stl/css :form-modal-title)}
(tr "workspace.tokens.create-token" token-type)]
[:div {:class (stl/css :input-row)}
[:> fc/form-input* {:id "token-name"
:name :name
:label (tr "workspace.tokens.token-name")
:placeholder (tr "workspace.tokens.enter-token-name" token-title)
:max-length max-input-length
:variant "comfortable"
:auto-focus true}]
(when (and warning-name-change? (= action "edit"))
[:div {:class (stl/css :warning-name-change-notification-wrapper)}
[:> context-notification*
{:level :warning :appearance :ghost} (tr "workspace.tokens.warning-name-change")]])]
[:div {:class (stl/css :input-row)}
[:> font-picker-combobox*
{:placeholder (tr "workspace.tokens.token-value-enter")
:label (tr "workspace.tokens.token-value")
:name :value
:token token
:tokens tokens}]]
[:div {:class (stl/css :input-row)}
[:> fc/form-input* {:id "token-description"
:name :description
:label (tr "workspace.tokens.token-description")
:placeholder (tr "workspace.tokens.token-description")
:max-length max-input-length
:variant "comfortable"
:is-optional true}]]
[:div {:class (stl/css-case :button-row true
:with-delete (= action "edit"))}
(when (= action "edit")
[:> button* {:on-click on-delete-token
:on-key-down handle-key-down-delete
:class (stl/css :delete-btn)
:type "button"
:icon i/delete
:variant "secondary"}
(tr "labels.delete")])
[:> button* {:on-click on-cancel
:on-key-down handle-key-down-cancel
:type "button"
:id "token-modal-cancel"
:variant "secondary"}
(tr "labels.cancel")]
[:> fc/form-submit* {:variant "primary"
:on-submit on-submit}
(tr "labels.save")]]]]))

Some files were not shown because too many files have changed in this diff Show More