mirror of
https://github.com/penpot/penpot.git
synced 2026-01-15 09:50:23 -05:00
Compare commits
20 Commits
eva-replac
...
nitrate-mo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afd5b98041 | ||
|
|
99e045248c | ||
|
|
02915a834d | ||
|
|
ddbbeb487c | ||
|
|
eb5339a637 | ||
|
|
5de280ef09 | ||
|
|
e1f48c54b6 | ||
|
|
b9cb507bbd | ||
|
|
7509f36424 | ||
|
|
dc6f6c4589 | ||
|
|
88872f6842 | ||
|
|
0472af69ae | ||
|
|
214b0efa02 | ||
|
|
661436ecae | ||
|
|
0d5fe6e527 | ||
|
|
e7230d9da4 | ||
|
|
5054f6bc38 | ||
|
|
68f5671eab | ||
|
|
92976143bb | ||
|
|
fd675e0194 |
24
.github/workflows/tests.yml
vendored
24
.github/workflows/tests.yml
vendored
@@ -21,7 +21,7 @@ concurrency:
|
||||
jobs:
|
||||
lint:
|
||||
name: "Linter"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
steps:
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
|
||||
test-common:
|
||||
name: "Common Tests"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
steps:
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
test-plugins:
|
||||
name: Plugins Runtime Linter & Tests
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: penpot-runner-02
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
|
||||
test-frontend:
|
||||
name: "Frontend Tests"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
steps:
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
|
||||
test-render-wasm:
|
||||
name: "Render WASM Tests"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
steps:
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
|
||||
test-backend:
|
||||
name: "Backend Tests"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
services:
|
||||
@@ -182,7 +182,7 @@ jobs:
|
||||
|
||||
test-library:
|
||||
name: "Library Tests"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
steps:
|
||||
@@ -196,7 +196,7 @@ jobs:
|
||||
|
||||
build-integration:
|
||||
name: "Build Integration Bundle"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
steps:
|
||||
@@ -217,7 +217,7 @@ jobs:
|
||||
|
||||
test-integration-1:
|
||||
name: "Integration Tests 1/4"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
needs: build-integration
|
||||
|
||||
@@ -247,7 +247,7 @@ jobs:
|
||||
|
||||
test-integration-2:
|
||||
name: "Integration Tests 2/4"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
needs: build-integration
|
||||
|
||||
@@ -277,7 +277,7 @@ jobs:
|
||||
|
||||
test-integration-3:
|
||||
name: "Integration Tests 3/4"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
needs: build-integration
|
||||
|
||||
@@ -307,7 +307,7 @@ jobs:
|
||||
|
||||
test-integration-4:
|
||||
name: "Integration Tests 4/4"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
needs: build-integration
|
||||
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -21,6 +21,7 @@
|
||||
.rebel_readline_history
|
||||
.repl
|
||||
.shadow-cljs
|
||||
.pnpm-store/
|
||||
/*.jpg
|
||||
/*.md
|
||||
/*.png
|
||||
@@ -72,6 +73,7 @@
|
||||
/library/target/
|
||||
/library/*.zip
|
||||
/external
|
||||
/penpot-nitrate
|
||||
|
||||
clj-profiler/
|
||||
node_modules
|
||||
|
||||
@@ -13,10 +13,14 @@
|
||||
- Remap references when renaming tokens [Taiga #10202](https://tree.taiga.io/project/penpot/us/10202)
|
||||
- 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)
|
||||
|
||||
### :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)
|
||||
|
||||
|
||||
## 2.13.0 (Unreleased)
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@ export PENPOT_FLAGS="\
|
||||
enable-file-validation \
|
||||
enable-file-schema-validation \
|
||||
enable-redis-cache \
|
||||
enable-subscriptions";
|
||||
enable-subscriptions \
|
||||
enable-nitrate";
|
||||
|
||||
# Default deletion delay for devenv
|
||||
export PENPOT_DELETION_DELAY="24h"
|
||||
@@ -55,6 +56,8 @@ export PENPOT_OBJECTS_STORAGE_BACKEND=s3
|
||||
export PENPOT_OBJECTS_STORAGE_S3_ENDPOINT=http://minio:9000
|
||||
export PENPOT_OBJECTS_STORAGE_S3_BUCKET=penpot
|
||||
|
||||
export PENPOT_NITRATE_BACKEND_URI=http://localhost:3000
|
||||
|
||||
export JAVA_OPTS="\
|
||||
-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \
|
||||
-Djdk.attach.allowAttachSelf \
|
||||
|
||||
@@ -225,6 +225,8 @@
|
||||
[: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]
|
||||
|
||||
@@ -323,6 +323,7 @@
|
||||
{::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)
|
||||
@@ -339,6 +340,9 @@
|
||||
::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)
|
||||
@@ -348,6 +352,7 @@
|
||||
::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)}
|
||||
|
||||
|
||||
123
backend/src/app/nitrate.clj
Normal file
123
backend/src/app/nitrate.clj
Normal file
@@ -0,0 +1,123 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.nitrate
|
||||
"Module that make calls to the external nitrate aplication"
|
||||
(:require
|
||||
[app.common.logging :as l]
|
||||
[app.common.schema :as sm]
|
||||
[app.config :as cf]
|
||||
[app.http.client :as http]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.setup :as-alias setup]
|
||||
[app.util.json :as json]
|
||||
[clojure.core :as c]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
|
||||
(def baseuri (cf/get :nitrate-backend-uri))
|
||||
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; HELPERS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- coercer
|
||||
[schema & {:as opts}]
|
||||
(let [decode-fn (sm/decoder schema sm/json-transformer)
|
||||
check-fn (sm/check-fn schema opts)]
|
||||
(fn [data]
|
||||
(-> data decode-fn check-fn))))
|
||||
|
||||
|
||||
(defn- request-builder
|
||||
[cfg method uri management-key profile-id]
|
||||
(fn []
|
||||
(http/req! cfg {:method method
|
||||
:headers {"content-type" "application/json"
|
||||
"accept" "application/json"
|
||||
"x-shared-key" management-key
|
||||
"x-profile-id" (str profile-id)}
|
||||
:uri uri
|
||||
:version :http1.1})))
|
||||
|
||||
|
||||
(defn- with-retries
|
||||
[handler max-retries]
|
||||
(fn []
|
||||
(loop [attempt 1]
|
||||
(let [result (try
|
||||
(handler)
|
||||
(catch Exception e
|
||||
(if (< attempt max-retries)
|
||||
::retry
|
||||
(do
|
||||
;; TODO Error handling
|
||||
(l/error :hint "request fail after multiple retries" :cause e)
|
||||
nil))))]
|
||||
(if (= result ::retry)
|
||||
(recur (inc attempt))
|
||||
result)))))
|
||||
|
||||
|
||||
(defn- with-validate [handler uri schema]
|
||||
(fn []
|
||||
(let [coercer-http (coercer schema
|
||||
:type :validation
|
||||
:hint (str "invalid data received calling " uri))]
|
||||
(try
|
||||
(coercer-http (-> (handler) :body json/decode))
|
||||
(catch Exception e
|
||||
;; TODO Error handling
|
||||
(l/error :hint "error validating json response" :cause e)
|
||||
nil)))))
|
||||
|
||||
(defn- request-to-nitrate
|
||||
[{:keys [::management-key] :as cfg} method uri schema {:keys [::rpc/profile-id] :as params}]
|
||||
(let [full-http-call (-> (request-builder cfg method uri management-key profile-id)
|
||||
(with-retries 3)
|
||||
(with-validate uri schema))]
|
||||
(full-http-call)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; API
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn call
|
||||
[cfg method params]
|
||||
(when (contains? cf/flags :nitrate)
|
||||
(let [client (get cfg ::client)
|
||||
method (get client method)]
|
||||
(method params))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private schema:organization
|
||||
[:map
|
||||
[:id ::sm/text]
|
||||
[:name ::sm/text]])
|
||||
|
||||
|
||||
(defn- get-team-org
|
||||
[cfg {:keys [team-id] :as params}]
|
||||
(request-to-nitrate cfg :get (str baseuri "/api/teams/" (str team-id)) schema:organization params))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; INITIALIZATION
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defmethod ig/init-key ::client
|
||||
[_ {:keys [::setup/props] :as cfg}]
|
||||
(if (contains? cf/flags :nitrate)
|
||||
(let [management-key (or (cf/get :management-api-key)
|
||||
(get props :management-key))
|
||||
cfg (assoc cfg ::management-key management-key)]
|
||||
{:get-team-org (partial get-team-org cfg)})
|
||||
{}))
|
||||
|
||||
(defmethod ig/halt-key! ::client
|
||||
[_ {:keys []}]
|
||||
(do :stuff))
|
||||
@@ -301,6 +301,7 @@
|
||||
(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 {}))))
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
[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]
|
||||
@@ -172,6 +173,12 @@
|
||||
(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)
|
||||
@@ -190,7 +197,9 @@
|
||||
::sm/params schema:get-teams}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(get-teams conn profile-id)))
|
||||
(cond->> (get-teams conn profile-id)
|
||||
(contains? cf/flags :nitrate)
|
||||
(map #(add-org-to-team cfg % params)))))
|
||||
|
||||
(def ^:private sql:get-owned-teams
|
||||
"SELECT t.id, t.name,
|
||||
|
||||
112
backend/src/app/rpc/management/nitrate.clj
Normal file
112
backend/src/app/rpc/management/nitrate.clj
Normal file
@@ -0,0 +1,112 @@
|
||||
;; 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}))))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -526,20 +526,25 @@
|
||||
ids))
|
||||
|
||||
(defn clean-loops
|
||||
"Clean a list of ids from circular references."
|
||||
"Clean a list of ids from circular references. Optimized fast-path for single selections."
|
||||
[objects ids]
|
||||
(let [parent-selected?
|
||||
(fn [id]
|
||||
(let [parents (get-parent-ids objects id)]
|
||||
(some ids parents)))
|
||||
(if (<= (count ids) 1)
|
||||
;; For single selection, there can't be circularity; return as ordered-set.
|
||||
(into (d/ordered-set) ids)
|
||||
(let [ids-set (if (set? ids) ids (set ids))
|
||||
parent-selected?
|
||||
(fn [id]
|
||||
;; Stop early as soon as we find any selected parent
|
||||
(let [parents (get-parent-ids objects id)]
|
||||
(some #(contains? ids-set %) parents)))
|
||||
|
||||
add-element
|
||||
(fn [result id]
|
||||
(cond-> result
|
||||
(not (parent-selected? id))
|
||||
(conj id)))]
|
||||
add-element
|
||||
(fn [result id]
|
||||
(cond-> result
|
||||
(not (parent-selected? id))
|
||||
(conj id)))]
|
||||
|
||||
(reduce add-element (d/ordered-set) ids)))
|
||||
(reduce add-element (d/ordered-set) ids))))
|
||||
|
||||
(defn- indexed-shapes
|
||||
"Retrieves a vector with the indexes for each element in the layer
|
||||
|
||||
@@ -145,7 +145,10 @@
|
||||
|
||||
;; A temporal flag, enables backend code use more extensivelly
|
||||
;; redis for caching data
|
||||
:redis-cache})
|
||||
:redis-cache
|
||||
|
||||
;; Activates the nitrate module
|
||||
:nitrate})
|
||||
|
||||
(def all-flags
|
||||
(set/union email login varia))
|
||||
|
||||
@@ -487,7 +487,6 @@
|
||||
:vertical-margin #{:spacing :dimensions}
|
||||
:sided-margins #{:spacing :dimensions}
|
||||
:line-height #{:line-height :number}
|
||||
:opacity #{:opacity}
|
||||
:font-size #{:font-size}
|
||||
:letter-spacing #{:letter-spacing}
|
||||
:fill #{:color}
|
||||
|
||||
@@ -141,8 +141,14 @@ http {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
}
|
||||
|
||||
location /nitrate/ {
|
||||
proxy_pass http://127.0.0.1:3000/;
|
||||
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 /wasm-playground {
|
||||
|
||||
@@ -41,4 +41,9 @@ tmux select-window -t penpot:3
|
||||
tmux send-keys -t penpot 'cd penpot/backend' enter C-l
|
||||
tmux send-keys -t penpot './scripts/start-dev' enter
|
||||
|
||||
tmux new-window -t penpot:5 -n 'nitrate'
|
||||
tmux select-window -t penpot:5
|
||||
tmux send-keys -t penpot 'cd penpot/penpot-nitrate' enter C-l
|
||||
tmux send-keys -t penpot 'pnpm dev --host' enter
|
||||
|
||||
tmux -2 attach-session -t penpot
|
||||
|
||||
@@ -29,8 +29,9 @@ 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_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE" \
|
||||
envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_NITRATE_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)"
|
||||
|
||||
@@ -139,6 +139,14 @@ 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 / {
|
||||
|
||||
@@ -101,70 +101,6 @@ 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);
|
||||
@@ -193,6 +129,189 @@ 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,
|
||||
}) => {
|
||||
|
||||
@@ -1008,41 +1008,6 @@ 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 } =
|
||||
@@ -1291,58 +1256,6 @@ 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 edits typography token and all fields are valid", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 138 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 50 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 138 KiB |
@@ -683,12 +683,24 @@
|
||||
(rx/of (dcm/change-team-role params)
|
||||
(modal/hide)))))
|
||||
|
||||
(defn handle-change-team-org
|
||||
[{:keys [team-id organization-id organization-name] :as message}]
|
||||
(ptk/reify ::handle-change-team-org
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(if (contains? (:teams state) team-id)
|
||||
(-> state
|
||||
(assoc-in [:teams team-id :organization-id] organization-id)
|
||||
(assoc-in [:teams team-id :organization-name] organization-name))
|
||||
state))))
|
||||
|
||||
(defn- process-message
|
||||
[{:keys [type] :as msg}]
|
||||
(case type
|
||||
:notification (dcm/handle-notification msg)
|
||||
:team-role-change (handle-change-team-role msg)
|
||||
:team-membership-change (dcm/team-membership-change msg)
|
||||
:team-org-change (handle-change-team-org msg)
|
||||
nil))
|
||||
|
||||
|
||||
|
||||
@@ -61,6 +61,11 @@
|
||||
;; Def micro-benchmark iterations
|
||||
(def micro-benchmark-iterations 1e6)
|
||||
|
||||
;; Performance logs
|
||||
(defonce ^:private longtask-observer* (atom nil))
|
||||
(defonce ^:private stall-timer* (atom nil))
|
||||
(defonce ^:private current-op* (atom nil))
|
||||
|
||||
;; --- CONTEXT
|
||||
|
||||
(defn- collect-context
|
||||
@@ -464,3 +469,72 @@
|
||||
(defn event
|
||||
[props]
|
||||
(ptk/data-event ::event props))
|
||||
|
||||
;; --- DEVTOOLS PERF LOGGING
|
||||
|
||||
(defn install-long-task-observer! []
|
||||
(when (and (some? (.-PerformanceObserver js/window)) (nil? @longtask-observer*))
|
||||
(let [observer (js/PerformanceObserver.
|
||||
(fn [list _]
|
||||
(doseq [entry (.getEntries list)]
|
||||
(let [dur (.-duration entry)
|
||||
start (.-startTime entry)
|
||||
attrib (.-attribution entry)
|
||||
attrib-count (when attrib (.-length attrib))
|
||||
first-attrib (when (and attrib-count (> attrib-count 0)) (aget attrib 0))
|
||||
attrib-name (when first-attrib (.-name first-attrib))
|
||||
attrib-ctype (when first-attrib (.-containerType first-attrib))
|
||||
attrib-cid (when first-attrib (.-containerId first-attrib))
|
||||
attrib-csrc (when first-attrib (.-containerSrc first-attrib))]
|
||||
|
||||
(.warn js/console (str "[perf] long task " (Math/round dur) "ms at " (Math/round start) "ms"
|
||||
(when first-attrib
|
||||
(str " attrib:name=" attrib-name
|
||||
" ctype=" attrib-ctype
|
||||
" cid=" attrib-cid
|
||||
" csrc=" attrib-csrc))))))))]
|
||||
(.observe observer #js{:entryTypes #js["longtask"]})
|
||||
(reset! longtask-observer* observer))))
|
||||
|
||||
(defn start-event-loop-stall-logger!
|
||||
"Log event loop stalls by measuring setInterval drift.
|
||||
interval-ms: base interval
|
||||
threshold-ms: drift over which we report"
|
||||
[interval-ms threshold-ms]
|
||||
(when (nil? @stall-timer*)
|
||||
(let [last (atom (.now js/performance))
|
||||
id (js/setInterval
|
||||
(fn []
|
||||
(let [now (.now js/performance)
|
||||
expected (+ @last interval-ms)
|
||||
drift (- now expected)
|
||||
current-op @current-op*
|
||||
measures (.getEntriesByType js/performance "measure")
|
||||
mlen (.-length measures)
|
||||
last-measure (when (> mlen 0) (aget measures (dec mlen)))
|
||||
meas-name (when last-measure (.-name last-measure))
|
||||
meas-detail (when last-measure (.-detail last-measure))
|
||||
meas-count (when meas-detail (unchecked-get meas-detail "count"))]
|
||||
(reset! last now)
|
||||
(when (> drift threshold-ms)
|
||||
(.warn js/console
|
||||
(str "[perf] event loop stall: " (Math/round drift) "ms"
|
||||
(when current-op (str " op=" current-op))
|
||||
(when meas-name (str " last=" meas-name))
|
||||
(when meas-count (str " count=" meas-count)))))))
|
||||
interval-ms)]
|
||||
(reset! stall-timer* id))))
|
||||
|
||||
(defn init!
|
||||
"Install perf observers in dev builds. Safe to call multiple times."
|
||||
[]
|
||||
(when ^boolean js/goog.DEBUG
|
||||
(install-long-task-observer!)
|
||||
(start-event-loop-stall-logger! 50 100)
|
||||
;; Expose simple API on window for manual control in devtools
|
||||
(let [api #js {:reset (fn []
|
||||
(try
|
||||
(.clearMarks js/performance)
|
||||
(.clearMeasures js/performance)
|
||||
(catch :default _ nil)))}]
|
||||
(aset js/window "PenpotPerf" api))))
|
||||
|
||||
@@ -347,6 +347,12 @@
|
||||
(with-meta {:team-id team-id
|
||||
:file-id file-id}))))))
|
||||
|
||||
;; Install dev perf observers once the workspace is ready
|
||||
(->> stream
|
||||
(rx/filter (ptk/type? ::workspace-initialized))
|
||||
(rx/take 1)
|
||||
(rx/map (fn [_] (ev/init!))))
|
||||
|
||||
(->> stream
|
||||
(rx/filter (ptk/type? ::dps/persistence-notification))
|
||||
(rx/take 1)
|
||||
|
||||
@@ -18,13 +18,13 @@
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(let [expand-fn (fn [expanded]
|
||||
(merge expanded
|
||||
(->> ids
|
||||
(map #(cfh/get-parent-ids objects %))
|
||||
flatten
|
||||
(remove #(= % uuid/zero))
|
||||
(map (fn [id] {id true}))
|
||||
(into {}))))]
|
||||
(let [parents-seqs (map (fn [x] (cfh/get-parent-ids objects x)) ids)
|
||||
flat-parents (apply concat parents-seqs)
|
||||
non-root-parents (remove #(= % uuid/zero) flat-parents)
|
||||
distinct-parents (into #{} non-root-parents)]
|
||||
(merge expanded
|
||||
(into {}
|
||||
(map (fn [id] {id true}) distinct-parents)))))]
|
||||
(update-in state [:workspace-local :expanded] expand-fn)))))
|
||||
|
||||
|
||||
|
||||
@@ -264,10 +264,13 @@
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [objects (dsh/lookup-page-objects state)]
|
||||
(rx/of
|
||||
(dwc/expand-all-parents ids objects)
|
||||
::dwsp/interrupt)))))
|
||||
(let [objects (dsh/lookup-page-objects state)
|
||||
;; Schedule expanding parents asynchronously to avoid blocking
|
||||
;; the event loop
|
||||
expand-s (->> (rx/of (dwc/expand-all-parents ids objects))
|
||||
(rx/observe-on :async))
|
||||
interrupt-s (rx/of ::dwsp/interrupt)]
|
||||
(rx/merge expand-s interrupt-s)))))
|
||||
|
||||
(defn select-all
|
||||
[]
|
||||
|
||||
@@ -305,7 +305,7 @@
|
||||
(l/derived #(dsh/lookup-shape % page-id shape-id) st/state =))
|
||||
|
||||
(def workspace-page-objects
|
||||
(l/derived dsh/lookup-page-objects st/state))
|
||||
(l/derived dsh/lookup-page-objects st/state identical?))
|
||||
|
||||
(def workspace-read-only?
|
||||
(l/derived :read-only? workspace-global))
|
||||
|
||||
@@ -280,8 +280,8 @@
|
||||
|
||||
(mf/defc teams-selector-dropdown*
|
||||
{::mf/private true}
|
||||
[{:keys [team profile teams] :rest props}]
|
||||
(let [on-create-click
|
||||
[{:keys [team profile teams show-default-team allow-create-teams allow-create-org] :rest props}]
|
||||
(let [on-create-team-click
|
||||
(mf/use-fn #(st/emit! (modal/show :team-form {})))
|
||||
|
||||
on-team-click
|
||||
@@ -290,18 +290,25 @@
|
||||
(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)))))]
|
||||
(st/emit! (dcm/go-to-dashboard-recent :team-id team-id)))))
|
||||
|
||||
on-create-org-click
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
;; TODO update when org creation route is ready
|
||||
(dom/open-new-window "localhost:3000/org/create")))]
|
||||
|
||||
[:> dropdown-menu* props
|
||||
|
||||
[:> 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]
|
||||
(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]
|
||||
|
||||
[: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
|
||||
@@ -322,11 +329,19 @@
|
||||
(when (= (:id team-item) (:id team))
|
||||
tick-icon)])
|
||||
|
||||
[: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")]]]))
|
||||
(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")]])]))
|
||||
|
||||
(mf/defc team-options-dropdown*
|
||||
{::mf/private true}
|
||||
@@ -476,9 +491,80 @@
|
||||
: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 [teams (mf/deref refs/teams)
|
||||
(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)))
|
||||
|
||||
subscription
|
||||
(get team :subscription)
|
||||
@@ -586,7 +672,10 @@
|
||||
:class (stl/css :dropdown :teams-dropdown)
|
||||
:team team
|
||||
:profile profile
|
||||
:teams teams}]
|
||||
:teams teams
|
||||
:show-default-team true
|
||||
:allow-create-teams true
|
||||
:allow-create-org false}]
|
||||
|
||||
[:> team-options-dropdown* {:show show-team-options-menu?
|
||||
:on-close close-team-options-menu
|
||||
@@ -703,6 +792,8 @@
|
||||
[:*
|
||||
[: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
|
||||
|
||||
@@ -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 name]
|
||||
property align ref]
|
||||
:rest props}]
|
||||
|
||||
(let [;; NOTE: we use mfu/bean here for transparently handle
|
||||
@@ -662,10 +662,7 @@
|
||||
label (get token :name)
|
||||
token-value (or (get token :resolved-value)
|
||||
(or (mf/ref-val last-value*)
|
||||
(fmt/format-number value)))
|
||||
token-value (if (= name :opacity)
|
||||
(* 100 token-value)
|
||||
token-value)]
|
||||
(fmt/format-number value)))]
|
||||
(mf/spread-props props
|
||||
{:id id
|
||||
:label label
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
@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);
|
||||
@@ -16,32 +15,32 @@
|
||||
--options-dropdown-border-color: var(--color-background-quaternary);
|
||||
|
||||
position: absolute;
|
||||
inset-block-start: $sz-36;
|
||||
inline-size: var(--dropdown-width, 100%);
|
||||
top: $sz-36;
|
||||
width: 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-block-size: $sz-400;
|
||||
max-height: $sz-400;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
z-index: var(--z-index-dropdown);
|
||||
}
|
||||
|
||||
.left-align {
|
||||
inset-inline-start: var(--dropdown-offset, 0);
|
||||
left: var(--dropdown-offset, 0);
|
||||
}
|
||||
|
||||
.right-align {
|
||||
inset-inline-end: var(--dropdown-offset, 0);
|
||||
right: var(--dropdown-offset, 0);
|
||||
}
|
||||
|
||||
.option-separator {
|
||||
border: $b-1 solid var(--options-dropdown-border-color);
|
||||
margin-block-start: var(--sp-xs);
|
||||
margin-block-end: var(--sp-xs);
|
||||
margin-top: var(--sp-xs);
|
||||
margin-bottom: var(--sp-xs);
|
||||
}
|
||||
|
||||
.group-option,
|
||||
@@ -52,11 +51,11 @@
|
||||
gap: var(--sp-xs);
|
||||
color: var(--color-foreground-secondary);
|
||||
padding-inline: var(--sp-s);
|
||||
block-size: var(--sp-xxxl);
|
||||
height: var(--sp-xxxl);
|
||||
}
|
||||
|
||||
.option-empty {
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 0 px2rem(40);
|
||||
padding: 0 40px;
|
||||
}
|
||||
|
||||
@@ -33,9 +33,24 @@
|
||||
[okulary.core :as l]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
;; Coalesce sidebar hover highlights to 1 frame to avoid long tasks
|
||||
(defonce ^:private sidebar-hover-queue (atom {:enter #{} :leave #{}}))
|
||||
(defonce ^:private sidebar-hover-pending? (atom false))
|
||||
|
||||
(defn- schedule-sidebar-hover-flush []
|
||||
(when (compare-and-set! sidebar-hover-pending? false true)
|
||||
(ts/raf
|
||||
(fn []
|
||||
(let [{:keys [enter leave]} (swap! sidebar-hover-queue (constantly {:enter #{} :leave #{}}))]
|
||||
(reset! sidebar-hover-pending? false)
|
||||
(when (seq leave)
|
||||
(apply st/emit! (map dw/dehighlight-shape leave)))
|
||||
(when (seq enter)
|
||||
(apply st/emit! (map dw/highlight-shape enter))))))))
|
||||
|
||||
(mf/defc layer-item-inner
|
||||
{::mf/wrap-props false}
|
||||
[{:keys [item depth parent-size name-ref children ref
|
||||
[{:keys [item depth parent-size name-ref children ref style
|
||||
;; Flags
|
||||
read-only? highlighted? selected? component-tree?
|
||||
filtered? expanded? dnd-over? dnd-over-top? dnd-over-bot? hide-toggle?
|
||||
@@ -82,7 +97,8 @@
|
||||
:dnd-over dnd-over?
|
||||
:dnd-over-top dnd-over-top?
|
||||
:dnd-over-bot dnd-over-bot?
|
||||
:root-board parent-board?)}
|
||||
:root-board parent-board?)
|
||||
:style style}
|
||||
[:span {:class (stl/css-case
|
||||
:tab-indentation true
|
||||
:filtered filtered?)
|
||||
@@ -165,10 +181,12 @@
|
||||
|
||||
children]))
|
||||
|
||||
;; Memoized for performance
|
||||
(mf/defc layer-item
|
||||
{::mf/props :obj
|
||||
::mf/memo true}
|
||||
[{:keys [index item selected objects sortable? filtered? depth parent-size component-child? highlighted]}]
|
||||
::mf/wrap [mf/memo]}
|
||||
[{:keys [index item selected objects sortable? filtered? depth parent-size component-child? highlighted style render-children?]
|
||||
:or {render-children? true}}]
|
||||
(let [id (:id item)
|
||||
blocked? (:blocked item)
|
||||
hidden? (:hidden item)
|
||||
@@ -245,13 +263,21 @@
|
||||
(mf/use-fn
|
||||
(mf/deps id)
|
||||
(fn [_]
|
||||
(st/emit! (dw/highlight-shape id))))
|
||||
(swap! sidebar-hover-queue (fn [{:keys [enter leave] :as q}]
|
||||
(-> q
|
||||
(assoc :enter (conj enter id))
|
||||
(assoc :leave (disj leave id)))))
|
||||
(schedule-sidebar-hover-flush)))
|
||||
|
||||
on-pointer-leave
|
||||
(mf/use-fn
|
||||
(mf/deps id)
|
||||
(fn [_]
|
||||
(st/emit! (dw/dehighlight-shape id))))
|
||||
(swap! sidebar-hover-queue (fn [{:keys [enter leave] :as q}]
|
||||
(-> q
|
||||
(assoc :enter (disj enter id))
|
||||
(assoc :leave (conj leave id)))))
|
||||
(schedule-sidebar-hover-flush)))
|
||||
|
||||
on-context-menu
|
||||
(mf/use-fn
|
||||
@@ -337,14 +363,18 @@
|
||||
component-tree? (or component-child? (ctk/instance-root? item) (ctk/instance-head? item))
|
||||
|
||||
enable-drag (mf/use-fn #(reset! drag-disabled* false))
|
||||
disable-drag (mf/use-fn #(reset! drag-disabled* true))]
|
||||
disable-drag (mf/use-fn #(reset! drag-disabled* true))
|
||||
|
||||
;; Lazy loading of child elements via IntersectionObserver
|
||||
children-count* (mf/use-state 0)
|
||||
children-count (deref children-count*)
|
||||
lazy-ref (mf/use-ref nil)
|
||||
observer-var (mf/use-var nil)
|
||||
chunk-size 50]
|
||||
|
||||
(mf/with-effect [selected? selected]
|
||||
(let [single? (= (count selected) 1)
|
||||
node (mf/ref-val ref)
|
||||
;; NOTE: Neither get-parent-at nor get-parent-with-selector
|
||||
;; work if the component template changes, so we need to
|
||||
;; seek for an alternate solution. Maybe use-context?
|
||||
scroll-node (dom/get-parent-with-data node "scroll-container")
|
||||
parent-node (dom/get-parent-at node 2)
|
||||
first-child-node (dom/get-first-child parent-node)
|
||||
@@ -362,6 +392,61 @@
|
||||
#(when (some? subid)
|
||||
(rx/dispose! subid))))
|
||||
|
||||
;; Setup scroll-driven lazy loading when expanded
|
||||
;; and ensures selected children are loaded immediately
|
||||
(mf/with-effect [expanded? (:shapes item) selected]
|
||||
(let [shapes-vec (:shapes item)
|
||||
total (count shapes-vec)]
|
||||
(if expanded?
|
||||
(let [;; Children are rendered in reverse order, so index 0 in render = last in shapes-vec
|
||||
;; Find if any selected id is a direct child and get its render index
|
||||
selected-child-render-idx
|
||||
(when (and (> total chunk-size) (seq selected))
|
||||
(let [shapes-reversed (vec (reverse shapes-vec))]
|
||||
(some (fn [sel-id]
|
||||
(let [idx (.indexOf shapes-reversed sel-id)]
|
||||
(when (>= idx 0) idx)))
|
||||
selected)))
|
||||
;; Load at least enough to include the selected child plus extra
|
||||
;; for context (so it can be centered in the scroll view)
|
||||
min-count (if selected-child-render-idx
|
||||
(+ selected-child-render-idx chunk-size)
|
||||
chunk-size)
|
||||
current @children-count*
|
||||
new-count (min total (max current chunk-size min-count))]
|
||||
(reset! children-count* new-count))
|
||||
(reset! children-count* 0)))
|
||||
(fn []
|
||||
(when-let [obs ^js @observer-var]
|
||||
(.disconnect obs)
|
||||
(reset! observer-var nil))))
|
||||
|
||||
;; Re-observe sentinel whenever children-count changes (sentinel moves)
|
||||
(mf/with-effect [children-count expanded?]
|
||||
(let [total (count (:shapes item))
|
||||
node (mf/ref-val ref)
|
||||
scroll-node (dom/get-parent-with-data node "scroll-container")
|
||||
lazy-node (mf/ref-val lazy-ref)]
|
||||
;; Disconnect previous observer
|
||||
(when-let [obs ^js @observer-var]
|
||||
(.disconnect obs)
|
||||
(reset! observer-var nil))
|
||||
;; Setup new observer if there are more children to load
|
||||
(when (and expanded?
|
||||
(< children-count total)
|
||||
scroll-node
|
||||
lazy-node)
|
||||
(let [cb (fn [entries]
|
||||
(when (and (seq entries)
|
||||
(.-isIntersecting (first entries)))
|
||||
;; Load next chunk when sentinel intersects
|
||||
(let [current @children-count*
|
||||
next-count (min total (+ current chunk-size))]
|
||||
(reset! children-count* next-count))))
|
||||
observer (js/IntersectionObserver. cb #js {:root scroll-node})]
|
||||
(.observe observer lazy-node)
|
||||
(reset! observer-var observer)))))
|
||||
|
||||
[:& layer-item-inner
|
||||
{:ref dref
|
||||
:item item
|
||||
@@ -386,24 +471,32 @@
|
||||
:on-enable-drag enable-drag
|
||||
:on-disable-drag disable-drag
|
||||
:on-toggle-visibility toggle-visibility
|
||||
:on-toggle-blocking toggle-blocking}
|
||||
:on-toggle-blocking toggle-blocking
|
||||
:style style}
|
||||
|
||||
(when (and (:shapes item) expanded?)
|
||||
(when (and render-children?
|
||||
(:shapes item)
|
||||
expanded?)
|
||||
[:div {:class (stl/css-case
|
||||
:element-children true
|
||||
:parent-selected selected?
|
||||
:sticky-children parent-board?)
|
||||
:data-testid (dm/str "children-" id)}
|
||||
(for [[index id] (reverse (d/enumerate (:shapes item)))]
|
||||
(when-let [item (get objects id)]
|
||||
[:& layer-item
|
||||
{:item item
|
||||
:highlighted highlighted
|
||||
:selected selected
|
||||
:index index
|
||||
:objects objects
|
||||
:key (dm/str id)
|
||||
:sortable? sortable?
|
||||
:depth depth
|
||||
:parent-size parent-size
|
||||
:component-child? component-tree?}]))])]))
|
||||
(let [all-children (reverse (d/enumerate (:shapes item)))
|
||||
visible (take children-count all-children)]
|
||||
(for [[index id] visible]
|
||||
(when-let [item (get objects id)]
|
||||
[:& layer-item
|
||||
{:item item
|
||||
:highlighted highlighted
|
||||
:selected selected
|
||||
:index index
|
||||
:objects objects
|
||||
:key (dm/str id)
|
||||
:sortable? sortable?
|
||||
:depth depth
|
||||
:parent-size parent-size
|
||||
:component-child? component-tree?}])))
|
||||
(when (< children-count (count (:shapes item)))
|
||||
[:div {:ref lazy-ref
|
||||
:style {:min-height 1}}])])]))
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
(-> (deref tokens)
|
||||
(select-keys (get tk/tokens-by-input name))
|
||||
(not-empty))))
|
||||
|
||||
on-detach-attr
|
||||
(mf/use-fn
|
||||
(mf/deps on-detach name)
|
||||
|
||||
@@ -9,17 +9,13 @@
|
||||
(: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 :as deprecated-input]
|
||||
[app.main.ui.components.numeric-input :refer [numeric-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]]
|
||||
@@ -43,16 +39,11 @@
|
||||
(defn- check-layer-menu-props
|
||||
[old-props new-props]
|
||||
(let [old-values (unchecked-get old-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")]
|
||||
new-values (unchecked-get new-props "values")]
|
||||
(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)
|
||||
@@ -62,54 +53,12 @@
|
||||
(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 applied-tokens]}]
|
||||
(let [token-numeric-inputs
|
||||
(features/use-feature "tokens/numeric-input")
|
||||
|
||||
hidden? (get values :hidden)
|
||||
[{:keys [ids values]}]
|
||||
(let [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))
|
||||
|
||||
@@ -169,17 +118,6 @@
|
||||
(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)
|
||||
@@ -238,9 +176,8 @@
|
||||
preview-complete?))
|
||||
(swap! state* assoc :selected-blend-mode current-blend-mode)))
|
||||
|
||||
[:section {:class (stl/css-case :element-set-content true
|
||||
:hidden hidden?)
|
||||
:aria-label "layer-menu-section"}
|
||||
[:div {:class (stl/css-case :element-set-content true
|
||||
:hidden hidden?)}
|
||||
[:div {:class (stl/css :select)}
|
||||
[:& select
|
||||
{:default-value selected-blend-mode
|
||||
@@ -250,34 +187,16 @@
|
||||
:class (stl/css-case :hidden-select hidden?)
|
||||
:on-pointer-enter-option handle-blend-mode-enter
|
||||
:on-pointer-leave-option handle-blend-mode-leave}]]
|
||||
|
||||
|
||||
|
||||
(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 :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)}]]
|
||||
|
||||
|
||||
[:div {:class (stl/css :actions)}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
@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;
|
||||
@@ -44,9 +43,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.numeric-input-wrapper {
|
||||
grid-column: span 2;
|
||||
--dropdown-width: var(--7-columns-dropdown-width);
|
||||
--dropdown-offset: #{px2rem(-35)};
|
||||
}
|
||||
|
||||
@@ -85,7 +85,6 @@
|
||||
[:*
|
||||
[:> layer-menu* {:ids ids
|
||||
:type type
|
||||
:applied-tokens applied-tokens
|
||||
:values layer-values}]
|
||||
|
||||
[:> measures-menu* {:ids ids
|
||||
|
||||
@@ -84,7 +84,6 @@
|
||||
[:*
|
||||
[:> layer-menu* {:ids ids
|
||||
:type type
|
||||
:applied-tokens applied-tokens
|
||||
:values layer-values}]
|
||||
|
||||
[:> measures-menu* {:ids ids
|
||||
|
||||
@@ -100,7 +100,6 @@
|
||||
[:*
|
||||
[:> layer-menu* {:ids ids
|
||||
:type shape-type
|
||||
:applied-tokens applied-tokens
|
||||
:values layer-values}]
|
||||
[:> measures-menu* {:ids ids
|
||||
:applied-tokens applied-tokens
|
||||
|
||||
@@ -111,7 +111,6 @@
|
||||
[: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
|
||||
|
||||
@@ -382,7 +382,7 @@
|
||||
objects
|
||||
objects)))
|
||||
|
||||
[layer-ids layer-values layer-tokens]
|
||||
[layer-ids layer-values]
|
||||
(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-container-tokens]
|
||||
[layout-container-ids layout-container-values layout-contianer-tokens]
|
||||
(get-attrs shapes objects :layout-container)
|
||||
|
||||
[layout-item-ids layout-item-values {}]
|
||||
@@ -442,7 +442,6 @@
|
||||
(when-not (empty? layer-ids)
|
||||
[:> layer-menu* {:type type
|
||||
:ids layer-ids
|
||||
:applied-tokens layer-tokens
|
||||
:values layer-values}])
|
||||
|
||||
(when-not (empty? measure-ids)
|
||||
@@ -460,7 +459,7 @@
|
||||
{:type type
|
||||
:ids layout-container-ids
|
||||
:values layout-container-values
|
||||
:applied-tokens layout-container-tokens
|
||||
:applied-tokens layout-contianer-tokens
|
||||
:multiple true}]
|
||||
|
||||
(when (or is-layout-child? has-flex-layout-container?)
|
||||
|
||||
@@ -84,7 +84,6 @@
|
||||
|
||||
[:*
|
||||
[:> layer-menu* {:ids ids
|
||||
:applied-tokens applied-tokens
|
||||
:type type
|
||||
:values layer-values}]
|
||||
[:> measures-menu* {:ids ids
|
||||
|
||||
@@ -85,7 +85,6 @@
|
||||
[:*
|
||||
[:> layer-menu* {:ids ids
|
||||
:type type
|
||||
:applied-tokens applied-tokens
|
||||
:values layer-values}]
|
||||
[:> measures-menu* {:ids ids
|
||||
:type type
|
||||
|
||||
@@ -125,7 +125,6 @@
|
||||
[:*
|
||||
[:> layer-menu* {:ids ids
|
||||
:type type
|
||||
:applied-tokens applied-tokens
|
||||
:values layer-values}]
|
||||
[:> measures-menu*
|
||||
{:ids ids
|
||||
|
||||
@@ -433,6 +433,9 @@ 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"
|
||||
|
||||
@@ -442,6 +442,9 @@ 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"
|
||||
|
||||
Reference in New Issue
Block a user