Compare commits

..

66 Commits

Author SHA1 Message Date
Eva Marco
fd91fc6d9c ♻️ Replace stroke width numeric input 2026-01-20 17:14:41 +01:00
Eva Marco
4af0ad17fd 🐛 Fix glich when applying padding 2026-01-20 14:33:23 +01:00
Eva Marco
49eef0771b Add test 2026-01-20 14:33:22 +01:00
Eva Marco
875155e032 Replace opacity numeric input 2026-01-20 14:32:53 +01:00
Eva Marco
7499a5bca6 ♻️ Replace opacity input (#8072)
*  Replace opacity numeric input

*  Add test
2026-01-20 14:30:35 +01:00
Xaviju
6cd5bc76d7 💄 Replace current themes switch with DS switch (#8131) 2026-01-20 14:17:25 +01:00
David Barragán Merino
bbe6ee2e19 🔧 Define a different temporary config file for each execution 2026-01-20 12:59:56 +01:00
David Barragán Merino
fb6d8309b6 🔧 Prevent error 429 downloading docker images from dockerhub 2026-01-20 12:59:56 +01:00
Andrey Antukh
689467bcf9 📎 Update changelog 2026-01-20 12:25:43 +01:00
Andrey Antukh
7724450037 Merge remote-tracking branch 'origin/staging-render' into develop 2026-01-20 12:18:04 +01:00
Xaviju
368fa954ce Remove tokens tree node (#8042) 2026-01-20 11:00:13 +01:00
Andrey Antukh
6fd0f5377c Merge remote-tracking branch 'origin/staging' into staging-render 2026-01-20 10:08:58 +01:00
Elena Torró
eb54bc485e Merge pull request #8120 from penpot/alotor-fix-flex-layout
🐛 Fix problems with layout
2026-01-20 10:00:24 +01:00
Elena Torró
12c24a36b4 Merge pull request #8122 from penpot/fix-thumbnail-generation
🐛 Fix problem with thumbnail generation
2026-01-20 09:59:34 +01:00
Alejandro Alonso
324d54ad28 🐛 Fix set all rounded corners to 0 2026-01-20 09:34:06 +01:00
alonso.torres
f42ff27f3d 🐛 Fix problem with bools 2026-01-19 17:05:04 +01:00
Andrey Antukh
0ecb2bc838 Merge remote-tracking branch 'origin/staging' into develop 2026-01-19 13:42:05 +01:00
Andrey Antukh
3ecf509f3b 🔧 Add missing gif files handling on defaut nginx config 2026-01-19 13:32:01 +01:00
alonso.torres
2c1cc89f53 🐛 Fix problem with thumbnail generation 2026-01-19 12:54:15 +01:00
alonso.torres
498b0b30fe 🐛 Fix problems with layout 2026-01-19 12:17:58 +01:00
Elena Torró
89f40dcda2 🔧 Move WebGL context error message to 'errors' namespace (#8117) 2026-01-19 11:24:19 +01:00
Andrey Antukh
e92f3fb3cb Use pseudo-names on release builds of frontend (#8105) 2026-01-19 11:23:35 +01:00
Andrey Antukh
5193cfd56e :paerclip: Update changelog 2026-01-19 11:15:39 +01:00
Dalai Felinto
7f395b2642 🐛 Fix import tokens dialog default option (#8051)
This was intended to be changed on 13fcf3a9bb. However only the menu
order changed, not the default option.

Signed-off-by: Dalai Felinto <dalai@blender.org>
Co-authored-by: Dalai Felinto <dalai@blender.org>
2026-01-19 11:14:34 +01:00
Dalai Felinto
813d5d8e69 🐛 Fix import tokens dialog default option (#8051)
This was intended to be changed on 13fcf3a9bb. However only the menu
order changed, not the default option.

Signed-off-by: Dalai Felinto <dalai@blender.org>
Co-authored-by: Dalai Felinto <dalai@blender.org>
2026-01-19 11:13:55 +01:00
Andrey Antukh
84f1ff092d Merge remote-tracking branch 'origin/staging' into develop 2026-01-19 11:10:37 +01:00
Juan de la Cruz
2a62bd2586 🎉 Add 2.13 release slides (#8116) 2026-01-19 11:09:58 +01:00
Elena Torró
ccac7bd510 Merge pull request #8108 from penpot/ladybenko-13022-blur-page
🎉 Apply blur effect when switching pages
2026-01-19 11:04:31 +01:00
Andrey Antukh
f2b082b93e Merge branch 'staging-render' into develop 2026-01-19 10:54:37 +01:00
Andrey Antukh
d73197625d Merge remote-tracking branch 'origin/staging' into staging-render 2026-01-19 10:43:43 +01:00
Marina López
fdf5bb250b 🐛 Fix profile not updating after deleting a team member (#8075) 2026-01-18 10:08:18 +01:00
Yamila Moreno
786736fadd Improve default nginx config (#8104) 2026-01-18 10:07:44 +01:00
Pablo Alba
1ff6e00398 🐛 Fix error message on components doesn't close automatically (#8081) 2026-01-18 10:07:11 +01:00
David Barragán Merino
25455523ad 🔧 Revert use of selfhosted runner 02 to generate the bundle 2026-01-16 17:51:02 +01:00
David Barragán Merino
8dfeb21978 🔧 Use selfhosted runner 02 to generate the bundle and the docker images 2026-01-16 17:44:15 +01:00
Belén Albeza
43d1d127dc 🎉 Apply blur effect to previous canvas pixels while setting wasm objects 2026-01-16 13:04:59 +01:00
Belén Albeza
8bd3ef717c 🎉 Apply blur to canvas when switching pages 2026-01-16 13:04:59 +01:00
Elena Torro
53bc647783 🔧 Fix shape selection from canvas to sidebar 2026-01-16 13:02:25 +01:00
Alejandro Alonso
ad2833bb7a Merge pull request #8107 from penpot/elenatorro-13077-improve-shape-selection-performance
🔧 Fix shape selection from canvas to sidebar
2026-01-16 13:01:58 +01:00
Elena Torro
21911e898f 🔧 Fix shape selection from canvas to sidebar 2026-01-16 12:45:45 +01:00
Andrey Antukh
538073debf Merge remote-tracking branch 'origin/staging' into develop 2026-01-16 10:38:20 +01:00
Andrey Antukh
1ae7515189 🐛 Set correct defaults for storage configuration on compose (#8060)
* 🔥 Remove mention to legacy and never released admin on compose

* 🐛 Set correct defaults for storage configuration on compose
2026-01-16 08:11:34 +01:00
andrés gonzález
8c401f5346 📚 Add Files and Projects section to user guide (#8095) 2026-01-15 17:01:05 +01:00
Elena Torró
6029f9bb51 Merge pull request #8089 from penpot/superalex-improve-zoom-pan-performance-5
🎉 Performance improvements
2026-01-15 16:46:07 +01:00
Elena Torro
e0fd8bac81 🔧 Optimize sidebar performance for deeply nested shapes
- Batch hover highlights using RAF to avoid long tasks from rapid events
- Run parent expansion asynchronously to not block selection
- Lazy-load children in layer items using IntersectionObserver
- Clarify expand-all-parents logic with explicit bindings
2026-01-15 13:41:54 +01:00
Elena Torro
34737ddfc9 🔧 Always lookup over a set 2026-01-15 13:41:10 +01:00
Elena Torro
a8dfd19338 🔧 Add performance debugging logs 2026-01-15 13:40:58 +01:00
Elena Torro
e33e8a8c3b 🔧 Lookup page objects only when value changes 2026-01-15 13:40:53 +01:00
Alejandro Alonso
c411aefc6c 🐛 Fix rotated shapes extrect calculation 2026-01-15 12:53:21 +01:00
Alejandro Alonso
311e124658 🎉 Reduce extrect work in tile traversal
Avoid repeated extrect calculations and simplify root ordering per tile.
2026-01-15 12:53:21 +01:00
Alejandro Alonso
afc914f486 🎉 Render simple shapes directly on Current
Bypass intermediate surfaces for simple shapes without effects.
2026-01-15 12:53:21 +01:00
andrés gonzález
b72de2dc8f 📚 Add shadow token documentation (#8082) 2026-01-15 09:09:08 +01:00
Alejandro Alonso
84f750da0d 🎉 Skip heavy effects in fast mode
Avoid blur and shadow passes for text and shapes when FAST_MODE is enabled.
2026-01-15 08:45:21 +01:00
Elena Torro
a3119bef5e 🔧 Show message and button to reload the page when WebGL context is lost 2026-01-14 11:10:03 +01:00
Alejandro Alonso
c60d74df62 🐛 Fix nested frames border clipping 2026-01-14 11:10:03 +01:00
Alejandro Alonso
d593e299e3 🐛 Fix mask erros on save/restore optimizations 2026-01-14 11:10:03 +01:00
Alejandro Alonso
4a8e02987f 🐛 Fix mask erros on save/restore optimizations 2026-01-14 11:10:03 +01:00
Alejandro Alonso
ee766e85a0 🎉 Wasm render dirty surfaces 2026-01-14 11:10:03 +01:00
Alejandro Alonso
35e3b7f19a 🎉 Root ids refactor 2026-01-14 11:10:03 +01:00
Alejandro Alonso
1810df232b 🎉 Ignore frames and groups when they have no visual extra information 2026-01-14 11:10:03 +01:00
Alejandro Alonso
3e99ad036c 🎉 Avoid unnecesary saves and restores 2026-01-14 11:10:03 +01:00
Alejandro Alonso
042a3a4080 🐛 Fix wasm playgrounds 2026-01-14 11:10:03 +01:00
Belén Albeza
f0687fd1f7 🎉 Make workspace loader to wait for first render 2026-01-14 11:10:03 +01:00
Aitor Moreno
2c9159288f 🐛 Fix previous styles lost when changing selected text 2026-01-14 11:10:01 +01:00
Alejandro Alonso
51635770ce Merge pull request #8065 from penpot/eva-fix-lost-translation
🐛 Fix translation on rename token
2026-01-14 09:44:41 +01:00
Eva Marco
18a4e63da0 🐛 Fix translation on rename token 2026-01-13 15:57:09 +01:00
104 changed files with 4618 additions and 2470 deletions

View File

@@ -7,9 +7,14 @@ jobs:
build-and-push:
name: Build and push DevEnv Docker image
environment: release-admins
runs-on: ubuntu-24.04
runs-on: penpot-runner-02
steps:
- name: Set common environment variables
run: |
# Each job execution will use its own docker configuration.
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
- name: Checkout code
uses: actions/checkout@v4

View File

@@ -19,9 +19,14 @@ on:
jobs:
build-and-push:
name: Build and Push Penpot Docker Images
runs-on: ubuntu-24.04-arm
runs-on: penpot-runner-02
steps:
- name: Set common environment variables
run: |
# Each job execution will use its own docker configuration.
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
- name: Checkout code
uses: actions/checkout@v4
with:
@@ -66,6 +71,15 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
# To avoid the “429 Too Many Requests” error when downloading
# images from DockerHub for unregistered users.
# https://docs.docker.com/docker-hub/usage/
- name: Login to DockerHub Registry
uses: docker/login-action@v3
with:
username: ${{ secrets.PUB_DOCKER_USERNAME }}
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5

2
.gitignore vendored
View File

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

View File

@@ -14,13 +14,16 @@
- Tokens panel nested path view [Taiga #9966](https://tree.taiga.io/project/penpot/us/9966)
- Improve usability of lock and hide buttons in the layer panel. [Taiga #12916](https://tree.taiga.io/project/penpot/issue/12916)
- Optimize sidebar performance for deeply nested shapes [Taiga #13017](https://tree.taiga.io/project/penpot/task/13017)
- Remove tokens path node and bulk remove tokens [Taiga #13007](https://tree.taiga.io/project/penpot/us/13007)
- Replace themes management modal radio buttons for switches [Taiga #9215](https://tree.taiga.io/project/penpot/us/9215)
### :bug: Bugs fixed
- Fix prototype connections lost when switching between variants [Taiga #12812](https://tree.taiga.io/project/penpot/issue/12812)
- Fix wrong image in the onboarding invitation block [Taiga #13040](https://tree.taiga.io/project/penpot/issue/13040)
- Fix wrong register image [Taiga #12955](https://tree.taiga.io/project/penpot/task/12955)
- Fix error message on components doesn't close automatically [Taiga #12012](https://tree.taiga.io/project/penpot/issue/12012)
- Fix incorrect default option on tokens import dialog [Github #8051](https://github.com/penpot/penpot/pull/8051)
## 2.13.0 (Unreleased)
@@ -52,6 +55,7 @@
- Fix missing text color token from selected shapes in selected colors list [Taiga #12956](https://tree.taiga.io/project/penpot/issue/12956)
- Fix dropdown option width in Guides columns dropdown [Taiga #12959](https://tree.taiga.io/project/penpot/issue/12959)
- Fix typos on download modal [Taiga #12865](https://tree.taiga.io/project/penpot/issue/12865)
- Fix problem with text editor maintaining previous styles [Taiga #12835](https://tree.taiga.io/project/penpot/issue/12835)
## 2.12.1
@@ -167,7 +171,6 @@ example. It's still usable as before, we just removed the example.
- Deprecated configuration variables with the prefix `PENPOT_ASSETS_*`, and will be
removed in future versions:
- The `PENPOT_ASSETS_STORAGE_BACKEND` becomes `PENPOT_OBJECTS_STORAGE_BACKEND` and its
values passes from (`assets-fs` or `assets-s3`) to (`fs` or `s3`)
- The `PENPOT_STORAGE_ASSETS_FS_DIRECTORY` becomes `PENPOT_OBJECTS_STORAGE_FS_DIRECTORY`

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/control-center
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,133 +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]])
(def ^:private schema:user
[:map
[:valid ::sm/boolean]])
(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))
(defn- is-valid-user
[cfg {:keys [profile-id] :as params}]
(request-to-nitrate cfg :get (str baseuri "/api/users/" (str profile-id)) schema:user 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)
:is-valid-user (partial is-valid-user cfg)})
{}))
(defmethod ig/halt-key! ::client
[_ {:keys []}]
(do :stuff))

View File

@@ -301,7 +301,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

@@ -21,7 +21,6 @@
[app.loggers.audit :as audit]
[app.main :as-alias main]
[app.media :as media]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[app.rpc.climit :as climit]
[app.rpc.doc :as-alias doc]
@@ -89,17 +88,6 @@
;; --- QUERY: Get profile (own)
(defn- add-nitrate-licence-to-profile
[cfg profile]
(try
(let [nitrate-licence (nitrate/call cfg :is-valid-user {:profile-id (:id profile)})]
(assoc profile :nitrate-licence (:valid nitrate-licence)))
(catch Throwable cause
(prn :hint "failed to get nitrate licence"
:profile-id (:id profile)
:cause cause)
profile)))
(sv/defmethod ::get-profile
{::rpc/auth false
::doc/added "1.18"
@@ -110,13 +98,9 @@
;; no profile-id is in session, and when db call raises not found. In all other
;; cases we need to reraise the exception.
(try
(let [profile (-> (get-profile pool profile-id)
(strip-private-attrs)
(update :props filter-props))]
(if (contains? cf/flags :nitrate)
(add-nitrate-licence-to-profile cfg profile)
profile))
(-> (get-profile pool profile-id)
(strip-private-attrs)
(update :props filter-props))
(catch Throwable _
{:id uuid/zero :fullname "Anonymous User"})))

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

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

@@ -474,8 +474,8 @@
:height #{:sizing :dimensions}
:max-width #{:sizing :dimensions}
:max-height #{:sizing :dimensions}
:x #{:spacing :dimensions}
:y #{:spacing :dimensions}
:x #{:dimensions}
:y #{:dimensions}
:rotation #{:number :rotation}
:border-radius #{:border-radius :dimensions}
:row-gap #{:spacing :dimensions}
@@ -487,6 +487,8 @@
:vertical-margin #{:spacing :dimensions}
:sided-margins #{:spacing :dimensions}
:line-height #{:line-height :number}
:opacity #{:opacity}
:stroke-width #{:stroke-width}
:font-size #{:font-size}
:letter-spacing #{:letter-spacing}
:fill #{:color}

View File

@@ -141,14 +141,8 @@ http {
proxy_pass http://127.0.0.1:5000;
}
location /control-center {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
location /nitrate/ {
proxy_pass http://127.0.0.1:3000/;
}
location /wasm-playground {
@@ -229,7 +223,7 @@ http {
add_header X-Cache-Status $upstream_cache_status;
}
location ~* \.(jpg|png|svg|ttf|woff|woff2)$ {
location ~* \.(jpg|png|svg|ttf|woff|woff2|gif)$ {
add_header Cache-Control "public, max-age=604800" always; # 7 days
}

View File

@@ -130,12 +130,6 @@ services:
environment:
<< : [*penpot-flags, *penpot-public-uri, *penpot-http-body-size, *penpot-secret-key]
## The PREPL host. Mainly used for external programatic access to penpot backend
## (example: admin). By default it will listen on `localhost` but if you are going to use
## the `admin`, you will need to uncomment this and set the host to `0.0.0.0`.
# PENPOT_PREPL_HOST: 0.0.0.0
## Database connection parameters. Don't touch them unless you are using custom
## postgresql connection parameters.
@@ -151,8 +145,8 @@ services:
## Default configuration for assets storage: using filesystem based with all files
## stored in a docker volume.
PENPOT_ASSETS_STORAGE_BACKEND: assets-fs
PENPOT_STORAGE_ASSETS_FS_DIRECTORY: /opt/data/assets
PENPOT_OBJECTS_STORAGE_BACKEND: fs
PENPOT_OBJECTS_STORAGE_FS_DIRECTORY: /opt/data/assets
## Also can be configured to to use a S3 compatible storage.

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,20 +139,12 @@ 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 / {
include /etc/nginx/overrides/location.d/*.conf;
location ~* \.(js|css|jpg|png|svg|ttf|woff|woff2|wasm)$ {
location ~* \.(js|css|jpg|png|svg|gif|ttf|woff|woff2|wasm|map)$ {
add_header Cache-Control "public, max-age=604800" always; # 7 days
}
@@ -160,8 +152,10 @@ http {
return 301 " /404";
}
add_header X-Frame-Options SAMEORIGIN 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -19,6 +19,12 @@ desc: Begin with the Penpot user guide! Get quickstarts, shortcuts, and tutorial
<p>Create and manage your teams</p>
</a>
</li>
<li>
<a href="/user-guide/account-teams/projects-files">
<h2>Projects and Files →</h2>
<p>Organize your work with projects and files</p>
</a>
</li>
<li>
<a href="/user-guide/account-teams/comments/">
<h2>Comments →</h2>

View File

@@ -0,0 +1,107 @@
---
title: Projects and Files
order: 3
desc: Learn how to organize your work in Penpot. Create, manage and organize projects and files, work with drafts, and handle deleted items.
---
<h1 id="projects-files">Projects and Files</h1>
<p class="main-paragraph">Projects and files are the core organizational structure in Penpot. Projects work like folders that contain multiple design files, helping you organize your work efficiently. Files are your actual design documents where you create boards, pages, and all your design elements.</p>
<p class="main-paragraph">Understanding how to manage projects and files will help you keep your workspace organized and make it easier to collaborate with your team.</p>
<h2 id="projects-management">Projects</h2>
<p>Projects are containers that help you organize and group related design files together. Think of them as folders in a file system. You can create as many projects as you need to organize your work by client, product, feature, or any other structure that fits your workflow.</p>
<p>If you're working with others, projects should be created inside a team so that team members can collaborate on the files within them. Projects created in your personal space ("Your Penpot") remain private to you.</p>
<figure>
<img src="/img/files-projects/01-projects.webp" alt="Projects view in dashboard" />
</figure>
<h3 id="create-project">Create a project</h3>
<p>To create a new project, use the <strong>+ New project</strong> button in the dashboard. You can also use the keyboard shortcut <kbd>+</kbd> when you're on the dashboard. A dialog will appear where you can enter the project name. Once created, the project will appear in your projects list.</p>
<p>When you create a project, you can immediately start adding files to it, or create files first and move them into the project later.</p>
<h3 id="edit-project">Edit a project</h3>
<p>To edit a project's name, right-click on the project in the sidebar or click the three-dot menu next to the project name. Select <strong>Edit</strong> or <strong>Rename</strong> to change the project name. You can also update the project's profile picture from the same menu.</p>
<h3 id="pin-project">Pin a project</h3>
<p>Projects can be pinned to the sidebar for quick access. Right-click on a project and select <strong>Pin</strong> to keep it visible in the sidebar even when you have many projects. Pinned projects appear at the top of your projects list for easy access.</p>
<p>To unpin a project, right-click on it and select <strong>Unpin</strong>. The project will remain in your list but won't be pinned to the sidebar anymore.</p>
<figure>
<img src="/img/files-projects/04-pin-project.webp" alt="Pin project option" />
</figure>
<h3 id="move-project">Move a project</h3>
<p>Projects can be moved between teams. To move a project, right-click on it and select <strong>Move to</strong> from the context menu. A dialog will appear showing all available teams where you can move the project. Select the destination team and confirm the move.</p>
<figure>
<img src="/img/files-projects/06-move-project.webp" alt="Move project to another team" />
</figure>
<p>When you move a project to another team, all files within the project are moved along with it. Team members of the destination team will gain access to the project and its files according to their permissions.</p>
<p class="advice">Moving a project to another team changes its ownership and access permissions. Make sure the destination team has the appropriate members and permissions for the work contained in the project.</p>
<h3 id="delete-project">Delete a project</h3>
<p>To delete a project, right-click on it and select <strong>Delete</strong> from the menu. You'll be asked to confirm the deletion. Keep in mind that deleting a project will also delete all files within it. Make sure you have backed up any important files before deleting a project.</p>
<p class="advice">Deleted projects and their files are moved to the trash area where they can be restored or permanently deleted.</p>
<h2 id="files-management">Files</h2>
<p>Files are your design documents in Penpot. Each file contains pages, boards, and all the design elements you create. Files can be created within a project or in the drafts section, and you can move them between projects as needed.</p>
<h3 id="create-file">Create a file</h3>
<p>To create a new file, you have several options:</p>
<ul>
<li>Click the <strong>+</strong> button in a project to create a file inside that project</li>
<li>Use the keyboard shortcut <kbd>+</kbd> when you have a project selected</li>
<li>Create a file directly in the drafts section if you're not ready to organize it into a project yet</li>
</ul>
<figure>
<img src="/img/files-projects/05-create-file.webp" alt="Create a new file" />
</figure>
<p>When creating a file, you'll be asked to give it a name. The file will open in the workspace where you can start designing immediately.</p>
<h3 id="edit-file">Edit a file</h3>
<p>To rename a file, right-click on the file card in the dashboard and select <strong>Rename</strong>, or click on the three-dot menu on the file card. Enter the new name and confirm the change. You can also access file settings and other options from the file's context menu.</p>
<h3 id="move-file">Move a file</h3>
<p>Files can be moved between projects, from drafts to a project, or even to projects in other teams. To move a file, right-click on the file card and select <strong>Move to</strong> from the context menu. A dialog will appear showing all available projects across your teams where you can choose the destination. Select the project where you want to move the file and confirm.</p>
<p>You can also drag and drop files between projects in the dashboard for a quick way to reorganize your files within the same team.</p>
<p>When moving a file to a project in another team, the file becomes accessible to members of that team according to their permissions. Moving a file doesn't affect its content or any shared libraries it might be using. Only its location in your project structure changes.</p>
<p class="advice">When moving files between teams, be aware that this changes who has access to the file. Make sure the destination team has the appropriate members and permissions for the work contained in the file.</p>
<h3 id="duplicate-file">Duplicate a file</h3>
<p>To create a copy of an existing file, right-click on the file card and select <strong>Duplicate</strong>. The duplicated file will be created in the same location (project or drafts) with the same name plus "Copy" added to it. You can then rename or move it as needed.</p>
<p>Duplicating a file creates a complete copy including all pages, boards, and design elements. This is useful when you want to create variations of a design or use a file as a starting point for a new project.</p>
<h3 id="delete-file">Delete a file</h3>
<p>To delete a file, right-click on the file card and select <strong>Delete</strong>. You'll be asked to confirm the deletion. The file will be moved to the trash area where it can be restored or permanently deleted later.</p>
<p class="advice">Deleting a file doesn't immediately remove it permanently. You can recover deleted files from the trash area within a certain time period.</p>
<h2 id="drafts">Drafts</h2>
<p>The drafts section is a fixed, non-deletable space in your dashboard where you can create and store files that aren't part of any specific project yet. This is useful for quick sketches, experimental designs, or files you're not ready to organize into projects.</p>
<figure>
<img src="/img/files-projects/02-drafts.webp" alt="Drafts section" />
</figure>
<p>Drafts appear in a dedicated section in the dashboard sidebar, separate from your projects. All team members can see and access files in the drafts section, depending on their permissions.</p>
<p>You can create files directly in drafts, or move existing files from projects into drafts if you want to temporarily remove them from a project's organization. Files in drafts work exactly like files in projects - they have the same functionality and features.</p>
<p>When you're ready to organize a file from drafts, you can move it into an appropriate project using the move option in the file's context menu.</p>
<h2 id="trash-area">Trash area</h2>
<p>When you delete projects or files, they are not removed permanently. Instead, they are moved to a trash area, a dedicated space for deleted content. This allows you to recover mistakenly deleted content or permanently remove items when you're sure you don't need them anymore.</p>
<p>The trash applies to both files and projects. Items in the trash remain there for a certain period depending on your Penpot subscription plan before being automatically deleted permanently.</p>
<h3 id="access-trash">Access the trash</h3>
<p>A <strong>Trash</strong> section is accessible from the dashboard navigation. When you access it, you'll see all your deleted files and projects, each clearly labeled so you can easily identify what you want to restore or permanently delete.</p>
<figure>
<img src="/img/files-projects/03-trash.webp" alt="Trash area" />
</figure>
<h3 id="trash-permissions">Trash permissions</h3>
<p>Access to the trash and the actions you can perform depend on your role in the team:</p>
<ul>
<li><strong>Owner, Admin, and Editor:</strong> Can view the trash, restore deleted items, and permanently delete items from the trash.</li>
<li><strong>Viewer:</strong> Cannot access the trash or manage deleted content.</li>
</ul>
<h3 id="restore-items">Restore items</h3>
<p>To restore a deleted file or project, access the trash area and find the item you want to recover. Select the item and choose <strong>Restore</strong>. The item will be restored to its original location (the project it belonged to, or the drafts section if it wasn't in a project).</p>
<h3 id="permanently-delete">Permanently delete items</h3>
<p>If you're sure you don't need an item anymore, you can permanently delete it from the trash. Select the item and choose <strong>Permanently delete</strong>. This action cannot be undone, so make sure you really want to remove the item permanently.</p>
<p class="advice">Items in the trash are automatically deleted after a certain period depending on your subscription plan. If you want to keep something, restore it before the auto-deletion period expires.</p>

View File

@@ -455,6 +455,43 @@ ExtraBold Italic
<p>A <strong>Typography composite token</strong> can be applied to a full text layer to set all typography properties at once. This lets you manage complete text styles using a single token instead of combining multiple individual ones.</p>
<p>When applying a Typography composite token to a layer, any previously applied <em>Typography composite token</em> or <em>style</em> will be detached. The same happens in reverse. Only one of them can be active at a time.</p>
<h3 id="design-tokens-shadow">Shadow</h3>
<p>Shadow tokens are composite entities that encapsulate the properties of one or more shadows into a single token definition. This token can contain a single shadow or an array of multiple shadows that can be reordered.</p>
<p>Shadow tokens support both <strong>Drop Shadow</strong> and <strong>Inner Shadow</strong> types. When creating or editing a shadow token, you can select the type of shadow you want to use. The default selection is Drop Shadow.</p>
<figure>
<img src="/img/design-tokens/37-tokens-shadow-individual.webp" alt="Shadow token creation with individual values" />
</figure>
<h4 id="design-tokens-shadow-properties">Shadow properties</h4>
<p>Each shadow within a shadow token contains a set of properties that define how the shadow appears:</p>
<ul>
<li><strong>Color:</strong> The color of the shadow. Accepts the same values as <a href="#design-tokens-color">color tokens</a> (Hex, RGB, RGBA, ARGB, HSL, HSLA), and you can reference existing color tokens. The color picker is available when defining the value.</li>
<li><strong>X offset:</strong> The horizontal offset of the shadow. Can be unit or unitless, and accepts negative values. You can use a number or reference a <a href="#design-tokens-number">number</a> or <a href="#design-tokens-dimensions">dimension</a> token.</li>
<li><strong>Y offset:</strong> The vertical offset of the shadow. Can be unit or unitless, and accepts negative values. You can use a number or reference a <a href="#design-tokens-number">number</a> or <a href="#design-tokens-dimensions">dimension</a> token.</li>
<li><strong>Blur:</strong> The blur radius of the shadow. Can be unit or unitless. You can use a number or reference a <a href="#design-tokens-number">number</a> or <a href="#design-tokens-dimensions">dimension</a> token.</li>
<li><strong>Spread:</strong> The spread radius of the shadow. Can be unit or unitless. You can use a number or reference a <a href="#design-tokens-number">number</a> or <a href="#design-tokens-dimensions">dimension</a> token.</li>
<li><strong>Type:</strong> Whether the shadow is a drop shadow or an inner shadow. Selected via a dropdown menu, with Drop Shadow as the default.</li>
</ul>
<p>Each property within a shadow token can reference existing tokens or be assigned hardcoded values. Shadows can also reference other shadow tokens (the type of shadow must match when using references).</p>
<p class="advice">Not all properties are mandatory to save a shadow token. Some can be empty (and will be computed as 0). Only the color property is mandatory. In an array of shadows, if any shadow does not have the color set, the form cannot be saved.</p>
<h4 id="design-tokens-shadow-create">Creating shadow tokens</h4>
<p>To create a shadow token, click on the <strong>+</strong> next to <strong>Shadow</strong> in the Tokens panel. Shadow tokens can be created in two ways:</p>
<ul>
<li><strong>Individual values:</strong> You can create one shadow or multiple shadows with individual property values. Click the <strong>+</strong> button to add more shadows to the array. New shadows are added at the top of the list.</li>
<li><strong>Single reference:</strong> You can reference another existing shadow token. When using a single reference, you cannot add more than one shadow. The resolved value will display the shadow or list of shadows that the referenced token contains.</li>
</ul>
<figure>
<img src="/img/design-tokens/38-tokens-shadow-reference.webp" alt="Shadow token creation with reference" />
</figure>
<p>When creating a shadow with individual values, the color value starts empty, but the other inputs have default values (X: 4, Y: 4, Blur: 4, Spread: 0). You can reorder shadows by hovering over a shadow form and using the reorder button to drag it to a different position.</p>
<p>You can also reference another existing shadow token instead of defining each property manually. When doing so, Penpot resolves all shadow properties from the referenced token.</p>
<h4 id="design-tokens-shadow-apply">Applying shadow tokens</h4>
<p>Shadow tokens can be applied to any layer type. Clicking on a shadow token will apply it to the selected layer. Right-clicking on a shadow token shows the context menu with the <strong>Shadow</strong> option to apply it.</p>
<p class="advice">Text elements in CSS do not support inner shadows, but Penpot does, since it uses the filter property internally instead of the box-shadow property.</p>
<p>When applying a shadow token, any existing shadow on the layer will be overridden (whether it's a raw shadow or an applied token shadow). If the token contains an array of shadows, each shadow will be added in the same order as in the creation form.</p>
<p class="advice">In Penpot, an element can have multiple shadows, but only one token of the same type can be applied. This means that applying a second shadow token would override the first one, regardless of how many shadows the shape currently has.</p>

View File

File diff suppressed because it is too large Load Diff

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -101,6 +101,72 @@ test.describe("Tokens: Apply token", () => {
await expect(brTokenPillXL).not.toBeVisible();
});
test("User applies opacity token to a shape from sidebar", async ({
page,
}) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
await page.getByRole("tab", { name: "Layers" }).click();
await workspacePage.layers.getByTestId("layer-row").nth(1).click();
// Open tokens sections on left sidebar
const tokensTabButton = page.getByRole("tab", { name: "Tokens" });
await tokensTabButton.click();
// Unfold opacity tokens
await page.getByRole("button", { name: "Opacity 3" }).click();
await expect(
tokensSidebar.getByRole("button", { name: "opacity", exact: true }),
).toBeVisible();
await tokensSidebar
.getByRole("button", { name: "opacity", exact: true })
.click();
await expect(
tokensSidebar.getByRole("button", { name: "opacity.high" }),
).toBeVisible();
// Apply opacity token from token panels
await tokensSidebar.getByRole("button", { name: "opacity.high" }).click();
// Check if opacity sections is visible on right sidebar
const layerMenuSection = page.getByRole("region", {
name: "layer-menu-section",
});
await expect(layerMenuSection).toBeVisible();
// Check if token pill is visible on design tab on right sidebar
const opacityHighPill = layerMenuSection.getByRole("button", {
name: "opacity.high",
});
await expect(opacityHighPill).toBeVisible();
// Detach token from design tab on right sidebar
const detachButton = layerMenuSection.getByRole("button", {
name: "Detach token",
});
await detachButton.click();
// Open dropdown from input
const dropdownBtn = layerMenuSection.getByLabel("Open token list");
await expect(dropdownBtn).toBeVisible();
await dropdownBtn.click();
// Change token from dropdown
const opacityLowOption = layerMenuSection.getByRole("option", {
name: "opacity.low",
});
await expect(opacityLowOption).toBeVisible();
await opacityLowOption.click();
await expect(opacityHighPill).not.toBeVisible();
const opacityLowPill = layerMenuSection.getByRole("button", {
name: "opacity.low",
});
await expect(opacityLowPill).toBeVisible();
});
test("User applies typography token to a text shape", async ({ page }) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTypographyTokensFile(page);
@@ -129,189 +195,6 @@ test.describe("Tokens: Apply token", () => {
await expect(fontSizeInput).toHaveValue("100");
});
test("User edits typography token and all fields are valid", async ({
page,
}) => {
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
await setupTypographyTokensFile(page);
await tokensSidebar
.getByRole("button")
.filter({ hasText: "Typography" })
.click();
// Open edit modal for "Full" typography token
const token = tokensSidebar.getByRole("button", { name: "Full" });
await token.click({ button: "right" });
await page.getByText("Edit token").click();
// Modal opens
await expect(tokensUpdateCreateModal).toBeVisible();
const saveButton = tokensUpdateCreateModal.getByRole("button", {
name: /save/i,
});
// Fill font-family to verify to verify that input value doesn't get split into list of characters
const fontFamilyField = tokensUpdateCreateModal
.getByLabel("Font family")
.first();
await fontFamilyField.fill("OneWord");
// Invalidate incorrect values for font size
const fontSizeField = tokensUpdateCreateModal.getByLabel(/Font Size/i);
await fontSizeField.fill("invalid");
await expect(
tokensUpdateCreateModal.getByText(/Invalid token value:/),
).toBeVisible();
await expect(saveButton).toBeDisabled();
// Show error with line-height depending on invalid font-size
await fontSizeField.fill("");
await expect(saveButton).toBeDisabled();
// Fill in values for all fields and verify they persist when switching tabs
await fontSizeField.fill("16");
await expect(saveButton).toBeEnabled();
const fontWeightField = tokensUpdateCreateModal.getByLabel(/Font Weight/i);
const letterSpacingField =
tokensUpdateCreateModal.getByLabel(/Letter Spacing/i);
const lineHeightField = tokensUpdateCreateModal.getByLabel(/Line Height/i);
const textCaseField = tokensUpdateCreateModal.getByLabel(/Text Case/i);
const textDecorationField =
tokensUpdateCreateModal.getByLabel(/Text Decoration/i);
// Capture all values before switching tabs
const originalValues = {
fontSize: await fontSizeField.inputValue(),
fontFamily: await fontFamilyField.inputValue(),
fontWeight: await fontWeightField.inputValue(),
letterSpacing: await letterSpacingField.inputValue(),
lineHeight: await lineHeightField.inputValue(),
textCase: await textCaseField.inputValue(),
textDecoration: await textDecorationField.inputValue(),
};
// Switch to reference tab and back to composite tab
const referenceTabButton =
tokensUpdateCreateModal.getByTestId("reference-opt");
await referenceTabButton.click();
// Empty reference tab should be disabled
await expect(saveButton).toBeDisabled();
const compositeTabButton =
tokensUpdateCreateModal.getByTestId("composite-opt");
await compositeTabButton.click();
// Filled composite tab should be enabled
await expect(saveButton).toBeEnabled();
// Verify all values are preserved after switching tabs
await expect(fontSizeField).toHaveValue(originalValues.fontSize);
await expect(fontFamilyField).toHaveValue(originalValues.fontFamily);
await expect(fontWeightField).toHaveValue(originalValues.fontWeight);
await expect(letterSpacingField).toHaveValue(originalValues.letterSpacing);
await expect(lineHeightField).toHaveValue(originalValues.lineHeight);
await expect(textCaseField).toHaveValue(originalValues.textCase);
await expect(textDecorationField).toHaveValue(
originalValues.textDecoration,
);
await saveButton.click();
// Modal should close, token should be visible (with new name) in sidebar
await expect(tokensUpdateCreateModal).not.toBeVisible();
});
test("User cant submit empty typography token or reference", async ({
page,
}) => {
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
await setupTypographyTokensFile(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await tokensTabPanel
.getByRole("button", { name: "Add Token: Typography" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill("typography.empty");
const valueField = tokensUpdateCreateModal.getByLabel("Font Size");
// Insert a value and then delete it
await valueField.fill("1");
await valueField.fill("");
// Submit button should be disabled when field is empty
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await expect(submitButton).toBeDisabled();
// Switch to reference tab, should not be submittable either
const referenceTabButton =
tokensUpdateCreateModal.getByTestId("reference-opt");
await referenceTabButton.click();
await expect(submitButton).toBeDisabled();
});
test("User adds typography token with reference", async ({ page }) => {
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
await setupTypographyTokensFile(page);
const newTokenTitle = "NewReference";
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await tokensTabPanel
.getByRole("button", { name: "Add Token: Typography" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill(newTokenTitle);
const referenceTabButton = tokensUpdateCreateModal.getByRole("button", {
name: "Use a reference",
});
referenceTabButton.click();
const referenceField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Reference",
});
await referenceField.fill("{Full}");
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
const resolvedValue =
await tokensUpdateCreateModal.getByText("Resolved value:");
await expect(resolvedValue).toBeVisible();
await expect(resolvedValue).toContainText("Font Family: 42dot Sans");
await expect(resolvedValue).toContainText("Font Size: 100");
await expect(resolvedValue).toContainText("Font Weight: 300");
await expect(resolvedValue).toContainText("Letter Spacing: 2");
await expect(resolvedValue).toContainText("Text Case: uppercase");
await expect(resolvedValue).toContainText("Text Decoration: underline");
await expect(submitButton).toBeEnabled();
await submitButton.click();
await expect(tokensUpdateCreateModal).not.toBeVisible();
const newToken = tokensSidebar.getByRole("button", {
name: newTokenTitle,
});
await expect(newToken).toBeVisible();
});
test("User adds shadow token with multiple shadows and applies it to shape", async ({
page,
}) => {
@@ -601,4 +484,219 @@ test.describe("Tokens: Apply token", () => {
await expect(shadowSection).toHaveCount(2);
});
});
test("User applies dimension token to a shape on width and height", async ({
page,
}) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
// Unfolds dimensions on token panel
await page.getByRole("tab", { name: "Layers" }).click();
await workspacePage.layers.getByTestId("layer-row").nth(1).click();
const tokensTabButton = page.getByRole("tab", { name: "Tokens" });
await tokensTabButton.click();
unfoldTokenTree(tokensSidebar, "dimensions", "dimension.dimension.sm");
// Apply token to width and height token from token panel
await tokensSidebar.getByRole("button", { name: "dimension.sm" }).click();
// Check if measures sections is visible on right sidebar
const measuresSection = page.getByRole("region", {
name: "shape-measures-section",
});
await expect(measuresSection).toBeVisible();
// Check if token pill is visible on design tab on right sidebar
const dimensionSMTokenPill = measuresSection.getByRole("button", {
name: "dimension.sm",
});
await expect(dimensionSMTokenPill).toHaveCount(2);
await dimensionSMTokenPill.nth(1).click();
// Change token from dropdown
const dimensionTokenOptionXl = measuresSection.getByLabel("dimension.xl");
await expect(dimensionTokenOptionXl).toBeVisible();
await dimensionTokenOptionXl.click();
await expect(dimensionSMTokenPill).toHaveCount(1);
const dimensionXLTokenPill = measuresSection.getByRole("button", {
name: "dimension.xl",
});
await expect(dimensionXLTokenPill).toBeVisible();
// Detach token from design tab on right sidebar
const detachButton = measuresSection.getByRole("button", {
name: "Detach token",
});
await detachButton.nth(1).click();
await expect(dimensionXLTokenPill).not.toBeVisible();
});
test("User applies dimension token to a shape on x position", async ({
page,
}) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
// Unfolds dimensions on token panel
await page.getByRole("tab", { name: "Layers" }).click();
await workspacePage.layers.getByTestId("layer-row").nth(1).click();
const tokensTabButton = page.getByRole("tab", { name: "Tokens" });
await tokensTabButton.click();
unfoldTokenTree(tokensSidebar, "dimensions", "dimension.dimension.sm");
// Apply token to width and height token from token panel
await tokensSidebar
.getByRole("button", { name: "dimension.sm" })
.click({ button: "right" });
await tokenContextMenuForToken.getByText("AxisX").click();
// Check if measures sections is visible on right sidebar
const measuresSection = page.getByRole("region", {
name: "shape-measures-section",
});
await expect(measuresSection).toBeVisible();
// Check if token pill is visible on design tab on right sidebar
const dimensionSMTokenPill = measuresSection.getByRole("button", {
name: "dimension.sm",
});
await expect(dimensionSMTokenPill).toBeVisible();
await dimensionSMTokenPill.click();
// Change token from dropdown
const dimensionTokenOptionXl = measuresSection.getByLabel("dimension.xl");
await expect(dimensionTokenOptionXl).toBeVisible();
await dimensionTokenOptionXl.click();
await expect(dimensionSMTokenPill).not.toBeVisible();
const dimensionXLTokenPill = measuresSection.getByRole("button", {
name: "dimension.xl",
});
await expect(dimensionXLTokenPill).toBeVisible();
// Detach token from design tab on right sidebar
const detachButton = measuresSection.getByRole("button", {
name: "Detach token",
});
await detachButton.nth(0).click();
await expect(dimensionXLTokenPill).not.toBeVisible();
});
test("User applies dimension token to a shape on y position", async ({
page,
}) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
// Unfolds dimensions on token panel
await page.getByRole("tab", { name: "Layers" }).click();
await workspacePage.layers.getByTestId("layer-row").nth(1).click();
const tokensTabButton = page.getByRole("tab", { name: "Tokens" });
await tokensTabButton.click();
unfoldTokenTree(tokensSidebar, "dimensions", "dimension.dimension.sm");
// Apply token to width and height token from token panel
await tokensSidebar
.getByRole("button", { name: "dimension.sm" })
.click({ button: "right" });
await tokenContextMenuForToken.getByText("Y").click();
// Check if measures sections is visible on right sidebar
const measuresSection = page.getByRole("region", {
name: "shape-measures-section",
});
await expect(measuresSection).toBeVisible();
// Check if token pill is visible on design tab on right sidebar
const dimensionSMTokenPill = measuresSection.getByRole("button", {
name: "dimension.sm",
});
await expect(dimensionSMTokenPill).toBeVisible();
await dimensionSMTokenPill.click();
// Change token from dropdown
const dimensionTokenOptionXl = measuresSection.getByLabel("dimension.xl");
await expect(dimensionTokenOptionXl).toBeVisible();
await dimensionTokenOptionXl.click();
await expect(dimensionSMTokenPill).not.toBeVisible();
const dimensionXLTokenPill = measuresSection.getByRole("button", {
name: "dimension.xl",
});
await expect(dimensionXLTokenPill).toBeVisible();
// Detach token from design tab on right sidebar
const detachButton = measuresSection.getByRole("button", {
name: "Detach token",
});
await detachButton.nth(0).click();
await expect(dimensionXLTokenPill).not.toBeVisible();
});
test("User applies dimension token to a shape border-radius", async ({
page,
}) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
// Unfolds dimensions on token panel
await page.getByRole("tab", { name: "Layers" }).click();
await workspacePage.layers.getByTestId("layer-row").nth(2).click();
const tokensTabButton = page.getByRole("tab", { name: "Tokens" });
await tokensTabButton.click();
unfoldTokenTree(tokensSidebar, "dimensions", "dimension.dimension.xs");
// Apply token to width and height token from token panel
await tokensSidebar
.getByRole("button", { name: "dimension.xs" })
.click({ button: "right" });
await tokenContextMenuForToken.getByText("Border radius").hover();
await tokenContextMenuForToken.getByText("RadiusAll").click();
// Check if border radius sections is visible on right sidebar
const borderRadiusSection = page.getByRole("region", {
name: "border-radius-section",
});
await expect(borderRadiusSection).toBeVisible();
// Check if token pill is visible on design tab on right sidebar
const dimensionXSTokenPill = borderRadiusSection.getByRole("button", {
name: "dimension.xs",
});
await expect(dimensionXSTokenPill).toBeVisible();
await dimensionXSTokenPill.click();
// Change token from dropdown
const dimensionTokenOptionXl = borderRadiusSection.getByLabel("dimension.xl");
await expect(dimensionTokenOptionXl).toBeVisible();
await dimensionTokenOptionXl.click();
await expect(dimensionXSTokenPill).not.toBeVisible();
const dimensionXLTokenPill = borderRadiusSection.getByRole("button", {
name: "dimension.xl",
});
await expect(dimensionXLTokenPill).toBeVisible();
// Detach token from design tab on right sidebar
const detachButton = borderRadiusSection.getByRole("button", {
name: "Detach token",
});
await detachButton.nth(0).click();
await expect(dimensionXLTokenPill).not.toBeVisible();
});
});

View File

@@ -14,7 +14,7 @@ test.beforeEach(async ({ page }) => {
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-tokens.json");
});
test.describe("Tokens - CRUD", () => {
test.describe("Tokens - creation", () => {
test("User creates border radius token", async ({ page }) => {
await testTokenCreationFlow(page, {
tokenLabel: "Border Radius",
@@ -1008,6 +1008,41 @@ test.describe("Tokens - CRUD", () => {
).toBeEnabled();
});
test("User cant submit empty typography token or reference", async ({
page,
}) => {
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
await setupTypographyTokensFile(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await tokensTabPanel
.getByRole("button", { name: "Add Token: Typography" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill("typography.empty");
const valueField = tokensUpdateCreateModal.getByLabel("Font Size");
// Insert a value and then delete it
await valueField.fill("1");
await valueField.fill("");
// Submit button should be disabled when field is empty
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await expect(submitButton).toBeDisabled();
// Switch to reference tab, should not be submittable either
const referenceTabButton =
tokensUpdateCreateModal.getByTestId("reference-opt");
await referenceTabButton.click();
await expect(submitButton).toBeDisabled();
});
test("User creates typography token", async ({ page }) => {
const emptyNameError = "Name should be at least 1 character";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
@@ -1256,6 +1291,228 @@ test.describe("Tokens - CRUD", () => {
).toBeEnabled();
});
test("User adds typography token with reference", async ({ page }) => {
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
await setupTypographyTokensFile(page);
const newTokenTitle = "NewReference";
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await tokensTabPanel
.getByRole("button", { name: "Add Token: Typography" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill(newTokenTitle);
const referenceTabButton = tokensUpdateCreateModal.getByRole("button", {
name: "Use a reference",
});
referenceTabButton.click();
const referenceField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Reference",
});
await referenceField.fill("{Full}");
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
const resolvedValue =
await tokensUpdateCreateModal.getByText("Resolved value:");
await expect(resolvedValue).toBeVisible();
await expect(resolvedValue).toContainText("Font Family: 42dot Sans");
await expect(resolvedValue).toContainText("Font Size: 100");
await expect(resolvedValue).toContainText("Font Weight: 300");
await expect(resolvedValue).toContainText("Letter Spacing: 2");
await expect(resolvedValue).toContainText("Text Case: uppercase");
await expect(resolvedValue).toContainText("Text Decoration: underline");
await expect(submitButton).toBeEnabled();
await submitButton.click();
await expect(tokensUpdateCreateModal).not.toBeVisible();
const newToken = tokensSidebar.getByRole("button", {
name: newTokenTitle,
});
await expect(newToken).toBeVisible();
});
test("User creates grouped color token", async ({ page }) => {
const { workspacePage, tokensUpdateCreateModal, tokensSidebar } =
await setupEmptyTokensFile(page);
await tokensSidebar
.getByRole("button", { name: "Add Token: Color" })
.click();
// Create grouped color token with mouse
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
const valueField = tokensUpdateCreateModal.getByLabel("Value");
await nameField.click();
await nameField.fill("dark.primary");
await valueField.click();
await valueField.fill("red");
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await expect(submitButton).toBeEnabled();
await submitButton.click();
await unfoldTokenTree(tokensSidebar, "color", "dark.primary");
await expect(tokensSidebar.getByLabel("primary")).toBeEnabled();
});
test("User cant create regular token with value missing", async ({
page,
}) => {
const { tokensUpdateCreateModal } = await setupEmptyTokensFile(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await tokensTabPanel
.getByRole("button", { name: "Add Token: Color" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
// Initially submit button should be disabled
await expect(submitButton).toBeDisabled();
// Fill in name but leave value empty
await nameField.click();
await nameField.fill("primary");
// Submit button should remain disabled when value is empty
await expect(submitButton).toBeDisabled();
});
test("User duplicate color token", async ({ page }) => {
const { tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
await expect(tokensSidebar).toBeVisible();
unfoldTokenTree(tokensSidebar, "color", "colors.blue.100");
const colorToken = tokensSidebar.getByRole("button", {
name: "100",
});
await colorToken.click({ button: "right" });
await expect(tokenContextMenuForToken).toBeVisible();
await tokenContextMenuForToken.getByText("Duplicate token").click();
await expect(tokenContextMenuForToken).not.toBeVisible();
await expect(
tokensSidebar.getByRole("button", { name: "colors.blue.100-copy" }),
).toBeVisible();
});
});
test("User creates grouped color token", async ({ page }) => {
const { workspacePage, tokensUpdateCreateModal, tokensSidebar } =
await setupEmptyTokensFile(page);
await tokensSidebar
.getByRole("button", { name: "Add Token: Color" })
.click();
// Create grouped color token with mouse
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
const valueField = tokensUpdateCreateModal.getByLabel("Value");
await nameField.click();
await nameField.fill("dark.primary");
await valueField.click();
await valueField.fill("red");
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await expect(submitButton).toBeEnabled();
await submitButton.click();
await unfoldTokenTree(tokensSidebar, "color", "dark.primary");
await expect(tokensSidebar.getByLabel("primary")).toBeEnabled();
});
test("User cant create regular token with value missing", async ({
page,
}) => {
const { tokensUpdateCreateModal } = await setupEmptyTokensFile(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await tokensTabPanel
.getByRole("button", { name: "Add Token: Color" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
// Initially submit button should be disabled
await expect(submitButton).toBeDisabled();
// Fill in name but leave value empty
await nameField.click();
await nameField.fill("primary");
// Submit button should remain disabled when value is empty
await expect(submitButton).toBeDisabled();
});
test("User duplicate color token", async ({ page }) => {
const { tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
await expect(tokensSidebar).toBeVisible();
unfoldTokenTree(tokensSidebar, "color", "colors.blue.100");
const colorToken = tokensSidebar.getByRole("button", {
name: "100",
});
await colorToken.click({ button: "right" });
await expect(tokenContextMenuForToken).toBeVisible();
await tokenContextMenuForToken.getByText("Duplicate token").click();
await expect(tokenContextMenuForToken).not.toBeVisible();
await expect(
tokensSidebar.getByRole("button", { name: "colors.blue.100-copy" }),
).toBeVisible();
});
test.describe("Tokens tab - edition", () => {
test("User edits typography token and all fields are valid", async ({
page,
}) => {
@@ -1388,67 +1645,7 @@ test.describe("Tokens - CRUD", () => {
await expect(colorTokenChanged).toBeVisible();
});
test("User creates grouped color token", async ({ page }) => {
const { workspacePage, tokensUpdateCreateModal, tokensSidebar } =
await setupEmptyTokensFile(page);
await tokensSidebar
.getByRole("button", { name: "Add Token: Color" })
.click();
// Create grouped color token with mouse
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
const valueField = tokensUpdateCreateModal.getByLabel("Value");
await nameField.click();
await nameField.fill("dark.primary");
await valueField.click();
await valueField.fill("red");
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await expect(submitButton).toBeEnabled();
await submitButton.click();
await unfoldTokenTree(tokensSidebar, "color", "dark.primary");
await expect(tokensSidebar.getByLabel("primary")).toBeEnabled();
});
test("User cant create regular token with value missing", async ({
page,
}) => {
const { tokensUpdateCreateModal } = await setupEmptyTokensFile(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await tokensTabPanel
.getByRole("button", { name: "Add Token: Color" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
// Initially submit button should be disabled
await expect(submitButton).toBeDisabled();
// Fill in name but leave value empty
await nameField.click();
await nameField.fill("primary");
// Submit button should remain disabled when value is empty
await expect(submitButton).toBeDisabled();
});
test("User changes color token color while keeping custom color space", async ({
test("User edits color token color while keeping custom color space", async ({
page,
}) => {
const { workspacePage, tokensUpdateCreateModal, tokenThemesSetsSidebar } =
@@ -1502,30 +1699,9 @@ test.describe("Tokens - CRUD", () => {
await valueSaturationSelector.click({ position: { x: 0, y: 0 } });
await expect(valueField).toHaveValue(/^rgba(.*)$/);
});
});
test("User duplicate color token", async ({ page }) => {
const { tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
await expect(tokensSidebar).toBeVisible();
unfoldTokenTree(tokensSidebar, "color", "colors.blue.100");
const colorToken = tokensSidebar.getByRole("button", {
name: "100",
});
await colorToken.click({ button: "right" });
await expect(tokenContextMenuForToken).toBeVisible();
await tokenContextMenuForToken.getByText("Duplicate token").click();
await expect(tokenContextMenuForToken).not.toBeVisible();
await expect(
tokensSidebar.getByRole("button", { name: "colors.blue.100-copy" }),
).toBeVisible();
});
test.describe("Tokens tab - delete", () => {
test("User delete color token", async ({ page }) => {
const { tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
@@ -1546,4 +1722,40 @@ test.describe("Tokens - CRUD", () => {
await expect(tokenContextMenuForToken).not.toBeVisible();
await expect(colorToken).not.toBeVisible();
});
test("User removes node and all child tokens", async ({ page }) => {
const { tokensSidebar, workspacePage } = await setupTokensFile(page);
await expect(tokensSidebar).toBeVisible();
// Expand color tokens
unfoldTokenTree(tokensSidebar, "color", "colors.blue.100");
// Verify that the node and child token are visible before deletion
const colorNode = tokensSidebar.getByRole("button", {
name: "blue",
exact: true,
});
const colorNodeToken = tokensSidebar.getByRole("button", {
name: "100",
});
// Select a node and right click on it to open context menu
await expect(colorNode).toBeVisible();
await expect(colorNodeToken).toBeVisible();
await colorNode.click({ button: "right" });
// select "Delete" from the context menu
const deleteNodeButton = page.getByRole("button", {
name: "Delete",
exact: true,
});
await expect(deleteNodeButton).toBeVisible();
await deleteNodeButton.click();
// Verify that the node is removed
await expect(colorNode).not.toBeVisible();
// Verify that child token is also removed
await expect(colorNodeToken).not.toBeVisible();
});
});

View File

@@ -216,32 +216,4 @@ test.describe("Tokens: Sets Tab", () => {
await expect(tokenSetItems.nth(1)).toHaveAttribute("aria-checked", "false");
await expect(tokenSetItems.nth(2)).toHaveAttribute("aria-checked", "true");
});
test("Display active set and verify if is enabled", async ({ page }) => {
const { tokenThemesSetsSidebar, tokensSidebar, tokenSetItems } =
await setupTokensFile(page);
// Create set
await tokenThemesSetsSidebar
.getByRole("button", { name: "Add set" })
.click();
await changeSetInput(tokenThemesSetsSidebar, "Inactive set");
await tokenThemesSetsSidebar
.getByRole("button", { name: "Inactive set" })
.click();
let activeSetTitle = await tokensSidebar.getByTestId(
"active-token-set-title",
);
await expect(activeSetTitle).toHaveText("TOKENS - Inactive set");
const inactiveSetInfo = await tokensSidebar.getByTitle(
"This set is not active.",
);
await expect(inactiveSetInfo).toBeVisible();
// Switch active set
await tokenThemesSetsSidebar.getByRole("button", { name: "theme" }).click();
await expect(activeSetTitle).toHaveText("TOKENS - theme");
await expect(inactiveSetInfo).not.toBeVisible();
});
});

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

View File

@@ -23,7 +23,7 @@
<body>
<canvas id="canvas"></canvas>
<script type="module">
import initWasmModule from '/js/render_wasm.js';
import initWasmModule from '/js/render-wasm.js';
import {
init, addShapeSolidFill, assignCanvas, hexToU32ARGB, getRandomInt, getRandomColor,
getRandomFloat, useShape, setShapeChildren, setupInteraction, addShapeSolidStrokeFill,

View File

@@ -23,7 +23,7 @@
<body>
<canvas id="canvas"></canvas>
<script type="module">
import initWasmModule from '/js/render_wasm.js';
import initWasmModule from '/js/render-wasm.js';
import {
init, addShapeSolidFill, assignCanvas, hexToU32ARGB, getRandomInt, getRandomColor,
getRandomFloat, useShape, setShapeChildren, setupInteraction, set_parent

View File

@@ -23,7 +23,7 @@
<body>
<canvas id="canvas"></canvas>
<script type="module">
import initWasmModule from '/js/render_wasm.js';
import initWasmModule from '/js/render-wasm.js';
import {
init, addShapeSolidFill, assignCanvas, hexToU32ARGB, getRandomInt, getRandomColor,
getRandomFloat, useShape, setShapeChildren, setupInteraction, set_parent, draw_star,

View File

@@ -23,7 +23,7 @@
<body>
<canvas id="canvas"></canvas>
<script type="module">
import initWasmModule from '/js/render_wasm.js';
import initWasmModule from '/js/render-wasm.js';
import {
init, addShapeSolidFill, assignCanvas, hexToU32ARGB, getRandomInt, getRandomColor,
getRandomFloat, useShape, setShapeChildren, setupInteraction, set_parent, allocBytes,

View File

@@ -23,7 +23,7 @@
<body>
<canvas id="canvas"></canvas>
<script type="module">
import initWasmModule from '/js/render_wasm.js';
import initWasmModule from '/js/render-wasm.js';
import {
init, addShapeSolidFill, assignCanvas, hexToU32ARGB, getRandomInt, getRandomColor,
getRandomFloat, useShape, setShapeChildren, setupInteraction, addShapeSolidStrokeFill

View File

@@ -23,7 +23,7 @@
<body>
<canvas id="canvas"></canvas>
<script type="module">
import initWasmModule from '/js/render_wasm.js';
import initWasmModule from '/js/render-wasm.js';
import {
init, assignCanvas, setupInteraction, useShape, setShapeChildren, addTextShape, hexToU32ARGB,getRandomInt, getRandomColor, getRandomFloat, addShapeSolidFill, addShapeSolidStrokeFill
} from './js/lib.js';
@@ -102,4 +102,4 @@
});
</script>
</body>
</html>
</html>

View File

@@ -75,6 +75,7 @@
{:fn-invoke-direct true
:optimizations #shadow/env ["PENPOT_BUILD_OPTIMIZATIONS" :as :keyword :default :advanced]
:source-map true
:pseudo-names true
:elide-asserts true
:anon-fn-naming-policy :off
:cross-chunk-method-motion false

View File

@@ -15,7 +15,6 @@
[app.common.time :as ct]
[app.common.types.project :refer [valid-project?]]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.constants :as mconst]
[app.main.data.common :as dcm]
[app.main.data.event :as ev]
@@ -684,25 +683,12 @@
(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 (and (contains? cf/flags :nitrate)
(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

@@ -173,7 +173,8 @@
params (assoc params :team-id team-id)]
(->> (rp/cmd! :delete-team-member params)
(rx/mapcat (fn [_]
(rx/of (fetch-members team-id)
(rx/of (dp/refresh-profile)
(fetch-members team-id)
(fetch-teams)
(ptk/data-event ::ev/event
{::ev/name "delete-team-member"

View File

@@ -191,16 +191,6 @@
(when (:fill attributes) (update-fill value shape-ids attributes page-id))
(when (:stroke-color attributes) (update-stroke-color value shape-ids attributes page-id)))))))
(defn update-shape-dimensions
([value shape-ids attributes] (update-shape-dimensions value shape-ids attributes nil))
([value shape-ids attributes page-id]
(ptk/reify ::update-shape-dimensions
ptk/WatchEvent
(watch [_ _ _]
(when (number? value)
(rx/of
(when (:width attributes) (dwtr/update-dimensions shape-ids :width value {:ignore-touched true :page-id page-id}))
(when (:height attributes) (dwtr/update-dimensions shape-ids :height value {:ignore-touched true :page-id page-id}))))))))
(defn- attributes->layout-gap [attributes value]
(let [layout-gap (-> (set/intersection attributes #{:column-gap :row-gap})
@@ -248,21 +238,6 @@
{:ignore-touched true
:page-id page-id}))))))))
(defn update-layout-spacing
([value shape-ids attributes] (update-layout-spacing value shape-ids attributes nil))
([value shape-ids attributes page-id]
(ptk/reify ::update-layout-spacing
ptk/WatchEvent
(watch [_ state _]
(when (number? value)
(let [ids-with-layout (shape-ids-with-layout state (or page-id (:current-page-id state)) shape-ids)
layout-attributes (attributes->layout-gap attributes value)]
(rx/of
(dwsl/update-layout ids-with-layout
layout-attributes
{:ignore-touched true
:page-id page-id}))))))))
(defn update-shape-position
([value shape-ids attributes] (update-shape-position value shape-ids attributes nil))
([value shape-ids attributes page-id]
@@ -276,6 +251,20 @@
{:ignore-touched true
:page-id page-id})))))))))
(defn update-layout-gap
[value shape-ids attributes page-id]
(ptk/reify ::update-layout-gao
ptk/WatchEvent
(watch [_ state _]
(when (number? value)
(let [ids-with-layout (shape-ids-with-layout state (or page-id (:current-page-id state)) shape-ids)
layout-attributes (attributes->layout-gap attributes value)]
(rx/of
(dwsl/update-layout ids-with-layout
layout-attributes
{:ignore-touched true
:page-id page-id})))))))
(defn update-layout-sizing-limits
([value shape-ids attributes] (update-layout-sizing-limits value shape-ids attributes nil))
([value shape-ids attributes page-id]
@@ -470,20 +459,126 @@
value
[shape-ids attributes page-id])))))
(defn update-typography-interactive
([value shape-ids attributes] (update-typography value shape-ids attributes nil))
(defn update-shape-dimensions
([value shape-ids attributes] (update-shape-dimensions value shape-ids attributes nil))
([value shape-ids attributes page-id]
(when (map? value)
(rx/merge
(apply-functions-map
{:font-size update-font-size
:font-family update-font-family-interactive
:font-weight update-font-weight-interactive
:letter-spacing update-letter-spacing
:text-case update-text-case
:text-decoration update-text-decoration-interactive}
value
[shape-ids attributes page-id])))))
(ptk/reify ::update-shape-dimensions
ptk/WatchEvent
(watch [_ _ _]
(when (number? value)
(rx/of
(when (:width attributes) (dwtr/update-dimensions shape-ids :width value {:ignore-touched true :page-id page-id}))
(when (:height attributes) (dwtr/update-dimensions shape-ids :height value {:ignore-touched true :page-id page-id}))))))))
(defn- attributes->actions
[{:keys [value shape-ids attributes page-id]}]
(cond-> []
(some attributes #{:width :height})
(conj #(update-shape-dimensions
value shape-ids
(set (filter attributes #{:width :height}))
page-id))
(some attributes #{:x :y})
(conj #(update-shape-position
value shape-ids
(set (filter attributes #{:x :y}))
page-id))
(some attributes #{:p1 :p2 :p3 :p4})
(conj #(update-layout-padding
value shape-ids
(set (filter attributes #{:p1 :p2 :p3 :p4}))
page-id))
(some attributes #{:m1 :m2 :m3 :m4})
(conj #(update-layout-item-margin
value shape-ids
(set (filter attributes #{:m1 :m2 :m3 :m4}))
page-id))
(some attributes #{:row-gap :column-gap})
(conj #(update-layout-gap
value shape-ids
(set (filter attributes #{:row-gap :column-gap}))
page-id))
(some attributes #{:r1 :r2 :r3 :r4})
(conj #(if (= attributes #{:r1 :r2 :r3 :r4})
(update-shape-radius-all value shape-ids attributes page-id)
(update-shape-radius-for-corners
value shape-ids
(set (filter attributes #{:r1 :r2 :r3 :r4}))
page-id)))
(some attributes #{:strole-width})
(conj #(update-stroke-width
value shape-ids
#{:strole-width}
page-id))
(some attributes #{:max-width :max-height})
(conj #(update-layout-sizing-limits
value shape-ids
(set (filter attributes #{:max-width :max-height}))
page-id))))
(defn use-dimensions-token
([value shape-ids attributes] (use-dimensions-token value shape-ids attributes nil))
([value shape-ids attributes page-id]
(ptk/reify ::use-dimensions-token
ptk/WatchEvent
(watch [_ state _]
(when (number? value)
(let [actions (attributes->actions
{:value value
:shape-ids shape-ids
:attributes attributes
:page-id page-id
:state state})]
(apply rx/of (map #(%) actions))))))))
(defn use-spacing-token
([value shape-ids attributes] (use-spacing-token value shape-ids attributes nil))
([value shape-ids attributes page-id]
(ptk/reify ::use-spacing-token
ptk/WatchEvent
(watch [_ state _]
(let [spacing-attrs
#{:row-gap :column-gap
:m1 :m2 :m3 :m4
:p1 :p2 :p3 :p4}]
(when (and (number? value)
(set? attributes)
(set/subset? attributes spacing-attrs))
(let [actions (attributes->actions
{:value value
:shape-ids shape-ids
:attributes attributes
:page-id page-id
:state state})]
(apply rx/of (map #(%) actions)))))))))
(defn use-sizing-token
([value shape-ids attributes] (use-sizing-token value shape-ids attributes nil))
([value shape-ids attributes page-id]
(ptk/reify ::use-sizing-token
ptk/WatchEvent
(watch [_ state _]
(let [sizing-attrs
#{:width :height
:max-width :max-height}]
(when (and (number? value)
(set? attributes)
(set/subset? attributes sizing-attrs))
(let [actions (attributes->actions
{:value value
:shape-ids shape-ids
:attributes attributes
:page-id page-id
:state state})]
(apply rx/of (map #(%) actions)))))))))
;; Events to apply / unapply tokens to shapes ------------------------------------------------------------
@@ -623,54 +718,19 @@
:token token
:shape-ids shape-ids}))
(rx/of
(case (:type token)
:spacing
(cond
(and (= (:type token) :spacing)
(nil? attrs))
(apply-spacing-token {:token token
:attr attrs
:shapes shapes})
:else
(apply-token {:attributes (if (empty? attrs) attributes attrs)
:token token
:shape-ids shape-ids
:on-update-shape on-update-shape}))))))))
(defn toggle-border-radius-token
[{:keys [token attrs shape-ids expand-with-children]}]
(ptk/reify ::on-toggle-border-radius-token
ptk/WatchEvent
(watch [_ state _]
(let [objects (dsh/lookup-page-objects state)
shapes (into [] (keep (d/getf objects)) shape-ids)
shapes
(if expand-with-children
(into []
(mapcat (fn [shape]
(if (= (:type shape) :group)
(keep objects (:shapes shape))
[shape])))
shapes)
shapes)
{:keys [attributes all-attributes]}
(get token-properties (:type token))
unapply-tokens?
(cft/shapes-token-applied? token shapes (or attrs all-attributes attributes))
shape-ids (map :id shapes)]
(if unapply-tokens?
(rx/of
(unapply-token {:attributes (or attrs all-attributes attributes)
:token token
:shape-ids shape-ids}))
(rx/of
(apply-token {:attributes attrs
:token token
:shape-ids shape-ids
:on-update-shape update-shape-radius-for-corners})))))))
(defn apply-token-on-selected
[color-operations token]
(ptk/reify ::apply-token-on-selected
@@ -800,7 +860,7 @@
{:title "Sizing"
:attributes #{:width :height}
:all-attributes ctt/sizing-keys
:on-update-shape update-shape-dimensions
:on-update-shape use-sizing-token
:modal {:key :tokens/sizing
:fields [{:label "Sizing"
:key :sizing}]}}
@@ -813,7 +873,7 @@
ctt/border-radius-keys
ctt/axis-keys
ctt/stroke-width-keys)
:on-update-shape update-shape-dimensions
:on-update-shape use-dimensions-token
:modal {:key :tokens/dimensions
:fields [{:label "Dimensions"
:key :dimensions}]}}
@@ -846,7 +906,7 @@
{:title "Spacing"
:attributes #{:column-gap :row-gap}
:all-attributes ctt/spacing-keys
:on-update-shape update-layout-spacing
:on-update-shape use-spacing-token
:modal {:key :tokens/spacing
:fields [{:label "Spacing"
:key :spacing}]}}))

View File

@@ -433,11 +433,22 @@
ptk/WatchEvent
(watch [it state _]
(let [data (dsh/lookup-file-data state)
changes (-> (pcb/empty-changes it)
(pcb/with-library-data data)
(pcb/set-token set-id token-id nil))]
(rx/of (dch/commit-changes changes))))))
(defn bulk-delete-tokens
[set-id token-ids]
(dm/assert! (uuid? set-id))
(dm/assert! (every? uuid? token-ids))
(ptk/reify ::bulk-delete-tokens
ptk/WatchEvent
(watch [_ _ _]
(apply rx/of
(map #(delete-token set-id %) token-ids)))))
(defn duplicate-token
[token-id]
(dm/assert! (uuid? token-id))
@@ -505,6 +516,19 @@
(update state :workspace-tokens assoc :token-context-menu params)
(update state :workspace-tokens dissoc :token-context-menu)))))
(defn assign-token-node-context-menu
[{:keys [position] :as params}]
(when params
(assert (gpt/point? position) "expected a point instance for `position` param"))
(ptk/reify ::show-token-node-context-menu
ptk/UpdateEvent
(update [_ state]
(if params
(update state :workspace-tokens assoc :token-node-context-menu params)
(update state :workspace-tokens dissoc :token-node-context-menu)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TOKEN-SET UI OPS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@@ -54,7 +54,7 @@
{ctt/border-radius-keys dwta/update-shape-radius-for-corners
ctt/color-keys dwta/update-fill-stroke
ctt/stroke-width-keys dwta/update-stroke-width
ctt/sizing-keys dwta/update-shape-dimensions
ctt/sizing-keys dwta/use-dimensions-token
ctt/opacity-keys dwta/update-opacity
ctt/rotation-keys dwta/update-rotation
@@ -73,8 +73,8 @@
#{:x :y} dwta/update-shape-position
#{:p1 :p2 :p3 :p4} dwta/update-layout-padding
#{:m1 :m2 :m3 :m4} dwta/update-layout-item-margin
#{:column-gap :row-gap} dwta/update-layout-spacing
#{:width :height} dwta/update-shape-dimensions
#{:column-gap :row-gap} dwta/update-layout-gap
#{:width :height} dwta/use-dimensions-token
#{:layout-item-min-w :layout-item-min-h :layout-item-max-w :layout-item-max-h} dwta/update-layout-sizing-limits})
(def ^:private attribute-actions-map

View File

@@ -1,46 +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.dashboard.nitrate-form
(:require-macros [app.main.style :as stl])
(:require
[app.main.data.modal :as modal]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.icons :as deprecated-icon]
[app.util.dom :as dom]
[rumext.v2 :as mf]))
(mf/defc nitrate-form-modal
{::mf/register modal/components
::mf/register-as :nitrate-form}
[]
(let [on-click
(mf/use-fn
(fn []
(dom/open-new-window "/control-center/licences/start")))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
[:div {:class (stl/css :nitrate-form)}
[:div {:class (stl/css :modal-header)}
[:h2 {:class (stl/css :modal-title)}
"BUY NITRATE"]
[:button {:class (stl/css :modal-close-btn)
:on-click modal/hide!} deprecated-icon/close]]
[:div {:class (stl/css :modal-content)}
"Nitrate is so cool! You should buy it!"]
[:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons)}
[:> button* {:variant "primary"
:on-click on-click}
"BUY NOW!"]]]]]]))

View File

@@ -1,52 +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 "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend .modal-overlay-base;
}
.modal-container {
@extend .modal-container-base;
}
.modal-header {
margin-bottom: deprecated.$s-24;
}
.modal-title {
@include deprecated.uppercaseTitleTipography;
color: var(--modal-title-foreground-color);
}
.modal-close-btn {
@extend .modal-close-btn-base;
}
.modal-content {
margin-bottom: deprecated.$s-24;
}
.nitrate-form {
min-width: deprecated.$s-400;
}
.action-buttons {
@extend .modal-action-btns;
}
.cancel-button {
@extend .modal-cancel-btn;
}
.accept-btn {
@extend .modal-accept-btn;
&.danger {
@extend .modal-danger-btn;
}
}

View File

@@ -26,7 +26,6 @@
[app.main.ui.components.link :refer [link]]
[app.main.ui.dashboard.comments :refer [comments-icon* comments-section]]
[app.main.ui.dashboard.inline-edition :refer [inline-edition]]
[app.main.ui.dashboard.nitrate-form]
[app.main.ui.dashboard.project-menu :refer [project-menu*]]
[app.main.ui.dashboard.subscription :refer [dashboard-cta*
get-subscription-type
@@ -281,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
@@ -291,27 +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 []
(if (:nitrate-licence profile)
;; TODO update when org creation route is ready
(dom/open-new-window "/control-center/org/create")
(st/emit! (modal/show :nitrate-form {})))))]
(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
@@ -332,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}
@@ -494,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)
@@ -675,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
@@ -795,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

@@ -216,7 +216,7 @@
is-selected-on-focus nillable
tokens applied-token empty-to-end
on-change on-blur on-focus on-detach
property align ref]
property align ref name]
:rest props}]
(let [;; NOTE: we use mfu/bean here for transparently handle
@@ -662,7 +662,10 @@
label (get token :name)
token-value (or (get token :resolved-value)
(or (mf/ref-val last-value*)
(fmt/format-number value)))]
(fmt/format-number value)))
token-value (if (= name :opacity)
(* 100 token-value)
token-value)]
(mf/spread-props props
{:id id
:label label

View File

@@ -7,6 +7,7 @@
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/typography.scss" as *;
@use "ds/_utils.scss" as *;
.option-list {
--options-dropdown-icon-fg-color: var(--color-foreground-secondary);
@@ -15,32 +16,32 @@
--options-dropdown-border-color: var(--color-background-quaternary);
position: absolute;
top: $sz-36;
width: var(--dropdown-width, 100%);
inset-block-start: $sz-36;
inline-size: var(--dropdown-width, 100%);
transform: translateX(var(--dropdown-translate-distance, 0));
background-color: var(--options-dropdown-bg-color);
border-radius: $br-8;
border: $b-1 solid var(--options-dropdown-border-color);
padding-block: var(--sp-xs);
margin-block-end: 0;
max-height: $sz-400;
max-block-size: $sz-400;
overflow-y: auto;
overflow-x: hidden;
z-index: var(--z-index-dropdown);
}
.left-align {
left: var(--dropdown-offset, 0);
inset-inline-start: var(--dropdown-offset, 0);
}
.right-align {
right: var(--dropdown-offset, 0);
inset-inline-end: var(--dropdown-offset, 0);
}
.option-separator {
border: $b-1 solid var(--options-dropdown-border-color);
margin-top: var(--sp-xs);
margin-bottom: var(--sp-xs);
margin-block-start: var(--sp-xs);
margin-block-end: var(--sp-xs);
}
.group-option,
@@ -51,11 +52,11 @@
gap: var(--sp-xs);
color: var(--color-foreground-secondary);
padding-inline: var(--sp-s);
height: var(--sp-xxxl);
block-size: var(--sp-xxxl);
}
.option-empty {
justify-content: center;
text-align: center;
padding: 0 40px;
padding: 0 px2rem(40);
}

View File

@@ -19,17 +19,19 @@
[:expandable {:optional true} :boolean]
[:expanded {:optional true} :boolean]
[:icon {:optional true} :string]
[:on-toggle-expand fn?]])
[:on-toggle-expand {:optional true} fn?]
[:on-context-menu {:optional true} fn?]])
(mf/defc layer-button*
{::mf/schema schema:layer-button}
[{:keys [label description class is-expandable expanded icon on-toggle-expand children] :rest props}]
[{:keys [label description class is-expandable expanded icon on-toggle-expand on-context-menu 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})]
:on-click on-toggle-expand
:on-context-menu on-context-menu})]
[:div {:class (stl/css :layer-button-wrapper)}
[:> "button" button-props
[:div {:class (stl/css :layer-button-content)}

View File

@@ -10,6 +10,7 @@ $z-index-200: 200;
$z-index-300: 300;
$z-index-400: 400;
$z-index-500: 500;
$z-index-600: 600;
:global(:root) {
--z-index-auto: #{$z-index-auto}; // Index for elements such as workspace, rulers ...
@@ -18,4 +19,5 @@ $z-index-500: 500;
--z-index-set: #{$z-index-300}; // Index for configuration elements like modals, color picker, grid edition elements
--z-index-dropdown: #{$z-index-400}; // Index for dropdown like elements, selects, menus, dropdowns
--z-index-notifications: #{$z-index-500}; // Index for notification
--z-index-loaders: #{$z-index-600}; // Index for loaders
}

View File

@@ -31,6 +31,7 @@
[app.main.ui.releases.v2-10]
[app.main.ui.releases.v2-11]
[app.main.ui.releases.v2-12]
[app.main.ui.releases.v2-13]
[app.main.ui.releases.v2-2]
[app.main.ui.releases.v2-3]
[app.main.ui.releases.v2-4]
@@ -103,4 +104,4 @@
(defmethod rc/render-release-notes "0.0"
[params]
(rc/render-release-notes (assoc params :version "2.12")))
(rc/render-release-notes (assoc params :version "2.13")))

View File

@@ -0,0 +1,118 @@
;; 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.releases.v2-13
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
(defmethod c/render-release-notes "2.13"
[{:keys [slide klass next finish navigate version]}]
(mf/html
(case slide
:start
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.13-slide-0.jpg"
:class (stl/css :start-image)
:border "0"
:alt "Penpot 2.13 is here!"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"Whats new in Penpot?"]
[:div {:class (stl/css :version-tag)}
(dm/str "Version " version)]]
[:div {:class (stl/css :features-block)}
[:span {:class (stl/css :feature-title)}
"The first release of the year, and were just getting started 🚀"]
[:p {:class (stl/css :feature-content)}
"This is our first release of the year, and it sets the tone for whats coming next. Were kicking off an exciting year where well take Penpot to a whole new level, with improved performance, stronger design system foundations, long-requested features, and new capabilities that unlock better workflows for teams."]
[:p {:class (stl/css :feature-content)}
"This release brings two highlights the community has been asking for, along with solid improvements under the hood to keep everything fast and smooth."]
[:p {:class (stl/css :feature-content)}
"Lets dive in!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.13-trash.gif"
:class (stl/css :start-image)
:border "0"
:alt "The Trash"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"The Trash"]]
[:div {:class (stl/css :feature)}
[:p {:class (stl/css :feature-content)}
"Deleting a file no longer means its gone forever. Introducing The Trash, a dedicated space in the dashboard where deleted files and projects live before being permanently removed."]
[:p {:class (stl/css :feature-content)}
"From here, you can recover content deleted by mistake or clean things up for good when youre sure you dont need them anymore. The Trash works for both files and projects, and items are automatically removed after a period of time depending on your Penpot plan."]
[:p {:class (stl/css :feature-content)}
"Highly requested, long overdue, and now officially here."]]
[:div {:class (stl/css :navigation)}
[:& c/navigation-bullets
{:slide slide
:navigate navigate
:total 3}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.13-shadow-tokens.gif"
:class (stl/css :start-image)
:border "0"
:alt "Shadow tokens: Reusable shadows, at last!"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"Shadow tokens: Reusable shadows, at last!"]]
[:div {:class (stl/css :feature)}
[:p {:class (stl/css :feature-content)}
"With Shadow tokens, were introducing our second composite token, right after Typography tokens. This is a big step forward for design systems in Penpot."]
[:p {:class (stl/css :feature-content)}
"Until now, shadows couldnt be defined as reusable styles the way colors could before color tokens existed. Shadow tokens change that. You can now create reusable, consistent shadows, made of one or multiple layers, fully tokenized and ready to scale across your designs."]
[:p {:class (stl/css :feature-content)}
"Each shadow can reference existing tokens or use custom values, supports both Drop Shadow and Inner Shadow, and even allows shadow tokens to reference other shadow tokens. A brand-new capability, unlocked."]]
[:div {:class (stl/css :navigation)}
[:& c/navigation-bullets
{:slide slide
:navigate navigate
:total 2}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))

View File

@@ -0,0 +1,102 @@
// 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 "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend .modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
a {
color: var(--button-primary-background-color-rest);
}
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flexCenter;
@include deprecated.headlineSmallTypography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headlineLargeTypography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature {
display: flex;
flex-direction: column;
gap: deprecated.$s-8;
}
.feature-title {
@include deprecated.bodyLargeTypography;
color: var(--modal-title-foreground-color);
}
.feature-content {
@include deprecated.bodyMediumTypography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.feature-list {
@include deprecated.bodyMediumTypography;
color: var(--modal-text-foreground-color);
list-style: disc;
display: grid;
gap: deprecated.$s-8;
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend .button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}

View File

@@ -308,6 +308,16 @@
[:div {:class (stl/css :sign-info)}
[:button {:on-click on-click} (tr "labels.retry")]]]))
(mf/defc webgl-context-lost*
[]
(let [on-reload (mf/use-fn #(js/location.reload))]
[:> error-container* {}
[:div {:class (stl/css :main-message)} (tr "errors.webgl-context-lost.main-message")]
[:div {:class (stl/css :desc-message)} (tr "errors.webgl-context-lost.desc-message")]
[:div {:class (stl/css :buttons-container)}
[:> button* {:variant "primary" :on-click on-reload}
(tr "labels.reload-page")]]]))
(defn- generate-report
[data]
(try
@@ -437,6 +447,7 @@
(rx/of default)
(rx/throw cause)))))))
(mf/defc exception-section*
{::mf/private true}
[{:keys [data route] :as props}]
@@ -469,6 +480,9 @@
:service-unavailable
[:> service-unavailable*]
:webgl-context-lost
[:> webgl-context-lost*]
[:> internal-error* props])))
(mf/defc context-wrapper*

View File

@@ -218,6 +218,10 @@
design-tokens? (features/use-feature "design-tokens/v1")
wasm-renderer-enabled? (features/use-feature "render-wasm/v1")
first-frame-rendered? (mf/use-state false)
background-color (:background-color wglobal)]
(mf/with-effect []
@@ -242,6 +246,17 @@
(when (and file-loaded? (not page-id))
(st/emit! (dcm/go-to-workspace :file-id file-id ::rt/replace true))))
(mf/with-effect [file-id page-id]
(reset! first-frame-rendered? false))
(mf/with-effect []
(let [handle-wasm-render
(fn [_]
(reset! first-frame-rendered? true))
listener-key (events/listen globals/document "penpot:wasm:render" handle-wasm-render)]
(fn []
(events/unlistenByKey listener-key))))
[:> (mf/provider ctx/current-project-id) {:value project-id}
[:> (mf/provider ctx/current-file-id) {:value file-id}
[:> (mf/provider ctx/current-page-id) {:value page-id}
@@ -250,15 +265,22 @@
[:> modal-container*]
[:section {:class (stl/css :workspace)
:style {:background-color background-color
:touch-action "none"}}
:touch-action "none"
:position "relative"}}
[:> context-menu*]
(if (and file-loaded? page-id)
(when (and file-loaded? page-id)
[:> workspace-inner*
{:page-id page-id
:file-id file-id
:file file
:wglobal wglobal
:layout layout}]
:layout layout}])
(when (or (not (and file-loaded? page-id))
;; in wasm renderer, extend the pixel loader until the first frame is rendered
;; but do not apply it when switching pages
(and wasm-renderer-enabled?
(not file-loaded?)
(not @first-frame-rendered?)))
[:> workspace-loader*])]]]]]]))
(mf/defc workspace-page*

View File

@@ -20,7 +20,13 @@
}
.workspace-loader {
grid-area: viewport;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: var(--z-index-loaders);
background-color: var(--color-background-primary);
}
.workspace-content {

View File

@@ -116,13 +116,29 @@
(->> (dm/get-in grid-edition [edition :selected])
(map #(dm/get-in objects [edition :layout-grid-cells %])))
shapes-with-children
(mf/with-memo [selected objects shapes]
(let [xform (comp (remove nil?)
(mapcat #(cfh/get-children-ids objects %)))
selected (into selected xform selected)]
(sequence (keep (d/getf objects)) selected)))
shapes-with-children*
(mf/use-state nil)
_ (mf/use-effect
(mf/deps selected objects shapes)
(fn []
(reset! shapes-with-children* nil)
(let [result
(loop [queue (into #queue [] selected)
visited selected]
(if-let [id (peek queue)]
(let [shape (get objects id)
children (:shapes shape)]
(if (seq children)
(let [new-children (remove visited children)]
(recur (into (pop queue) new-children)
(into visited new-children)))
(recur (pop queue) visited)))
(sequence (keep (d/getf objects)) visited)))]
(reset! shapes-with-children* result))))
shapes-with-children
(deref shapes-with-children*)
total-selected
(count selected)]

View File

@@ -55,7 +55,6 @@
(-> (deref tokens)
(select-keys (get tk/tokens-by-input name))
(not-empty))))
on-detach-attr
(mf/use-fn
(mf/deps on-detach name)
@@ -193,11 +192,10 @@
(st/emit!
(change-radius (fn [shape]
(ctsr/set-radius-to-all-corners shape value))))
(doseq [attr [:r1 :r2 :r3 :r4]]
(st/emit!
(dwta/toggle-token {:token (first value)
:attrs #{attr}
:shape-ids ids}))))))
(st/emit!
(dwta/toggle-token {:token (first value)
:attrs #{:r1 :r2 :r3 :r4}
:shape-ids ids})))))
on-single-radius-change
@@ -206,9 +204,10 @@
(fn [value attr]
(if (or (string? value) (number? value))
(st/emit! (change-one-radius #(ctsr/set-radius-to-single-corner % attr value) attr))
(st/emit! (dwta/toggle-border-radius-token {:token (first value)
:attrs #{attr}
:shape-ids ids})))))
(st/emit! (st/emit!
(dwta/toggle-token {:token (first value)
:attrs #{attr}
:shape-ids ids}))))))
on-radius-r1-change #(on-single-radius-change % :r1)
on-radius-r2-change #(on-single-radius-change % :r2)

View File

@@ -507,7 +507,7 @@
mdata {:on-error #(do
(reset! key* (uuid/next))
(st/emit! (ntf/error error-msg)))}
(st/emit! (ntf/warn error-msg)))}
params {:shapes shapes :pos pos :val val}]
(st/emit! (dwv/variants-switch (with-meta params mdata)))))))

View File

@@ -9,13 +9,17 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.types.token :as tk]
[app.main.data.workspace :as dw]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.tokens.application :as dwta]
[app.main.features :as features]
[app.main.store :as st]
[app.main.ui.components.numeric-input :refer [numeric-input*]]
[app.main.ui.components.numeric-input :as deprecated-input]
[app.main.ui.components.select :refer [select]]
[app.main.ui.context :as muc]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.numeric-input :refer [numeric-input*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.render-wasm.api :as wasm.api]
[app.util.i18n :as i18n :refer [tr]]
@@ -39,11 +43,16 @@
(defn- check-layer-menu-props
[old-props new-props]
(let [old-values (unchecked-get old-props "values")
new-values (unchecked-get new-props "values")]
new-values (unchecked-get new-props "values")
old-applied-tokens (unchecked-get old-props "appliedTokens")
new-applied-tokens (unchecked-get new-props "appliedTokens")]
(and (identical? (unchecked-get old-props "class")
(unchecked-get new-props "class"))
(identical? (unchecked-get old-props "ids")
(unchecked-get new-props "ids"))
(identical? old-applied-tokens
new-applied-tokens)
(identical? (get old-values :opacity)
(get new-values :opacity))
(identical? (get old-values :blend-mode)
@@ -53,12 +62,53 @@
(identical? (get old-values :hidden)
(get new-values :hidden)))))
(mf/defc numeric-input-wrapper*
{::mf/private true}
[{:keys [values name applied-tokens align on-detach] :rest props}]
(let [tokens (mf/use-ctx muc/active-tokens-by-type)
tokens (mf/with-memo [tokens name]
(delay
(-> (deref tokens)
(select-keys (get tk/tokens-by-input name))
(not-empty))))
on-detach-attr (mf/use-fn
(mf/deps on-detach name)
#(on-detach % name))
applied-token (get applied-tokens name)
opacity-value (or (get values name) 1)
props (mf/spread-props props
{:placeholder (if (or (= :multiple (:applied-tokens values))
(= :multiple opacity-value))
(tr "settings.multiple")
"--")
:applied-token applied-token
:tokens (if (delay? tokens) @tokens tokens)
:align align
:on-detach on-detach-attr
:name name
:value (* 100 opacity-value)})]
[:> numeric-input* props]))
(mf/defc layer-menu*
{::mf/wrap [#(mf/memo' % check-layer-menu-props)]}
[{:keys [ids values]}]
(let [hidden? (get values :hidden)
[{:keys [ids values applied-tokens]}]
(let [token-numeric-inputs
(features/use-feature "tokens/numeric-input")
hidden? (get values :hidden)
blocked? (get values :blocked)
on-detach-token
(mf/use-fn
(mf/deps ids)
(fn [token attr]
(st/emit! (dwta/unapply-token {:token (first token)
:attributes #{attr}
:shape-ids ids}))))
current-blend-mode (or (get values :blend-mode) :normal)
current-opacity (opacity->string (:opacity values))
@@ -118,6 +168,17 @@
(let [value (/ value 100)]
(on-change ids :opacity value))))
on-opacity-change
(mf/use-fn
(mf/deps on-change handle-opacity-change)
(fn [value]
(if (or (string? value) (int? value))
(handle-opacity-change value)
(do
(st/emit! (dwta/toggle-token {:token (first value)
:attrs #{:opacity}
:shape-ids ids}))))))
handle-set-hidden
(mf/use-fn
(mf/deps ids)
@@ -176,8 +237,9 @@
preview-complete?))
(swap! state* assoc :selected-blend-mode current-blend-mode)))
[:div {:class (stl/css-case :element-set-content true
:hidden hidden?)}
[:section {:class (stl/css-case :element-set-content true
:hidden hidden?)
:aria-label "layer-menu-section"}
[:div {:class (stl/css :select)}
[:& select
{:default-value selected-blend-mode
@@ -187,16 +249,34 @@
:class (stl/css-case :hidden-select hidden?)
:on-pointer-enter-option handle-blend-mode-enter
:on-pointer-leave-option handle-blend-mode-leave}]]
[:div {:class (stl/css :input)
:title (tr "workspace.options.opacity")}
[:span {:class (stl/css :icon)} "%"]
[:> numeric-input*
{:value current-opacity
:placeholder "--"
:on-change handle-opacity-change
:min 0
:max 100
:className (stl/css :numeric-input)}]]
(if token-numeric-inputs
[:> numeric-input-wrapper*
{:on-change on-opacity-change
:on-detach on-detach-token
:icon i/percentage
:min 0
:max 100
:name :opacity
:property (tr "workspace.options.opacity")
:applied-tokens applied-tokens
:align :right
:class (stl/css :numeric-input-wrapper)
:values values}]
[:div {:class (stl/css :input)
:title (tr "workspace.options.opacity")}
[:span {:class (stl/css :icon)} "%"]
[:> deprecated-input/numeric-input*
{:value current-opacity
:placeholder "--"
:on-change handle-opacity-change
:min 0
:max 100
:className (stl/css :numeric-input)}]])
[:div {:class (stl/css :actions)}

View File

@@ -6,6 +6,7 @@
@use "refactor/common-refactor.scss" as deprecated;
@use "../../../sidebar/common/sidebar.scss" as sidebar;
@use "ds/_utils.scss" as *;
.element-set-content {
@include sidebar.option-grid-structure;
@@ -43,3 +44,9 @@
}
}
}
.numeric-input-wrapper {
grid-column: span 2;
--dropdown-width: var(--7-columns-dropdown-width);
--dropdown-offset: #{px2rem(-35)};
}

View File

@@ -369,12 +369,12 @@
(if (or (string? value) (int? value))
(on-change :simple attr value event)
(do
(let [resolved-value (:resolved-value (first value))
updated-attr (if (= :p1 attr) #{:p1 :p3} #{:p2 :p4})]
(st/emit! (dwta/toggle-token {:token (first value)
:attrs updated-attr
:shape-ids ids}))
(on-change :simple attr resolved-value event))))))
(st/emit!
(dwta/toggle-token {:token (first value)
:attrs (if (= :p1 attr)
#{:p1 :p3}
#{:p2 :p4})
:shape-ids ids}))))))
on-detach-token
(mf/use-fn
@@ -483,11 +483,9 @@
(if (or (string? value) (int? value))
(on-change :multiple attr value event)
(do
(let [resolved-value (:resolved-value (first value))]
(st/emit! (dwta/toggle-token {:token (first value)
:attrs #{attr}
:shape-ids ids}))
(on-change :multiple attr resolved-value event))))))
(st/emit! (dwta/toggle-token {:token (first value)
:attrs #{attr}
:shape-ids ids}))))))
on-focus
(mf/use-fn
@@ -716,11 +714,12 @@
(if (or (string? value) (int? value))
(on-change (= "nowrap" wrap-type) attr value event)
(do
(let [resolved-value (:resolved-value (first value))]
(st/emit! (dwta/toggle-token {:token (first value)
:attrs #{attr}
:shape-ids ids}))
(on-change (= "nowrap" wrap-type) attr resolved-value event))))))
(st/emit!
(dwta/toggle-token {:token (first value)
:attrs (if (= "nowrap" wrap-type)
#{:row-gap :colum-gap}
#{attr})
:shape-ids ids}))))))
on-detach-token
(mf/use-fn

View File

@@ -284,28 +284,17 @@
(st/emit! (udw/change-orientation ids (keyword orientation)))))
;; SIZE AND PROPORTION LOCK
do-size-change
(mf/use-fn
(mf/deps ids)
(fn [value attr]
(st/emit! (udw/trigger-bounding-box-cloaking ids)
(udw/update-dimensions ids attr value))))
on-size-change
(mf/use-fn
(mf/deps ids shapes)
(fn [value attr]
(if (or (string? value) (number? value))
(do
(st/emit! (udw/trigger-bounding-box-cloaking ids))
(run! #(do-size-change value attr) shapes))
(do
(let [resolved-value (:resolved-value (first value))]
(st/emit! (udw/trigger-bounding-box-cloaking ids)
(dwta/toggle-token {:token (first value)
:attrs #{attr}
:shape-ids ids}))
(run! #(do-size-change resolved-value attr) shapes))))))
(st/emit! (udw/trigger-bounding-box-cloaking ids)
(udw/update-dimensions ids attr value))
(st/emit! (udw/trigger-bounding-box-cloaking ids)
(dwta/toggle-token {:token (first value)
:attrs #{attr}
:shape-ids ids})))))
on-proportion-lock-change
(mf/use-fn
@@ -315,11 +304,6 @@
(run! #(st/emit! (udw/set-shape-proportion-lock % new-lock)) ids))))
;; POSITION
do-position-change
(mf/use-fn
(fn [shape' value attr]
(st/emit! (udw/update-position (:id shape') {attr value}))))
on-position-change
(mf/use-fn
(mf/deps ids)
@@ -327,21 +311,11 @@
(if (or (string? value) (number? value))
(do
(st/emit! (udw/trigger-bounding-box-cloaking ids))
(run! #(do-position-change %1 value attr) shapes))
(do
(let [resolved-value (:resolved-value (first value))]
(st/emit! (udw/trigger-bounding-box-cloaking ids)
(dwta/toggle-token {:token (first value)
:attrs #{attr}
:shape-ids ids}))
(run! #(do-position-change %1 resolved-value attr) shapes))))))
;; ROTATION
do-rotation-change
(mf/use-fn
(mf/deps ids)
(fn [value]
(st/emit! (udw/increase-rotation ids value))))
(st/emit! (udw/update-position ids {attr value})))
(st/emit! (udw/trigger-bounding-box-cloaking ids)
(dwta/toggle-token {:token (first value)
:attrs #{attr}
:shape-ids ids})))))
on-rotation-change
(mf/use-fn
@@ -350,14 +324,11 @@
(if (or (string? value) (number? value))
(do
(st/emit! (udw/trigger-bounding-box-cloaking ids))
(run! #(do-rotation-change value) shapes))
(do
(let [resolved-value (:resolved-value (first value))]
(st/emit! (udw/trigger-bounding-box-cloaking ids)
(dwta/toggle-token {:token (first value)
:attrs #{:rotation}
:shape-ids ids}))
(run! #(do-rotation-change resolved-value) shapes))))))
(st/emit! (udw/increase-rotation ids value)))
(st/emit! (udw/trigger-bounding-box-cloaking ids)
(dwta/toggle-token {:token (first value)
:attrs #{:rotation}
:shape-ids ids})))))
on-width-change
(mf/use-fn (mf/deps on-size-change) #(on-size-change % :width))
@@ -410,7 +381,8 @@
(fn []
(st/emit! (dwt/selected-fit-content))))]
[:div {:class (stl/css :element-set)}
[:section {:class (stl/css :element-set)
:aria-label "shape-measures-section"}
(when (and (options :presets)
(or (nil? all-types) (= (count all-types) 1)))
[:div {:class (stl/css :presets)}

View File

@@ -9,18 +9,50 @@
(:require
[app.common.data :as d]
[app.common.types.color :as ctc]
[app.common.types.token :as tk]
[app.main.data.workspace.tokens.application :as dwta]
[app.main.features :as features]
[app.main.store :as st]
[app.main.ui.components.numeric-input :refer [numeric-input*]]
[app.main.ui.components.numeric-input :as deprecated-input]
[app.main.ui.components.reorder-handler :refer [reorder-handler*]]
[app.main.ui.components.select :refer [select]]
[app.main.ui.context :as muc]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.numeric-input :refer [numeric-input*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.hooks :as h]
[app.main.ui.workspace.sidebar.options.rows.color-row :refer [color-row*]]
[app.util.i18n :as i18n :refer [tr]]
[rumext.v2 :as mf]))
(mf/defc numeric-input-wrapper*
{::mf/private true}
[{:keys [values name applied-tokens align on-detach] :rest props}]
(let [tokens (mf/use-ctx muc/active-tokens-by-type)
tokens (mf/with-memo [tokens name]
(delay
(-> (deref tokens)
(select-keys (get tk/tokens-by-input name))
(not-empty))))
on-detach-attr (mf/use-fn
(mf/deps on-detach name)
#(on-detach % name))
applied-token (get applied-tokens name)
props (mf/spread-props props
{:placeholder (if (= :multiple values)
(tr "settings.multiple")
"--")
:applied-token applied-token
:tokens (if (delay? tokens) @tokens tokens)
:align align
:on-detach on-detach-attr
:name name
:value values})]
[:> numeric-input* props]))
(mf/defc stroke-row*
[{:keys [index
stroke
@@ -45,7 +77,10 @@
select-on-focus
ids]}]
(let [on-drop
(let [token-numeric-inputs
(features/use-feature "tokens/numeric-input")
on-drop
(mf/use-fn
(mf/deps on-reorder index)
(fn [relative-pos data]
@@ -88,7 +123,13 @@
on-width-change
(mf/use-fn
(mf/deps index on-stroke-width-change)
#(on-stroke-width-change index %))
(fn [value]
(if (or (string? value) (int? value))
(on-stroke-width-change index value)
(do
(st/emit! (dwta/toggle-token {:token (first value)
:attrs #{:stroke-width}
:shape-ids ids}))))))
stroke-alignment (or (:stroke-alignment stroke) :center)
@@ -149,6 +190,12 @@
(fn [token]
(on-detach-token token #{:stroke-color})))
on-detach-token-width
(mf/use-fn
(mf/deps on-detach-token)
(fn [token]
(on-detach-token (first token) #{:stroke-width})))
stroke-caps-options
[{:value nil :label (tr "workspace.options.stroke-cap.none")}
:separator
@@ -195,17 +242,30 @@
;; Stroke Width, Alignment & Style
[:div {:class (stl/css :stroke-options)}
[:div {:class (stl/css :stroke-width-input)
:title (tr "workspace.options.stroke-width")}
[:> icon* {:icon-id i/stroke-size
:size "s"}]
[:> numeric-input* {:value stroke-width
:min 0
:placeholder (tr "settings.multiple")
:on-change on-width-change
:on-focus on-focus
:select-on-focus select-on-focus
:on-blur on-blur}]]
(if token-numeric-inputs
[:> numeric-input-wrapper* {:on-change on-width-change
:on-detach on-detach-token-width
:icon i/stroke-size
:min 0
:on-focus on-focus
:on-blur on-blur
:name :stroke-width
:class (stl/css :numeric-input-wrapper)
:property (tr "workspace.options.stroke-width")
:applied-tokens applied-tokens
:values stroke-width}]
[:div {:class (stl/css :stroke-width-input)
:title (tr "workspace.options.stroke-width")}
[:> icon* {:icon-id i/stroke-size
:size "s"}]
[:> deprecated-input/numeric-input* {:value stroke-width
:min 0
:placeholder (tr "settings.multiple")
:on-change on-width-change
:on-focus on-focus
:select-on-focus select-on-focus
:on-blur on-blur}]])
[:div {:class (stl/css :stroke-alignment-select)
:data-testid "stroke.alignment"}

View File

@@ -45,6 +45,11 @@
padding-inline-start: var(--sp-xs);
}
.numeric-input-wrapper {
grid-column: span 2;
--dropdown-width: var(--7-columns-dropdown-width);
}
.stroke-alignment-select {
grid-column: span 3;
}

View File

@@ -85,6 +85,7 @@
[:*
[:> layer-menu* {:ids ids
:type type
:applied-tokens applied-tokens
:values layer-values}]
[:> measures-menu* {:ids ids

View File

@@ -84,6 +84,7 @@
[:*
[:> layer-menu* {:ids ids
:type type
:applied-tokens applied-tokens
:values layer-values}]
[:> measures-menu* {:ids ids

View File

@@ -100,6 +100,7 @@
[:*
[:> layer-menu* {:ids ids
:type shape-type
:applied-tokens applied-tokens
:values layer-values}]
[:> measures-menu* {:ids ids
:applied-tokens applied-tokens

View File

@@ -111,6 +111,7 @@
[:div {:class (stl/css :options)}
[:> layer-menu* {:type type
:ids layer-ids
:applied-tokens applied-tokens
:values layer-values}]
[:> measures-menu* {:type type
:ids measure-ids

View File

@@ -382,7 +382,7 @@
objects
objects)))
[layer-ids layer-values]
[layer-ids layer-values layer-tokens]
(get-attrs shapes objects :layer)
[text-ids text-values]
@@ -406,7 +406,7 @@
[exports-ids exports-values]
(get-attrs shapes objects :exports)
[layout-container-ids layout-container-values layout-contianer-tokens]
[layout-container-ids layout-container-values layout-container-tokens]
(get-attrs shapes objects :layout-container)
[layout-item-ids layout-item-values {}]
@@ -442,6 +442,7 @@
(when-not (empty? layer-ids)
[:> layer-menu* {:type type
:ids layer-ids
:applied-tokens layer-tokens
:values layer-values}])
(when-not (empty? measure-ids)
@@ -459,7 +460,7 @@
{:type type
:ids layout-container-ids
:values layout-container-values
:applied-tokens layout-contianer-tokens
:applied-tokens layout-container-tokens
:multiple true}]
(when (or is-layout-child? has-flex-layout-container?)

View File

@@ -84,6 +84,7 @@
[:*
[:> layer-menu* {:ids ids
:applied-tokens applied-tokens
:type type
:values layer-values}]
[:> measures-menu* {:ids ids

View File

@@ -85,6 +85,7 @@
[:*
[:> layer-menu* {:ids ids
:type type
:applied-tokens applied-tokens
:values layer-values}]
[:> measures-menu* {:ids ids
:type type

View File

@@ -125,6 +125,7 @@
[:*
[:> layer-menu* {:ids ids
:type type
:applied-tokens applied-tokens
:values layer-values}]
[:> measures-menu*
{:ids ids

View File

@@ -13,6 +13,7 @@
[app.main.data.helpers :as dsh]
[app.main.data.modal :as modal]
[app.main.data.workspace :as dw]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.title-bar :refer [title-bar*]]
@@ -22,9 +23,11 @@
[app.main.ui.hooks :as hooks]
[app.main.ui.icons :as deprecated-icon]
[app.main.ui.notifications.badge :refer [badge-notification]]
[app.render-wasm.api :as wasm.api]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[app.util.timers :as timers]
[cuerdas.core :as str]
[okulary.core :as l]
[rumext.v2 :as mf]))
@@ -52,6 +55,8 @@
refs/workspace-data
=))
;; --- Page Item
(mf/defc page-item
@@ -63,6 +68,22 @@
navigate-fn (mf/use-fn (mf/deps id) #(st/emit! :interrupt (dcm/go-to-workspace :page-id id)))
read-only? (mf/use-ctx ctx/workspace-read-only?)
on-click
(mf/use-fn
(mf/deps id)
(fn []
;; when using the wasm renderer, apply a blur effect to the viewport canvas
(if (features/active-feature? @st/state "render-wasm/v1")
(do
(wasm.api/capture-canvas-pixels)
(wasm.api/apply-canvas-blur)
;; NOTE: it seems we need two RAF so the blur is actually applied and visible
;; in the canvas :(
(timers/raf
(fn []
(timers/raf navigate-fn))))
(navigate-fn))))
on-delete
(mf/use-fn
(mf/deps id)
@@ -155,7 +176,7 @@
:selected selected?)
:data-testid (dm/str "page-" id)
:tab-index "0"
:on-click navigate-fn
:on-click on-click
:on-double-click on-double-click
:on-context-menu on-context-menu}
[:div {:class (stl/css :page-icon)}

View File

@@ -273,4 +273,4 @@
{:label (tr "workspace.tokens.import-menu-folder-option") :value :folder}]
:on-click handle-import-action
:text-render render-button-text
:default :zip}]]]))
:default :file}]]]))

View File

@@ -14,8 +14,10 @@
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.main.ui.workspace.tokens.management.context-menu :refer [token-context-menu]]
[app.main.ui.workspace.tokens.management.group :refer [token-group*]]
[app.main.ui.workspace.tokens.management.node-context-menu :refer [token-node-context-menu*]]
[app.util.array :as array]
[app.util.i18n :refer [tr]]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(defn- get-sorted-token-groups
@@ -44,7 +46,7 @@
{::mf/private true}
[{:keys [tokens-lib selected-token-set-id]}]
(let [selected-token-set
(mf/with-memo [tokens-lib selected-token-set-id]
(mf/with-memo [tokens-lib]
(when selected-token-set-id
(some-> tokens-lib (ctob/get-set selected-token-set-id))))
@@ -60,20 +62,18 @@
[:div {:class (stl/css :sets-header-container)}
[:> text* {:as "span"
:typography "headline-small"
:class (stl/css :sets-header)
:data-testid "active-token-set-title"}
:class (stl/css :sets-header)}
(tr "workspace.tokens.tokens-section-title" (ctob/get-name selected-token-set))]
(when (and (some? selected-token-set-id)
(not (token-set-active? (ctob/get-name selected-token-set))))
[:div {:class (stl/css :sets-header-status) :title (tr "workspace.tokens.inactive-set-description")}
[: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")]]])]))
(tr "workspace.tokens.inactive-set")]])]]))
(mf/defc tokens-section*
{::mf/private true}
@@ -122,7 +122,27 @@
[empty-group filled-group]
(mf/with-memo [tokens-by-type]
(get-sorted-token-groups tokens-by-type))]
(get-sorted-token-groups tokens-by-type))
;; Filter tokens by their path and return their ids
filter-tokens-by-path-ids
(mf/use-fn
(mf/deps tokens)
(fn [type path]
(->> tokens
(filter (fn [token]
(let [[_ token-value] token]
(and (= (:type token-value) type) (str/starts-with? (:name token-value) path)))))
(mapv (fn [token]
(let [[_ token-value] token]
(:id token-value)))))))
delete-node
(mf/with-memo [tokens selected-token-set-id]
(fn [node type]
(let [path (:path node)
tokens-in-path-ids (filter-tokens-by-path-ids type path)]
(st/emit! (dwtl/bulk-delete-tokens selected-token-set-id tokens-in-path-ids)))))]
(mf/with-effect [tokens-lib selected-token-set-id]
(when (and tokens-lib
@@ -136,8 +156,9 @@
[:*
[:& token-context-menu]
[:> token-node-context-menu* {:on-delete-node delete-node}]
[:> selected-set-info* {:tokens-lib tokens-lib
[:& selected-set-info* {:tokens-lib tokens-lib
:selected-token-set-id selected-token-set-id}]
(for [type filled-group]

View File

@@ -223,7 +223,7 @@
gap-items (all-or-separate-actions {:attribute-labels {:column-gap "Column Gap"
:row-gap "Row Gap"}
:hint (tr "workspace.tokens.gaps")
:on-update-shape dwta/update-layout-spacing}
:on-update-shape dwta/update-layout-gap}
context-data)]
(->> (concat
gap-items
@@ -239,7 +239,7 @@
(all-or-separate-actions {:attribute-labels {:width "Width"
:height "Height"}
:hint (tr "workspace.tokens.size")
:on-update-shape dwta/update-shape-dimensions}
:on-update-shape dwta/use-dimensions-token}
context-data)
[:separator]
(all-or-separate-actions {:attribute-labels {:layout-item-min-w "Min Width"

View File

@@ -88,7 +88,7 @@
expandable? (d/nilv (seq tokens) false)
on-context-menu
on-pill-context-menu
(mf/use-fn
(fn [event token]
(dom/prevent-default event)
@@ -98,6 +98,15 @@
:errors (:errors token)
:token-id (:id token)}))))
on-node-context-menu
(mf/use-fn
(fn [event node]
(dom/prevent-default event)
(st/emit! (dwtl/assign-token-node-context-menu
{:node node
:type type
:position (dom/get-client-position event)}))))
on-toggle-open-click
(mf/use-fn
(mf/deps type expandable?)
@@ -159,4 +168,5 @@
:selected-token-set-id selected-token-set-id
:is-selected-inside-layout is-selected-inside-layout
:on-token-pill-click on-token-pill-click
:on-context-menu on-context-menu}])]))
:on-pill-context-menu on-pill-context-menu
:on-node-context-menu on-node-context-menu}])]))

View File

@@ -0,0 +1,83 @@
(ns app.main.ui.workspace.tokens.management.node-context-menu
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.data.workspace.tokens.library-edit :as dwtl]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.dropdown :refer [dropdown]]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[okulary.core :as l]
[rumext.v2 :as mf]))
(def ^:private schema:token-node-context-menu
[:map
[:on-delete-node fn?]])
(def ^:private tokens-node-menu-ref
(l/derived :token-node-context-menu refs/workspace-tokens))
(defn- prevent-default
[event]
(dom/prevent-default event)
(dom/stop-propagation event))
(mf/defc token-node-context-menu*
{::mf/schema schema:token-node-context-menu}
[{:keys [on-delete-node]}]
(let [mdata (mf/deref tokens-node-menu-ref)
is-open? (boolean mdata)
dropdown-ref (mf/use-ref)
dropdown-action (mf/use-ref)
dropdown-direction* (mf/use-state "down")
dropdown-direction (deref dropdown-direction*)
dropdown-direction-change* (mf/use-ref 0)
top (+ (get-in mdata [:position :y]) 5)
left (+ (get-in mdata [:position :x]) 5)
delete-node (mf/use-fn
(mf/deps mdata)
(fn []
(let [node (get mdata :node)
type (get mdata :type)]
(when node
(on-delete-node node type)))))]
(mf/with-effect [is-open?]
(when (and (not= 0 (mf/ref-val dropdown-direction-change*)) (= false is-open?))
(reset! dropdown-direction* "down")
(mf/set-ref-val! dropdown-direction-change* 0)))
(mf/with-effect [is-open? dropdown-ref dropdown-action]
(let [dropdown-element (mf/ref-val dropdown-ref)]
(when (and (= 0 (mf/ref-val dropdown-direction-change*)) dropdown-element)
(let [is-outside? (dom/is-element-outside? dropdown-element)]
(reset! dropdown-direction* (if is-outside? "up" "down"))
(mf/set-ref-val! dropdown-direction-change* (inc (mf/ref-val dropdown-direction-change*)))))))
;; FIXME: perf optimization
(when is-open?
(mf/portal
(mf/html
[:& dropdown {:show is-open?
:on-close #(st/emit! (dwtl/assign-token-node-context-menu nil))}
[:div {:class (stl/css :token-node-context-menu)
:data-testid "tokens-context-menu-for-token-node"
:ref dropdown-ref
:data-direction dropdown-direction
:style {:--bottom (if (= dropdown-direction "up")
"40px"
"unset")
:--top (dm/str top "px")
:left (dm/str left "px")}
:on-context-menu prevent-default}
(when mdata
[:ul {:class (stl/css :token-node-context-menu-list)}
[:li {:class (stl/css :token-node-context-menu-listitem)}
[:button {:class (stl/css :token-node-context-menu-action)
:type "button"
:on-click delete-node}
(tr "labels.delete")]]])]])
(dom/get-body)))))

View File

@@ -0,0 +1,70 @@
// 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/_utils.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_borders.scss" as *;
@use "ds/typography.scss" as t;
@use "ds/spacing.scss" as *;
@use "ds/mixins.scss" as *;
.token-node-context-menu {
--menu-inline-size: #{px2rem(240)};
position: absolute;
z-index: var(--z-index-dropdown);
}
.token-node-context-menu[data-direction="up"] {
bottom: var(--bottom);
}
.token-node-context-menu[data-direction="down"] {
top: var(--top);
}
.token-node-context-menu-list {
inline-size: var(--menu-inline-size);
padding: var(--sp-xs);
border-radius: $br-8;
border: $b-2 solid var(--color-background-quaternary);
background-color: var(--color-background-tertiary);
max-block-size: 100vh;
overflow-y: auto;
box-shadow: 0px 0px $sz-12 0px var(--menu-shadow-color);
}
.token-node-context-menu-action {
--context-menu-item-bg-color: none;
--context-menu-item-fg-color: var(--color-foreground-primary);
--context-menu-item-border-color: none;
@include t.use-typography("body-small");
appearance: none;
background: var(--context-menu-item-bg-color);
border: $b-1 solid var(--context-menu-item-border-color);
color: var(--context-menu-item-fg-color);
border-radius: $br-8;
cursor: pointer;
block-size: px2rem(32);
inline-size: 100%;
display: flex;
align-items: center;
padding: var(--sp-xs);
&:hover {
--context-menu-item-bg-color: var(--color-background-quaternary);
}
&:focus {
--context-menu-item-bg-color: var(--menu-background-color-focus);
--context-menu-item-border-color: var(--color-background-tertiary);
}
&[aria-selected="true"] {
--context-menu-item-bg-color: var(--color-background-quaternary);
}
}

View File

@@ -10,6 +10,7 @@
[app.common.path-names :as cpn]
[app.common.types.tokens-lib :as ctob]
[app.main.data.workspace.tokens.library-edit :as dwtl]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.ds.layers.layer-button :refer [layer-button*]]
[app.main.ui.workspace.tokens.management.token-pill :refer [token-pill*]]
@@ -26,7 +27,8 @@
[:selected-token-set-id {:optional true} :any]
[:tokens-lib {:optional true} :any]
[:on-token-pill-click {:optional true} fn?]
[:on-context-menu {:optional true} fn?]])
[:on-pill-context-menu {:optional true} fn?]
[:on-node-context-menu {:optional true} fn?]])
(mf/defc folder-node*
{::mf/schema schema:folder-node}
@@ -39,22 +41,29 @@
selected-token-set-id
tokens-lib
on-token-pill-click
on-context-menu]}]
on-pill-context-menu
on-node-context-menu]}]
(let [full-path (str (name type) "." (:path node))
is-folder-expanded (contains? (set (or unfolded-token-paths [])) full-path)
swap-folder-expanded (mf/use-fn
(mf/deps (:path node) type)
(fn []
(let [path (str (name type) "." (:path node))]
(st/emit! (dwtl/toggle-token-path path)))))]
(st/emit! (dwtl/toggle-token-path path)))))
node-context-menu-prep (mf/use-fn
(mf/deps on-node-context-menu node)
(fn [event]
(when on-node-context-menu
(on-node-context-menu event node))))]
[:li {:class (stl/css :folder-node)}
[:> layer-button* {:label (:name node)
:expanded is-folder-expanded
:aria-expanded is-folder-expanded
:aria-controls (str "folder-children-" (:path node))
:is-expandable (not (:leaf node))
:on-toggle-expand swap-folder-expanded}]
:on-toggle-expand swap-folder-expanded
:on-context-menu node-context-menu-prep}]
(when is-folder-expanded
(let [children-fn (:children-fn node)]
[:div {:class (stl/css :folder-children-wrapper)
@@ -63,16 +72,17 @@
(let [children (children-fn)]
(for [child children]
(if (not (:leaf child))
[:ul {:class (stl/css :node-parent)}
[:> folder-node* {:key (:path child)
:type type
[:ul {:class (stl/css :node-parent)
:key (:path child)}
[:> folder-node* {:type type
:node child
:unfolded-token-paths unfolded-token-paths
:selected-shapes selected-shapes
:is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens active-theme-tokens
:on-token-pill-click on-token-pill-click
:on-context-menu on-context-menu
:on-pill-context-menu on-pill-context-menu
:on-node-context-menu on-node-context-menu
:tokens-lib tokens-lib
:selected-token-set-id selected-token-set-id}]]
(let [id (:id (:leaf child))
@@ -84,7 +94,7 @@
:is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens active-theme-tokens
:on-click on-token-pill-click
:on-context-menu on-context-menu}])))))]))]))
:on-context-menu on-pill-context-menu}])))))]))]))
(def ^:private schema:token-tree
[:map
@@ -97,7 +107,8 @@
[:selected-token-set-id {:optional true} :any]
[:tokens-lib {:optional true} :any]
[:on-token-pill-click {:optional true} fn?]
[:on-context-menu {:optional true} fn?]])
[:on-pill-context-menu {:optional true} fn?]
[:on-node-context-menu {:optional true} fn?]])
(mf/defc token-tree*
{::mf/schema schema:token-tree}
@@ -110,12 +121,19 @@
tokens-lib
selected-token-set-id
on-token-pill-click
on-context-menu]}]
on-pill-context-menu
on-node-context-menu]}]
(let [separator "."
tree (mf/use-memo
(mf/deps tokens)
(fn []
(cpn/build-tree-root tokens separator)))]
(cpn/build-tree-root tokens separator)))
can-edit? (:can-edit (deref refs/permissions))
on-node-context-menu (mf/use-fn
(mf/deps can-edit? on-node-context-menu)
(fn [event node]
(when can-edit?
(on-node-context-menu event node))))]
[:div {:class (stl/css :token-tree-wrapper)}
(for [node tree]
(if (:leaf node)
@@ -127,7 +145,7 @@
:is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens active-theme-tokens
:on-click on-token-pill-click
:on-context-menu on-context-menu}])
:on-context-menu on-pill-context-menu}])
;; Render segment folder
[:ul {:class (stl/css :node-parent)
:key (:path node)}
@@ -138,6 +156,7 @@
:is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens active-theme-tokens
:on-token-pill-click on-token-pill-click
:on-context-menu on-context-menu
:on-node-context-menu on-node-context-menu
:on-pill-context-menu on-pill-context-menu
:tokens-lib tokens-lib
:selected-token-set-id selected-token-set-id}]]))]))

View File

@@ -20,7 +20,7 @@
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.combobox :refer [combobox*]]
[app.main.ui.ds.controls.input :refer [input*]]
[app.main.ui.ds.controls.radio-buttons :refer [radio-buttons*]]
[app.main.ui.ds.controls.switch :refer [switch*]]
[app.main.ui.ds.controls.utilities.label :refer [label*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
@@ -85,21 +85,6 @@
:on-click create-theme}
(tr "workspace.tokens.add-new-theme")]]]]))
(mf/defc switch*
[{:keys [selected? name on-change]}]
(let [selected (if selected? :on :off)]
[:> radio-buttons* {:selected selected
:on-change on-change
:name name
:options [{:id "on"
:icon i/tick
:label (tr "workspace.tokens.theme.enable")
:value :on}
{:id "off"
:icon i/close
:label (tr "workspace.tokens.theme.disable")
:value :off}]}]))
(mf/defc themes-overview
[{:keys [change-view]}]
(let [active-theme-paths (mf/deref refs/workspace-active-theme-paths)
@@ -137,6 +122,9 @@
(dom/prevent-default e)
(dom/stop-propagation e)
(st/emit! (dwtl/delete-token-theme id)))
on-switch-theme
(fn []
(st/emit! (dwtl/toggle-token-theme-active id)))
on-edit-theme
(fn [e]
(dom/prevent-default e)
@@ -146,16 +134,10 @@
:class (stl/css :theme-row)}
[:div {:class (stl/css :theme-switch-row)}
;; FIXME: FIREEEEEEEEEE THIS
[:div {:on-click (fn [e]
(dom/prevent-default e)
(dom/stop-propagation e)
(st/emit! (dwtl/toggle-token-theme-active id)))}
[:> switch* {:name (tr "workspace.tokens.theme-name" name)
:on-change (constantly nil)
:selected? selected?}]]]
[:div {:class (stl/css :theme-name-row)}
[:> text* {:as "span" :typography "body-medium" :class (stl/css :theme-name) :title name} name]]
[:> switch* {:id name
:label name
:on-change on-switch-theme
:default-checked selected?}]]
[:div {:class (stl/css :theme-actions-row)}

View File

@@ -312,6 +312,11 @@
(js/console.error "Error initializing canvas context:" e)
false))]
(reset! canvas-init? init?)
(when init?
;; Restore previous canvas pixels immediately after context initialization
;; This happens before initialize-viewport is called
(wasm.api/apply-canvas-blur)
(wasm.api/restore-previous-canvas-pixels))
(when-not init?
(js/alert "WebGL not supported")
(st/emit! (dcm/go-to-dashboard-recent))))))))
@@ -340,6 +345,7 @@
(mf/with-effect [@canvas-init? zoom vbox background]
(when (and @canvas-init? (not @initialized?))
(wasm.api/clear-canvas-pixels)
(wasm.api/initialize-viewport base-objects zoom vbox background)
(reset! initialized? true)))

View File

@@ -10,6 +10,7 @@
["react-dom/server" :as rds]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.files.helpers :as cfh]
[app.common.logging :as log]
[app.common.math :as mth]
@@ -21,7 +22,6 @@
[app.common.types.text :as txt]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.render-wasm :as drw]
[app.main.refs :as refs]
[app.main.render :as render]
[app.main.store :as st]
@@ -29,6 +29,7 @@
[app.main.worker :as mw]
[app.render-wasm.api.fonts :as f]
[app.render-wasm.api.texts :as t]
[app.render-wasm.api.webgl :as webgl]
[app.render-wasm.deserializers :as dr]
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
@@ -37,7 +38,6 @@
[app.render-wasm.serializers :as sr]
[app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.svg-filters :as svg-filters]
;; FIXME: rename; confunsing name
[app.render-wasm.wasm :as wasm]
[app.util.debug :as dbg]
[app.util.dom :as dom]
@@ -279,30 +279,6 @@
[string]
(+ (count string) 1))
(defn- create-webgl-texture-from-image
"Creates a WebGL texture from an HTMLImageElement or ImageBitmap and returns the texture object"
[gl image-element]
(let [texture (.createTexture ^js gl)]
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) texture)
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_WRAP_S ^js gl) (.-CLAMP_TO_EDGE ^js gl))
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_WRAP_T ^js gl) (.-CLAMP_TO_EDGE ^js gl))
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_MIN_FILTER ^js gl) (.-LINEAR ^js gl))
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_MAG_FILTER ^js gl) (.-LINEAR ^js gl))
(.texImage2D ^js gl (.-TEXTURE_2D ^js gl) 0 (.-RGBA ^js gl) (.-RGBA ^js gl) (.-UNSIGNED_BYTE ^js gl) image-element)
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) nil)
texture))
(defn- get-webgl-context
"Gets the WebGL context from the WASM module"
[]
(when wasm/context-initialized?
(let [gl-obj (unchecked-get wasm/internal-module "GL")]
(when gl-obj
;; Get the current WebGL context from Emscripten
;; The GL object has a currentContext property that contains the context handle
(let [current-ctx (.-currentContext ^js gl-obj)]
(when current-ctx
(.-GLctx ^js current-ctx)))))))
(defn- get-texture-id-for-gl-object
"Registers a WebGL texture with Emscripten's GL object system and returns its ID"
@@ -332,8 +308,8 @@
(->> (retrieve-image url)
(rx/map
(fn [img]
(when-let [gl (get-webgl-context)]
(let [texture (create-webgl-texture-from-image gl img)
(when-let [gl (webgl/get-webgl-context)]
(let [texture (webgl/create-webgl-texture-from-image gl img)
texture-id (get-texture-id-for-gl-object texture)
width (.-width ^js img)
height (.-height ^js img)
@@ -979,6 +955,7 @@
(set-shape-grow-type grow-type))
(set-shape-layout shape)
(set-layout-data shape)
(set-shape-selrect selrect)
(let [pending_thumbnails (into [] (concat
@@ -1055,8 +1032,9 @@
(perf/end-measure "set-objects")
(process-pending shapes thumbnails full noop-fn
(fn []
(when render-callback (render-callback))
(render-finish)
(if render-callback
(render-callback)
(render-finish))
(ug/dispatch! (ug/event "penpot:wasm:set-objects")))))))
(defn clear-focus-mode
@@ -1236,7 +1214,8 @@
(dom/prevent-default event)
(reset! wasm/context-lost? true)
(log/warn :hint "WebGL context lost")
(st/emit! (drw/context-lost)))
(ex/raise :type :webgl-context-lost
:hint "WebGL context lost"))
(defn init-canvas-context
[canvas]
@@ -1383,8 +1362,9 @@
all-children
(->> ids
(mapcat #(cfh/get-children-with-self objects %)))]
(h/call wasm/internal-module "_init_shapes_pool" (count all-children))
(run! (partial set-object objects) all-children)
(run! set-object all-children)
(let [content (-> (calculate-bool* bool-type ids)
(path.impl/path-data))]
@@ -1447,6 +1427,12 @@
result)))
(defn apply-canvas-blur
[]
(when wasm/canvas
(dom/set-style! wasm/canvas "filter" "blur(4px)")))
(defn init-wasm-module
[module]
(let [default-fn (unchecked-get module "default")
@@ -1468,3 +1454,8 @@
(js/console.error cause)
(p/resolved false)))))
(p/resolved false))))
;; Re-export public WebGL functions
(def capture-canvas-pixels webgl/capture-canvas-pixels)
(def restore-previous-canvas-pixels webgl/restore-previous-canvas-pixels)
(def clear-canvas-pixels webgl/clear-canvas-pixels)

View File

@@ -0,0 +1,166 @@
;; 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.render-wasm.api.webgl
"WebGL utilities for pixel capture and rendering"
(:require
[app.common.logging :as log]
[app.render-wasm.wasm :as wasm]
[app.util.dom :as dom]))
(defn get-webgl-context
"Gets the WebGL context from the WASM module"
[]
(when wasm/context-initialized?
(let [gl-obj (unchecked-get wasm/internal-module "GL")]
(when gl-obj
;; Get the current WebGL context from Emscripten
;; The GL object has a currentContext property that contains the context handle
(let [current-ctx (.-currentContext ^js gl-obj)]
(when current-ctx
(.-GLctx ^js current-ctx)))))))
(defn create-webgl-texture-from-image
"Creates a WebGL texture from an HTMLImageElement or ImageBitmap and returns the texture object"
[gl image-element]
(let [texture (.createTexture ^js gl)]
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) texture)
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_WRAP_S ^js gl) (.-CLAMP_TO_EDGE ^js gl))
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_WRAP_T ^js gl) (.-CLAMP_TO_EDGE ^js gl))
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_MIN_FILTER ^js gl) (.-LINEAR ^js gl))
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_MAG_FILTER ^js gl) (.-LINEAR ^js gl))
(.texImage2D ^js gl (.-TEXTURE_2D ^js gl) 0 (.-RGBA ^js gl) (.-RGBA ^js gl) (.-UNSIGNED_BYTE ^js gl) image-element)
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) nil)
texture))
;; FIXME: temporary function until we are able to keep the same <canvas> across pages.
(defn- draw-imagedata-to-webgl
"Draws ImageData to a WebGL2 context by creating a texture"
[gl image-data]
(let [width (.-width ^js image-data)
height (.-height ^js image-data)
texture (.createTexture ^js gl)]
;; Bind texture and set parameters
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) texture)
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_WRAP_S ^js gl) (.-CLAMP_TO_EDGE ^js gl))
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_WRAP_T ^js gl) (.-CLAMP_TO_EDGE ^js gl))
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_MIN_FILTER ^js gl) (.-LINEAR ^js gl))
(.texParameteri ^js gl (.-TEXTURE_2D ^js gl) (.-TEXTURE_MAG_FILTER ^js gl) (.-LINEAR ^js gl))
(.texImage2D ^js gl (.-TEXTURE_2D ^js gl) 0 (.-RGBA ^js gl) (.-RGBA ^js gl) (.-UNSIGNED_BYTE ^js gl) image-data)
;; Set up viewport
(.viewport ^js gl 0 0 width height)
;; Vertex & Fragment shaders
;; Since we are only calling this function once (on page switch), we don't need
;; to cache the compiled shaders somewhere else (cannot be reused in a differen context).
(let [vertex-shader-source "#version 300 es
in vec2 a_position;
in vec2 a_texCoord;
out vec2 v_texCoord;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
v_texCoord = a_texCoord;
}"
fragment-shader-source "#version 300 es
precision highp float;
in vec2 v_texCoord;
uniform sampler2D u_texture;
out vec4 fragColor;
void main() {
fragColor = texture(u_texture, v_texCoord);
}"
vertex-shader (.createShader ^js gl (.-VERTEX_SHADER ^js gl))
fragment-shader (.createShader ^js gl (.-FRAGMENT_SHADER ^js gl))
program (.createProgram ^js gl)]
(.shaderSource ^js gl vertex-shader vertex-shader-source)
(.compileShader ^js gl vertex-shader)
(when-not (.getShaderParameter ^js gl vertex-shader (.-COMPILE_STATUS ^js gl))
(log/error :hint "Vertex shader compilation failed"
:log (.getShaderInfoLog ^js gl vertex-shader)))
(.shaderSource ^js gl fragment-shader fragment-shader-source)
(.compileShader ^js gl fragment-shader)
(when-not (.getShaderParameter ^js gl fragment-shader (.-COMPILE_STATUS ^js gl))
(log/error :hint "Fragment shader compilation failed"
:log (.getShaderInfoLog ^js gl fragment-shader)))
(.attachShader ^js gl program vertex-shader)
(.attachShader ^js gl program fragment-shader)
(.linkProgram ^js gl program)
(if (.getProgramParameter ^js gl program (.-LINK_STATUS ^js gl))
(do
(.useProgram ^js gl program)
;; Create full-screen quad vertices (normalized device coordinates)
(let [position-location (.getAttribLocation ^js gl program "a_position")
texcoord-location (.getAttribLocation ^js gl program "a_texCoord")
position-buffer (.createBuffer ^js gl)
texcoord-buffer (.createBuffer ^js gl)
positions #js [-1.0 -1.0 1.0 -1.0 -1.0 1.0 -1.0 1.0 1.0 -1.0 1.0 1.0]
texcoords #js [0.0 0.0 1.0 0.0 0.0 1.0 0.0 1.0 1.0 0.0 1.0 1.0]]
;; Set up position buffer
(.bindBuffer ^js gl (.-ARRAY_BUFFER ^js gl) position-buffer)
(.bufferData ^js gl (.-ARRAY_BUFFER ^js gl) (js/Float32Array. positions) (.-STATIC_DRAW ^js gl))
(.enableVertexAttribArray ^js gl position-location)
(.vertexAttribPointer ^js gl position-location 2 (.-FLOAT ^js gl) false 0 0)
;; Set up texcoord buffer
(.bindBuffer ^js gl (.-ARRAY_BUFFER ^js gl) texcoord-buffer)
(.bufferData ^js gl (.-ARRAY_BUFFER ^js gl) (js/Float32Array. texcoords) (.-STATIC_DRAW ^js gl))
(.enableVertexAttribArray ^js gl texcoord-location)
(.vertexAttribPointer ^js gl texcoord-location 2 (.-FLOAT ^js gl) false 0 0)
;; Set texture uniform
(.activeTexture ^js gl (.-TEXTURE0 ^js gl))
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) texture)
(let [texture-location (.getUniformLocation ^js gl program "u_texture")]
(.uniform1i ^js gl texture-location 0))
;; draw
(.drawArrays ^js gl (.-TRIANGLES ^js gl) 0 6)
;; cleanup
(.deleteBuffer ^js gl position-buffer)
(.deleteBuffer ^js gl texcoord-buffer)
(.deleteShader ^js gl vertex-shader)
(.deleteShader ^js gl fragment-shader)
(.deleteProgram ^js gl program)))
(log/error :hint "Program linking failed"
:log (.getProgramInfoLog ^js gl program)))
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) nil)
(.deleteTexture ^js gl texture))))
(defn restore-previous-canvas-pixels
"Restores previous canvas pixels into the new canvas"
[]
(when-let [previous-canvas-pixels wasm/canvas-pixels]
(when-let [gl wasm/gl-context]
(draw-imagedata-to-webgl gl previous-canvas-pixels)
(set! wasm/canvas-pixels nil))))
(defn clear-canvas-pixels
[]
(when wasm/canvas
(let [context wasm/gl-context]
(.clearColor ^js context 0 0 0 0.0)
(.clear ^js context (.-COLOR_BUFFER_BIT ^js context))
(.clear ^js context (.-DEPTH_BUFFER_BIT ^js context))
(.clear ^js context (.-STENCIL_BUFFER_BIT ^js context)))
(dom/set-style! wasm/canvas "filter" "none")
(set! wasm/canvas-pixels nil)))
(defn capture-canvas-pixels
"Captures the pixels of the viewport canvas"
[]
(when wasm/canvas
(let [context wasm/gl-context
width (.-width wasm/canvas)
height (.-height wasm/canvas)
buffer (js/Uint8ClampedArray. (* width height 4))
_ (.readPixels ^js context 0 0 width height (.-RGBA ^js context) (.-UNSIGNED_BYTE ^js context) buffer)
image-data (js/ImageData. buffer width height)]
(set! wasm/canvas-pixels image-data))))

View File

@@ -12,6 +12,8 @@
;; Reference to the HTML canvas element.
(defonce canvas nil)
;; Reference to the captured pixels of the canvas (for page switching effect)
(defonce canvas-pixels nil)
;; Reference to the Emscripten GL context wrapper.
(defonce gl-context-handle nil)
@@ -56,3 +58,4 @@
:stroke-linecap shared/RawStrokeLineCap
:stroke-linejoin shared/RawStrokeLineJoin
:fill-rule shared/RawFillRule})

View File

@@ -179,6 +179,7 @@
(->> (render-canvas-blob canvas width height bgcolor)
(p/fnly (fn [data cause]
(wasm.api/clear-canvas)
(if cause
(rx/error! subs cause)
(rx/push! subs

View File

@@ -260,7 +260,7 @@
events [(dwta/apply-token {:shape-ids [(:id rect-1)]
:attributes #{:width :height}
:token (toht/get-token file "dimensions.sm")
:on-update-shape dwta/update-shape-dimensions})]]
:on-update-shape dwta/use-dimensions-token})]]
(tohs/run-store-async
store done events
(fn [new-state]
@@ -333,7 +333,7 @@
events [(dwta/apply-token {:shape-ids [(:id rect-1)]
:attributes #{:width :height}
:token (toht/get-token file "sizing.sm")
:on-update-shape dwta/update-shape-dimensions})]]
:on-update-shape dwta/use-dimensions-token})]]
(tohs/run-store-async
store done events
(fn [new-state]

View File

@@ -433,9 +433,6 @@ msgstr "(copy)"
msgid "dashboard.create-new-team"
msgstr "Create new team"
msgid "dashboard.create-new-org"
msgstr "Create new org"
#: src/app/main/ui/workspace/main_menu.cljs:661
msgid "dashboard.create-version-menu"
msgstr "Pin this version"
@@ -2696,7 +2693,12 @@ msgstr "Release notes"
msgid "labels.reload-file"
msgstr "Reload file"
msgid "labels.reload-page"
msgstr "Reload page"
#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:167, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:406
#: src/app/main/ui/workspace/libraries.cljs, src/app/main/ui/dashboard/team.cljs
#, unused
msgid "labels.remove"
msgstr "Remove"
@@ -7800,6 +7802,10 @@ msgstr "Line Height depends on Font Size. Add a Font Size to get the resolved va
msgid "workspace.tokens.create-new-theme"
msgstr "Create your first theme now."
#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs
msgid "workspace.tokens.warning-name-change"
msgstr "Renaming this token will break any reference to its old name"
#: src/app/main/ui/workspace/tokens/sets/lists.cljs:96, src/app/main/ui/workspace/tokens/themes.cljs:44
msgid "workspace.tokens.create-one"
msgstr "Create one."
@@ -8725,6 +8731,12 @@ msgstr ""
msgid "workspace.versions.warning.text"
msgstr "Autosaved versions will be kept for %s days."
msgid "errors.webgl-context-lost.main-message"
msgstr "Oops! The canvas context was lost"
msgid "errors.webgl-context-lost.desc-message"
msgstr "WebGL has stopped working. Please reload the page to reset it"
#, unused
msgid "workspace.viewport.click-to-close-path"
msgstr "Click to close the path"

View File

@@ -442,9 +442,6 @@ msgstr "(copia)"
msgid "dashboard.create-new-team"
msgstr "Crear nuevo equipo"
msgid "dashboard.create-new-org"
msgstr "Crear nueva organización"
#: src/app/main/ui/workspace/main_menu.cljs:661
msgid "dashboard.create-version-menu"
msgstr "Guardar esta versión"
@@ -2661,7 +2658,12 @@ msgstr "Notas de versión"
msgid "labels.reload-file"
msgstr "Recargar archivo"
msgid "labels.reload-page"
msgstr "Recargar página"
#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:167, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:406
#: src/app/main/ui/workspace/libraries.cljs, src/app/main/ui/dashboard/team.cljs
#, unused
msgid "labels.remove"
msgstr "Quitar"
@@ -4578,6 +4580,10 @@ msgstr "Mostrar/ocultar recursos"
msgid "shortcuts.toggle-colorpalette"
msgstr "Mostrar/ocultar paleta de colores"
#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs
msgid "workspace.tokens.warning-name-change"
msgstr "Cambiar el nombre de este token romperá cualquier referencia a su nombre anterior."
#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:185
msgid "shortcuts.toggle-focus-mode"
msgstr "Mostrar/ocultar focus mode"
@@ -8575,3 +8581,9 @@ msgstr "Los autoguardados duran %s días."
#, unused
msgid "workspace.viewport.click-to-close-path"
msgstr "Pulsar para cerrar la ruta"
msgid "errors.webgl-context-lost.main-message"
msgstr "Ups! Se ha perdido el contexto del canvas"
msgid "errors.webgl-context-lost.desc-message"
msgstr "WebGL ha dejado de funcionar. Por favor, recarga la página para restaurarlo"

View File

@@ -10,7 +10,6 @@ mod shadows;
mod strokes;
mod surfaces;
pub mod text;
mod ui;
use skia_safe::{self as skia, Matrix, RRect, Rect};
@@ -53,6 +52,25 @@ pub struct NodeRenderState {
mask: bool,
}
/// Get simplified children of a container, flattening nested flattened containers
fn get_simplified_children<'a>(tree: ShapesPoolRef<'a>, shape: &'a Shape) -> Vec<Uuid> {
let mut result = Vec::new();
for child_id in shape.children_ids_iter(false) {
if let Some(child) = tree.get(child_id) {
if child.can_flatten() {
// Child is flattened: recursively get its simplified children
result.extend(get_simplified_children(tree, child));
} else {
// Child is not flattened: add it directly
result.push(*child_id);
}
}
}
result
}
impl NodeRenderState {
pub fn is_root(&self) -> bool {
self.id.is_nil()
@@ -398,12 +416,7 @@ impl RenderState {
}
fn frame_clip_layer_blur(shape: &Shape) -> Option<Blur> {
match shape.shape_type {
Type::Frame(_) if shape.clip() => shape.blur.filter(|blur| {
!blur.hidden && blur.blur_type == BlurType::LayerBlur && blur.value > 0.
}),
_ => None,
}
shape.frame_clip_layer_blur()
}
/// Runs `f` with `ignore_nested_blurs` temporarily forced to `true`.
@@ -521,38 +534,59 @@ impl RenderState {
let paint = skia::Paint::default();
self.surfaces
.draw_into(SurfaceId::TextDropShadows, SurfaceId::Current, Some(&paint));
// Only draw surfaces that have content (dirty flag optimization)
if self.surfaces.is_dirty(SurfaceId::TextDropShadows) {
self.surfaces
.draw_into(SurfaceId::TextDropShadows, SurfaceId::Current, Some(&paint));
}
self.surfaces
.draw_into(SurfaceId::Fills, SurfaceId::Current, Some(&paint));
if self.surfaces.is_dirty(SurfaceId::Fills) {
self.surfaces
.draw_into(SurfaceId::Fills, SurfaceId::Current, Some(&paint));
}
let mut render_overlay_below_strokes = false;
if let Some(shape) = shape {
render_overlay_below_strokes = shape.has_fills();
}
if render_overlay_below_strokes {
if render_overlay_below_strokes && self.surfaces.is_dirty(SurfaceId::InnerShadows) {
self.surfaces
.draw_into(SurfaceId::InnerShadows, SurfaceId::Current, Some(&paint));
}
self.surfaces
.draw_into(SurfaceId::Strokes, SurfaceId::Current, Some(&paint));
if self.surfaces.is_dirty(SurfaceId::Strokes) {
self.surfaces
.draw_into(SurfaceId::Strokes, SurfaceId::Current, Some(&paint));
}
if !render_overlay_below_strokes {
if !render_overlay_below_strokes && self.surfaces.is_dirty(SurfaceId::InnerShadows) {
self.surfaces
.draw_into(SurfaceId::InnerShadows, SurfaceId::Current, Some(&paint));
}
let surface_ids = SurfaceId::Strokes as u32
| SurfaceId::Fills as u32
| SurfaceId::InnerShadows as u32
| SurfaceId::TextDropShadows as u32;
// Build mask of dirty surfaces that need clearing
let mut dirty_surfaces_to_clear = 0u32;
if self.surfaces.is_dirty(SurfaceId::Strokes) {
dirty_surfaces_to_clear |= SurfaceId::Strokes as u32;
}
if self.surfaces.is_dirty(SurfaceId::Fills) {
dirty_surfaces_to_clear |= SurfaceId::Fills as u32;
}
if self.surfaces.is_dirty(SurfaceId::InnerShadows) {
dirty_surfaces_to_clear |= SurfaceId::InnerShadows as u32;
}
if self.surfaces.is_dirty(SurfaceId::TextDropShadows) {
dirty_surfaces_to_clear |= SurfaceId::TextDropShadows as u32;
}
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas().clear(skia::Color::TRANSPARENT);
});
if dirty_surfaces_to_clear != 0 {
self.surfaces.apply_mut(dirty_surfaces_to_clear, |s| {
s.canvas().clear(skia::Color::TRANSPARENT);
});
// Clear dirty flags for surfaces we just cleared
self.surfaces.clear_dirty(dirty_surfaces_to_clear);
}
}
pub fn clear_focus_mode(&mut self) {
@@ -605,11 +639,90 @@ impl RenderState {
| strokes_surface_id as u32
| innershadows_surface_id as u32
| text_drop_shadows_surface_id as u32;
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas().save();
});
// Only save canvas state if we have clipping or transforms
// For simple shapes without clipping, skip expensive save/restore
let needs_save =
clip_bounds.is_some() || offset.is_some() || !shape.transform.is_identity();
if needs_save {
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas().save();
});
}
let antialias = shape.should_use_antialias(self.get_scale());
let fast_mode = self.options.is_fast_mode();
let has_nested_fills = self
.nested_fills
.last()
.is_some_and(|fills| !fills.is_empty());
let has_inherited_blur = !self.ignore_nested_blurs
&& self.nested_blurs.iter().flatten().any(|blur| {
!blur.hidden && blur.blur_type == BlurType::LayerBlur && blur.value > 0.0
});
let can_render_directly = apply_to_current_surface
&& clip_bounds.is_none()
&& offset.is_none()
&& parent_shadows.is_none()
&& !shape.needs_layer()
&& shape.blur.is_none()
&& !has_inherited_blur
&& shape.shadows.is_empty()
&& shape.transform.is_identity()
&& matches!(
shape.shape_type,
Type::Rect(_) | Type::Circle | Type::Path(_) | Type::Bool(_)
)
&& !(shape.fills.is_empty() && has_nested_fills)
&& !shape
.svg_attrs
.as_ref()
.is_some_and(|attrs| attrs.fill_none);
if can_render_directly {
let scale = self.get_scale();
let translation = self
.surfaces
.get_render_context_translation(self.render_area, scale);
self.surfaces.apply_mut(SurfaceId::Current as u32, |s| {
let canvas = s.canvas();
canvas.save();
canvas.scale((scale, scale));
canvas.translate(translation);
});
for fill in shape.fills().rev() {
fills::render(self, shape, fill, antialias, SurfaceId::Current);
}
for stroke in shape.visible_strokes().rev() {
strokes::render(
self,
shape,
stroke,
Some(SurfaceId::Current),
None,
antialias,
);
}
self.surfaces.apply_mut(SurfaceId::Current as u32, |s| {
s.canvas().restore();
});
if self.options.is_debug_visible() {
let shape_selrect_bounds = self.get_shape_selrect_bounds(shape);
debug::render_debug_shape(self, Some(shape_selrect_bounds), None);
}
if needs_save {
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas().restore();
});
}
return;
}
// set clipping
if let Some(clips) = clip_bounds.as_ref() {
@@ -666,6 +779,9 @@ impl RenderState {
} else if shape_has_blur {
shape.to_mut().set_blur(None);
}
if fast_mode {
shape.to_mut().set_blur(None);
}
let center = shape.center();
let mut matrix = shape.transform;
@@ -683,16 +799,18 @@ impl RenderState {
matrix.pre_concat(&svg_transform);
}
self.surfaces.canvas(fills_surface_id).concat(&matrix);
self.surfaces
.canvas_and_mark_dirty(fills_surface_id)
.concat(&matrix);
if let Some(svg) = shape.svg.as_ref() {
svg.render(self.surfaces.canvas(fills_surface_id))
svg.render(self.surfaces.canvas_and_mark_dirty(fills_surface_id));
} else {
let font_manager = skia::FontMgr::from(self.fonts().font_provider().clone());
let dom_result = skia::svg::Dom::from_str(&sr.content, font_manager);
match dom_result {
Ok(dom) => {
dom.render(self.surfaces.canvas(fills_surface_id));
dom.render(self.surfaces.canvas_and_mark_dirty(fills_surface_id));
shape.to_mut().set_svg(dom);
}
Err(e) => {
@@ -708,18 +826,8 @@ impl RenderState {
});
let text_content = text_content.new_bounds(shape.selrect());
let mut drop_shadows = shape.drop_shadow_paints();
if let Some(inherited_shadows) = self.get_inherited_drop_shadows() {
drop_shadows.extend(inherited_shadows);
}
let inner_shadows = shape.inner_shadow_paints();
let blur_filter = shape.image_filter(1.);
let count_inner_strokes = shape.count_visible_inner_strokes();
let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None);
let mut paragraphs_with_shadows =
text_content.paragraph_builder_group_from_text(Some(true));
let mut stroke_paragraphs_list = shape
.visible_strokes()
.rev()
@@ -733,62 +841,8 @@ impl RenderState {
)
})
.collect::<Vec<_>>();
let mut stroke_paragraphs_with_shadows_list = shape
.visible_strokes()
.rev()
.map(|stroke| {
text::stroke_paragraph_builder_group_from_text(
&text_content,
stroke,
&shape.selrect(),
count_inner_strokes,
Some(true),
)
})
.collect::<Vec<_>>();
if let Some(parent_shadows) = parent_shadows {
if !shape.has_visible_strokes() {
for shadow in parent_shadows {
text::render(
Some(self),
None,
&shape,
&mut paragraphs_with_shadows,
text_drop_shadows_surface_id.into(),
Some(&shadow),
blur_filter.as_ref(),
);
}
} else {
shadows::render_text_shadows(
self,
&shape,
&mut paragraphs_with_shadows,
&mut stroke_paragraphs_with_shadows_list,
text_drop_shadows_surface_id.into(),
&parent_shadows,
&blur_filter,
);
}
} else {
// 1. Text drop shadows
if !shape.has_visible_strokes() {
for shadow in &drop_shadows {
text::render(
Some(self),
None,
&shape,
&mut paragraphs_with_shadows,
text_drop_shadows_surface_id.into(),
Some(shadow),
blur_filter.as_ref(),
);
}
}
// 2. Text fills
if fast_mode {
// Fast path: render fills and strokes only (skip shadows/blur).
text::render(
Some(self),
None,
@@ -796,21 +850,9 @@ impl RenderState {
&mut paragraph_builders,
Some(fills_surface_id),
None,
blur_filter.as_ref(),
None,
);
// 3. Stroke drop shadows
shadows::render_text_shadows(
self,
&shape,
&mut paragraphs_with_shadows,
&mut stroke_paragraphs_with_shadows_list,
text_drop_shadows_surface_id.into(),
&drop_shadows,
&blur_filter,
);
// 4. Stroke fills
for stroke_paragraphs in stroke_paragraphs_list.iter_mut() {
text::render(
Some(self),
@@ -819,34 +861,134 @@ impl RenderState {
stroke_paragraphs,
Some(strokes_surface_id),
None,
blur_filter.as_ref(),
None,
);
}
} else {
let mut drop_shadows = shape.drop_shadow_paints();
// 5. Stroke inner shadows
shadows::render_text_shadows(
self,
&shape,
&mut paragraphs_with_shadows,
&mut stroke_paragraphs_with_shadows_list,
Some(innershadows_surface_id),
&inner_shadows,
&blur_filter,
);
if let Some(inherited_shadows) = self.get_inherited_drop_shadows() {
drop_shadows.extend(inherited_shadows);
}
// 6. Fill Inner shadows
if !shape.has_visible_strokes() {
for shadow in &inner_shadows {
let inner_shadows = shape.inner_shadow_paints();
let blur_filter = shape.image_filter(1.);
let mut paragraphs_with_shadows =
text_content.paragraph_builder_group_from_text(Some(true));
let mut stroke_paragraphs_with_shadows_list = shape
.visible_strokes()
.rev()
.map(|stroke| {
text::stroke_paragraph_builder_group_from_text(
&text_content,
stroke,
&shape.selrect(),
count_inner_strokes,
Some(true),
)
})
.collect::<Vec<_>>();
if let Some(parent_shadows) = parent_shadows {
if !shape.has_visible_strokes() {
for shadow in parent_shadows {
text::render(
Some(self),
None,
&shape,
&mut paragraphs_with_shadows,
text_drop_shadows_surface_id.into(),
Some(&shadow),
blur_filter.as_ref(),
);
}
} else {
shadows::render_text_shadows(
self,
&shape,
&mut paragraphs_with_shadows,
&mut stroke_paragraphs_with_shadows_list,
text_drop_shadows_surface_id.into(),
&parent_shadows,
&blur_filter,
);
}
} else {
// 1. Text drop shadows
if !shape.has_visible_strokes() {
for shadow in &drop_shadows {
text::render(
Some(self),
None,
&shape,
&mut paragraphs_with_shadows,
text_drop_shadows_surface_id.into(),
Some(shadow),
blur_filter.as_ref(),
);
}
}
// 2. Text fills
text::render(
Some(self),
None,
&shape,
&mut paragraph_builders,
Some(fills_surface_id),
None,
blur_filter.as_ref(),
);
// 3. Stroke drop shadows
shadows::render_text_shadows(
self,
&shape,
&mut paragraphs_with_shadows,
&mut stroke_paragraphs_with_shadows_list,
text_drop_shadows_surface_id.into(),
&drop_shadows,
&blur_filter,
);
// 4. Stroke fills
for stroke_paragraphs in stroke_paragraphs_list.iter_mut() {
text::render(
Some(self),
None,
&shape,
&mut paragraphs_with_shadows,
Some(innershadows_surface_id),
Some(shadow),
stroke_paragraphs,
Some(strokes_surface_id),
None,
blur_filter.as_ref(),
);
}
// 5. Stroke inner shadows
shadows::render_text_shadows(
self,
&shape,
&mut paragraphs_with_shadows,
&mut stroke_paragraphs_with_shadows_list,
Some(innershadows_surface_id),
&inner_shadows,
&blur_filter,
);
// 6. Fill Inner shadows
if !shape.has_visible_strokes() {
for shadow in &inner_shadows {
text::render(
Some(self),
None,
&shape,
&mut paragraphs_with_shadows,
Some(innershadows_surface_id),
Some(shadow),
blur_filter.as_ref(),
);
}
}
}
}
}
@@ -886,16 +1028,25 @@ impl RenderState {
None,
antialias,
);
shadows::render_stroke_inner_shadows(
if !fast_mode {
shadows::render_stroke_inner_shadows(
self,
shape,
stroke,
antialias,
innershadows_surface_id,
);
}
}
if !fast_mode {
shadows::render_fill_inner_shadows(
self,
shape,
stroke,
antialias,
innershadows_surface_id,
);
}
shadows::render_fill_inner_shadows(self, shape, antialias, innershadows_surface_id);
// bools::debug_render_bool_paths(self, shape, shapes, modifiers, structure);
}
};
@@ -908,9 +1059,13 @@ impl RenderState {
if apply_to_current_surface {
self.apply_drawing_to_render_canvas(Some(&shape));
}
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas().restore();
});
// Only restore if we saved (optimization for simple shapes)
if needs_save {
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas().restore();
});
}
}
pub fn update_render_context(&mut self, tile: tiles::Tile) {
@@ -1031,6 +1186,7 @@ impl RenderState {
// reorder by distance to the center.
self.current_tile = None;
self.render_in_progress = true;
self.apply_drawing_to_render_canvas(None);
if sync_render {
@@ -1117,18 +1273,6 @@ impl RenderState {
self.nested_fills.push(Vec::new());
}
let mut paint = skia::Paint::default();
paint.set_blend_mode(element.blend_mode().into());
paint.set_alpha_f(element.opacity());
if let Some(frame_blur) = Self::frame_clip_layer_blur(element) {
let scale = self.get_scale();
let sigma = frame_blur.value * scale;
if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) {
paint.set_image_filter(filter);
}
}
// When we're rendering the mask shape we need to set a special blend mode
// called 'destination-in' that keeps the drawn content within the mask.
// @see https://skia.org/docs/user/api/skblendmode_overview/
@@ -1141,16 +1285,40 @@ impl RenderState {
.save_layer(&mask_rec);
}
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
self.surfaces
.canvas(SurfaceId::Current)
.save_layer(&layer_rec);
// Only create save_layer if actually needed
// For simple shapes with default opacity and blend mode, skip expensive save_layer
// Groups with masks need a layer to properly handle the mask rendering
let needs_layer = element.needs_layer();
if needs_layer {
let mut paint = skia::Paint::default();
paint.set_blend_mode(element.blend_mode().into());
paint.set_alpha_f(element.opacity());
if let Some(frame_blur) = Self::frame_clip_layer_blur(element) {
let scale = self.get_scale();
let sigma = frame_blur.value * scale;
if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) {
paint.set_image_filter(filter);
}
}
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
self.surfaces
.canvas(SurfaceId::Current)
.save_layer(&layer_rec);
}
self.focus_mode.enter(&element.id);
}
#[inline]
pub fn render_shape_exit(&mut self, element: &Shape, visited_mask: bool) {
pub fn render_shape_exit(
&mut self,
element: &Shape,
visited_mask: bool,
clip_bounds: Option<ClipStack>,
) {
if visited_mask {
// Because masked groups needs two rendering passes (first drawing
// the content and then drawing the mask), we need to do an
@@ -1206,7 +1374,7 @@ impl RenderState {
element_strokes.to_mut().clip_content = false;
self.render_shape(
&element_strokes,
None,
clip_bounds,
SurfaceId::Fills,
SurfaceId::Strokes,
SurfaceId::InnerShadows,
@@ -1217,7 +1385,14 @@ impl RenderState {
);
}
self.surfaces.canvas(SurfaceId::Current).restore();
// Only restore if we created a layer (optimization for simple shapes)
// Groups with masks need restore to properly handle the mask rendering
let needs_layer = element.needs_layer();
if needs_layer {
self.surfaces.canvas(SurfaceId::Current).restore();
}
self.focus_mode.exit(&element.id);
}
@@ -1450,6 +1625,8 @@ impl RenderState {
"Error: Element with root_id {} not found in the tree.",
node_render_state.id
))?;
let scale = self.get_scale();
let mut extrect: Option<Rect> = None;
// If the shape is not in the tile set, then we add them.
if self.tiles.get_tiles_of(node_id).is_none() {
@@ -1457,22 +1634,44 @@ impl RenderState {
}
if visited_children {
self.render_shape_exit(element, visited_mask);
// Skip render_shape_exit for flattened containers
if !element.can_flatten() {
self.render_shape_exit(element, visited_mask, clip_bounds);
}
continue;
}
if !node_render_state.is_root() {
let transformed_element: Cow<Shape> = Cow::Borrowed(element);
let scale = self.get_scale();
let extrect = transformed_element.extrect(tree, scale);
let is_visible = extrect.intersects(self.render_area)
&& !transformed_element.hidden
&& !transformed_element.visually_insignificant(scale, tree);
// Aggressive early exit: check hidden first (fastest check)
if transformed_element.hidden {
continue;
}
// For frames and groups, we must use extrect because they can have nested content
// that extends beyond their selrect. Using selrect for early exit would incorrectly
// skip frames/groups that have nested content in the current tile.
let is_container = matches!(
transformed_element.shape_type,
crate::shapes::Type::Frame(_) | crate::shapes::Type::Group(_)
);
let has_effects = transformed_element.has_effects_that_extend_bounds();
let is_visible = if is_container || has_effects {
let element_extrect =
extrect.get_or_insert_with(|| transformed_element.extrect(tree, scale));
element_extrect.intersects(self.render_area)
&& !transformed_element.visually_insignificant(scale, tree)
} else {
let selrect = transformed_element.selrect();
selrect.intersects(self.render_area)
&& !transformed_element.visually_insignificant(scale, tree)
};
if self.options.is_debug_visible() {
let shape_extrect_bounds =
self.get_shape_extrect_bounds(&transformed_element, tree);
let shape_extrect_bounds = self.get_shape_extrect_bounds(element, tree);
debug::render_debug_shape(self, None, Some(shape_extrect_bounds));
}
@@ -1481,10 +1680,14 @@ impl RenderState {
}
}
self.render_shape_enter(element, mask);
// Skip render_shape_enter/exit for flattened containers
// If a container was flattened, it doesn't affect children visually, so we skip
// the expensive enter/exit operations and process children directly
if !element.can_flatten() {
self.render_shape_enter(element, mask);
}
if !node_render_state.is_root() && self.focus_mode.is_active() {
let scale: f32 = self.get_scale();
let translation = self
.surfaces
.get_render_context_translation(self.render_area, scale);
@@ -1524,9 +1727,11 @@ impl RenderState {
.save_layer(&layer_rec);
// First pass: Render shadow in black to establish alpha mask
let element_extrect =
extrect.get_or_insert_with(|| element.extrect(tree, scale));
self.render_drop_black_shadow(
element,
&element.extrect(tree, scale),
element_extrect,
shadow,
clip_bounds.clone(),
scale,
@@ -1541,7 +1746,6 @@ impl RenderState {
if shadow_shape.hidden {
continue;
}
let clip_bounds = node_render_state
.get_nested_shadow_clip_bounds(element, shadow);
@@ -1682,14 +1886,18 @@ impl RenderState {
self.apply_drawing_to_render_canvas(Some(element));
}
match element.shape_type {
Type::Frame(_) if Self::frame_clip_layer_blur(element).is_some() => {
self.nested_blurs.push(None);
// Skip nested state updates for flattened containers
// Flattened containers don't affect children, so we don't need to track their state
if !element.can_flatten() {
match element.shape_type {
Type::Frame(_) if Self::frame_clip_layer_blur(element).is_some() => {
self.nested_blurs.push(None);
}
Type::Frame(_) | Type::Group(_) => {
self.nested_blurs.push(element.blur);
}
_ => {}
}
Type::Frame(_) | Type::Group(_) => {
self.nested_blurs.push(element.blur);
}
_ => {}
}
// Set the node as visited_children before processing children
@@ -1704,24 +1912,35 @@ impl RenderState {
if element.is_recursive() {
let children_clip_bounds =
node_render_state.get_children_clip_bounds(element, None);
let mut children_ids: Vec<_> = element.children_ids_iter(false).collect();
let children_ids: Vec<_> = if element.can_flatten() {
// Container was flattened: get simplified children (which skip this level)
get_simplified_children(tree, element)
} else {
// Container not flattened: use original children
element.children_ids_iter(false).copied().collect()
};
// Z-index ordering on Layouts
if element.has_layout() {
let children_ids = if element.has_layout() {
let mut ids = children_ids;
if element.is_flex() && !element.is_flex_reverse() {
children_ids.reverse();
ids.reverse();
}
children_ids.sort_by(|id1, id2| {
ids.sort_by(|id1, id2| {
let z1 = tree.get(id1).map(|s| s.z_index()).unwrap_or(0);
let z2 = tree.get(id2).map(|s| s.z_index()).unwrap_or(0);
z2.cmp(&z1)
});
}
ids
} else {
children_ids
};
for child_id in children_ids.iter() {
self.pending_nodes.push(NodeRenderState {
id: **child_id,
id: *child_id,
visited_children: false,
clip_bounds: children_clip_bounds.clone(),
visited_mask: false,
@@ -1747,6 +1966,16 @@ impl RenderState {
allow_stop: bool,
) -> Result<(), String> {
let mut should_stop = false;
let root_ids = {
if let Some(shape_id) = base_object {
vec![*shape_id]
} else {
let Some(root) = tree.get(&Uuid::nil()) else {
return Err(String::from("Root shape not found"));
};
root.children_ids(false)
}
};
while !should_stop {
if let Some(current_tile) = self.current_tile {
@@ -1803,17 +2032,6 @@ impl RenderState {
.canvas(SurfaceId::Current)
.clear(self.background_color);
let root_ids = {
if let Some(shape_id) = base_object {
vec![*shape_id]
} else {
let Some(root) = tree.get(&Uuid::nil()) else {
return Err(String::from("Root shape not found"));
};
root.children_ids(false)
}
};
// If we finish processing every node rendering is complete
// let's check if there are more pending nodes
if let Some(next_tile) = self.pending_tiles.pop() {
@@ -1821,21 +2039,13 @@ impl RenderState {
if !self.surfaces.has_cached_tile_surface(next_tile) {
if let Some(ids) = self.tiles.get_shapes_at(next_tile) {
let root_ids_map: std::collections::HashMap<Uuid, usize> = root_ids
.iter()
.enumerate()
.map(|(i, id)| (*id, i))
.collect();
// We only need first level shapes
let mut valid_ids: Vec<Uuid> = ids
.iter()
.filter(|id| root_ids_map.contains_key(id))
.copied()
.collect();
// These shapes for the tile should be ordered as they are in the parent node
valid_ids.sort_by_key(|id| root_ids_map.get(id).unwrap_or(&usize::MAX));
// We only need first level shapes, in the same order as the parent node
let mut valid_ids = Vec::with_capacity(ids.len());
for root_id in root_ids.iter() {
if ids.contains(root_id) {
valid_ids.push(*root_id);
}
}
self.pending_nodes.extend(valid_ids.into_iter().map(|id| {
NodeRenderState {

View File

@@ -18,7 +18,7 @@ fn draw_image_fill(
}
let size = image.unwrap().dimensions();
let canvas = render_state.surfaces.canvas(surface_id);
let canvas = render_state.surfaces.canvas_and_mark_dirty(surface_id);
let container = &shape.selrect;
let path_transform = shape.to_path_transform();

View File

@@ -41,7 +41,7 @@ where
F: FnOnce(&mut RenderState, SurfaceId),
{
if let Some((image, scale)) = render_into_filter_surface(render_state, bounds, draw_fn) {
let canvas = render_state.surfaces.canvas(target_surface);
let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface);
// If we scaled down, we need to scale the source rect and adjust the destination
if scale < 1.0 {

View File

@@ -135,7 +135,7 @@ pub fn render_text_shadows(
let canvas = render_state
.surfaces
.canvas(surface_id.unwrap_or(SurfaceId::TextDropShadows));
.canvas_and_mark_dirty(surface_id.unwrap_or(SurfaceId::TextDropShadows));
for shadow in shadows {
let shadow_layer = SaveLayerRec::default().paint(shadow);

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