Compare commits

...

72 Commits

Author SHA1 Message Date
Marina López
16a067c0ae Add nitrate subscription plan card 2026-02-20 13:15:26 +01:00
Pablo Alba
90288e32d5 Show different info on nitrate dialog by connectivity 2026-02-20 10:19:25 +01:00
Luis de Dios
a82cf34d35 Merge pull request #8415 from oraios/mcp-prod
 MCP changes to improve handling of use cases 2 & 3
2026-02-19 16:01:10 +01:00
Alejandro Alonso
3f277b7daf Merge pull request #8416 from penpot/luis-revert-mcp-changes
Revert " MCP changes to improve handling of use cases 2 & 3…
2026-02-19 15:54:56 +01:00
Luis de Dios
21a1320f16 Revert " MCP changes to improve handling of use cases 2 & 3 (#8369)"
This reverts commit 0a54d25d5a.
2026-02-19 14:46:44 +01:00
Dominik Jain
0a54d25d5a MCP changes to improve handling of use cases 2 & 3 (#8369)
* 📎 Fix spelling errors

* 🚧 Temporary workaround for sizing options not working

Add instructions explaining that FlexLayout sizing options do not work.
Relates to https://github.com/penpot/penpot-mcp/issues/39

* 🚧 Temporary workaround for Token resolvedValue not working

Instruct LLM to not use this property.
To be reverted once #8341 is fixed.

*  Improve description of token values

*  Make clear that ExecuteCodeTool serialises automatically

LLMs sometimes decide to apply serialisation themselves, which is unnecessary,
and which this seeks to prevent.

* 🚧 Temporary workaround for fills/strokes being read-only

Add instructions to make the limintations.
Once #8357 is resolved, this can be reverted.

* ♻️ Move high-level instructions to the end

In this way, they can reasonably reference the more low-level concepts

* 📚 Add instructions on cloning and the branch to use

* 📚 Revise instructions on prerequisites

* Do not state that pnpm must be available after Node.js installation
  (it is installed by corepack)
* Do not state that caddy is required; it is required only when
  rebuilding the API documentation for the server, which is not
  a task relevant to regular users.
* Do not strongly suggest that MCP users should be using the devenv.
* Windows: Add pointer to use Git Bash

* 📚 Remove unnecessary details on what the boostrap script does

* 📚 Update information on repository structure

* 📚 Add section on 'Development' to README
2026-02-19 14:29:07 +01:00
Pablo Alba
a19860a77b Add nitrate popup 2026-02-19 12:08:47 +01:00
alonso.torres
426c8ea714 🐛 Fix type annotation for layoutCell property in plugins 2026-02-19 10:26:51 +01:00
alonso.torres
75e8d226d9 Add textBounds property in plugins 2026-02-19 10:26:51 +01:00
alonso.torres
d42f5db1f0 🐛 Fix problem with horizontalSizing/verticalSizing in plugins 2026-02-19 10:26:51 +01:00
alonso.torres
03d0c62de1 🐛 Send a keep alive message in websocket connection 2026-02-19 10:26:51 +01:00
alonso.torres
698852cbeb 🐛 Fix permissions for mcp plugin 2026-02-19 10:26:51 +01:00
Dominik Jain
7cf88359fa 📚 Add section on 'Development' to README 2026-02-18 20:22:34 +01:00
Dominik Jain
ea4c6c3998 📚 Update information on repository structure 2026-02-18 20:17:05 +01:00
alonso.torres
5cc5e8771e 🐛 Fix problem with tokens in plugins 2026-02-18 17:01:47 +01:00
Dominik Jain
f8dd02169c 📚 Remove unnecessary details on what the boostrap script does 2026-02-18 11:14:21 +01:00
Dominik Jain
ebdae2cf65 📚 Revise instructions on prerequisites
* Do not state that pnpm must be available after Node.js installation
  (it is installed by corepack)
* Do not state that caddy is required; it is required only when
  rebuilding the API documentation for the server, which is not
  a task relevant to regular users.
* Do not strongly suggest that MCP users should be using the devenv.
* Windows: Add pointer to use Git Bash
2026-02-18 11:11:25 +01:00
Dominik Jain
79d3469f36 📚 Add instructions on cloning and the branch to use 2026-02-18 10:56:21 +01:00
Andrey Antukh
942da56e78 Merge branch 'staging-render' into develop 2026-02-17 21:56:54 +01:00
Andrey Antukh
2b130c7e52 Merge branch 'staging' into staging-render 2026-02-17 21:54:23 +01:00
Elena Torró
c41b9214c5 Merge pull request #8387 from penpot/ladybenko-13415-fix-layout-lines
🐛 Fix grid overlay persisting after deleting board (wasm)
2026-02-17 17:38:52 +01:00
Elena Torró
fb80c8f45b Merge pull request #8383 from penpot/superalex-fix-text-stroke-bounds
🐛 Fix text stroke bounds
2026-02-17 17:35:38 +01:00
Elena Torró
009dc4485a Merge pull request #8375 from penpot/alotor-fix-flex-layout-problem
🐛 Fix problem with flex layout auto sizing
2026-02-17 17:25:02 +01:00
alonso.torres
b8f3bee3ac 🐛 Fix problem with flex layout auto sizing 2026-02-17 16:40:59 +01:00
Elena Torró
b28457860c Merge pull request #8388 from penpot/ladybenko-fix-cargo-clean
🔧 Run cargo clean on devenv start
2026-02-17 15:34:13 +01:00
Belén Albeza
23b268b414 🔧 Run cargo clean on devenv start 2026-02-17 15:08:30 +01:00
Elena Torró
32706a1460 Merge pull request #8386 from penpot/alotor-fix-watch-script
🐛 Fix watch script in wasm
2026-02-17 15:08:24 +01:00
Belén Albeza
cd4b9ddd47 Add regression test for bug 13415 2026-02-17 14:32:09 +01:00
alonso.torres
f0e3f1a319 🐛 Fix watch script in wasm 2026-02-17 14:27:36 +01:00
Dominik Jain
6a49b5df8c ♻️ Move high-level instructions to the end
In this way, they can reasonably reference the more low-level concepts
2026-02-17 13:16:21 +01:00
Alejandro Alonso
afb252f42e 🔧 Migrate text editor v2 tests to wasm viewport 2026-02-17 12:59:53 +01:00
Belén Albeza
4185a7a6f3 🐛 Fix grid layout lines persisted after board is deleted 2026-02-17 12:58:15 +01:00
Dominik Jain
141847585e 🚧 Temporary workaround for fills/strokes being read-only
Add instructions to make the limintations.
Once #8357 is resolved, this can be reverted.
2026-02-17 12:51:48 +01:00
Alejandro Alonso
0dda7bd9ee 🐛 Fix text stroke bounds 2026-02-17 12:25:32 +01:00
Serhii Shvets
2b34767b2b 🐛 Fix Alt/Option to draw shapes from center point
Use the Alt/Option key stream (mouse-position-alt) instead of
the Command/Meta stream (mouse-position-mod) so the modifier
is actually detected during shape drawing.

When Alt is held, mirror the mouse point around the initial
click so that the click becomes the center of the drawn shape.
This aligns drawing behavior with resizing (transforms.cljs)
and with other design tools (Figma, Sketch, Illustrator).

Closes #8360

Signed-of-by: Serhii Shvets <justone128@gmail.com>
2026-02-17 11:02:40 +01:00
Andrey Antukh
082c8adb1d 📎 Update changelog 2026-02-17 10:29:05 +01:00
Melvin Laplanche
6cfaeb8a44 🎉 Add woff2 support on user uploaded fonts
Signed-off-by: Melvin Laplanche <noreply@melvin.la>
2026-02-17 10:29:05 +01:00
Andrey Antukh
d192cf8893 Merge remote-tracking branch 'origin/staging-render' into develop 2026-02-17 10:01:42 +01:00
Andrey Antukh
7ef16a2b69 Merge remote-tracking branch 'origin/staging' into staging-render 2026-02-17 10:01:06 +01:00
Andrey Antukh
e6fde82609 📎 Add 2.15 to changelog 2026-02-17 10:00:07 +01:00
Andrey Antukh
ecc633efbe Merge remote-tracking branch 'origin/staging' into develop 2026-02-17 09:59:09 +01:00
Andrey Antukh
dafad0c124 Merge remote-tracking branch 'origin/staging-render' into develop 2026-02-17 09:57:51 +01:00
Andrey Antukh
71ec51919e Merge remote-tracking branch 'origin/staging' into staging-render 2026-02-17 09:55:16 +01:00
Elena Torró
1cb113dfeb Merge pull request #8379 from penpot/superalex-fix-grouped-component-shadow
🐛 Fix grouped component shadow
2026-02-17 09:54:37 +01:00
Elena Torró
b45aec13ab Merge pull request #8378 from penpot/superalex-fix-focus-mode-simple-component
🐛 Fix focus mode for simple component
2026-02-17 09:53:27 +01:00
Elena Torró
19592fadd8 Merge pull request #8374 from penpot/ladybenko-13385-fix-restore-version
🐛 Fix restore version not updating the canvas (wasm)
2026-02-17 09:50:05 +01:00
Luis de Dios
11690e7428 🐛 Fix copies in mcp server (#8370) 2026-02-17 09:21:09 +01:00
Alonso Torres
c32a336c50 🎉 Add MCP plugin embedded execution (#8368)
*  Add core changes for mcp server

*  Changes to plugins-runtime to add mcp extensions

*  Changes to MCP plugin

*  Changes post-review and ci fixes
2026-02-17 09:18:46 +01:00
Alejandro Alonso
0b2dfe7297 🐛 Fix grouped component shadow 2026-02-17 08:19:37 +01:00
Alejandro Alonso
fe6fb0534c 🐛 Fix focus mode for simple component 2026-02-17 07:23:09 +01:00
Pablo Alba
b87d7e3de0 Add create org button for nitrate 2026-02-16 19:43:26 +01:00
Alejandro Alonso
f2d09a6140 🐛 Preserving selection when applying styles to selected text range 2026-02-16 17:39:30 +01:00
Eva Marco
d09c909788 🐛 Fix input width on composite token form (#8365) 2026-02-16 17:08:33 +01:00
Belén Albeza
5ae2351e5a Add playwright test for bug 13385 2026-02-16 16:58:05 +01:00
Belén Albeza
b5f4ce0a71 🐛 Fix canvas not being re-rendered after restoring a file version 2026-02-16 16:58:05 +01:00
Yamila Moreno
9fa77cd06c 🔧 Add workflow_dispatch to staging, render and tag builds 2026-02-16 15:38:38 +01:00
Yamila Moreno
8c5ce4d318 🔧 Add workflow_dispatch to develop builds 2026-02-16 12:22:09 +01:00
Luis de Dios
3c0df27fe0 🎉 Add MCP server to integrations section in dashboard (#8169) 2026-02-16 11:17:52 +01:00
Andrey Antukh
a278d54429 🎉 Add copy as image to clipboard menu option (#8364)
*  Copy as image

Function to copy a board directly to the clipboard.
This is exposed on the Copy/Paste as... context menu.

The image is always copied at 2x to work well with wireframes. I tried
with and without Retina display and it is better in both scenarios.

Signed-off-by: Dalai Felinto <dalai@blender.org>

*  Add minor adjustments on promise creation

* 🔥 Remove prn from obj/reify macros

---------

Signed-off-by: Dalai Felinto <dalai@blender.org>
2026-02-16 11:17:02 +01:00
Andrey Antukh
a1cc016727 🔥 Remove prn from obj/reify macros 2026-02-16 11:05:57 +01:00
Pablo Alba
3d38aeb089 Add nitrate banner 2026-02-16 10:52:59 +01:00
Pablo Alba
43725a4abe 🐛 Fix unable to finish the create account form using keyboard (#8273)
* 🐛 Fix unable to finish the create account form using keyboard

* 📎 Prefer dom/click over dom/click!

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-02-16 10:49:51 +01:00
Andrey Antukh
a0236e8c7e Merge pull request #8335 from penpot/dfelinto-download-font
 Add option for download used custom fonts
2026-02-16 10:44:57 +01:00
Andrey Antukh
caccf72c7f Add better approach for error handling to obj/reify 2026-02-16 10:44:13 +01:00
Andrey Antukh
60ecb901b2 Make the obj/proxy object do not extend js/Object directly 2026-02-16 10:44:13 +01:00
Andrey Antukh
fbf1240998 Add several optimizations for fonts zip download
Mainly prevent hold the whole zip in memory and uses an
unified response type, leavin frontend fetching the blob
data from the assets/storage subsystem.
2026-02-16 10:14:50 +01:00
Dalai Felinto
c55c23c6dd Add option to download user uploaded custom fonts
Allow users download any of the manually installed fonts.
When there is more than one font in the family download as a .zip.

Signed-off-by: Dalai Felinto <dalai@blender.org>
2026-02-16 10:14:49 +01:00
Dominik Jain
7a52550889 Make clear that ExecuteCodeTool serialises automatically
LLMs sometimes decide to apply serialisation themselves, which is unnecessary,
and which this seeks to prevent.
2026-02-15 22:20:38 +01:00
Dominik Jain
08fc6fe917 Improve description of token values 2026-02-12 17:45:50 +01:00
Dominik Jain
926d573d3e 🚧 Temporary workaround for Token resolvedValue not working
Instruct LLM to not use this property.
To be reverted once #8341 is fixed.
2026-02-12 17:24:44 +01:00
Dominik Jain
bac04f8a73 🚧 Temporary workaround for sizing options not working
Add instructions explaining that FlexLayout sizing options do not work.
Relates to https://github.com/penpot/penpot-mcp/issues/39
2026-02-12 12:37:24 +01:00
Dominik Jain
b4e815e787 📎 Fix spelling errors 2026-02-12 12:36:51 +01:00
126 changed files with 14955 additions and 1768 deletions

View File

@@ -1,6 +1,7 @@
name: _DEVELOP name: _DEVELOP
on: on:
workflow_dispatch:
schedule: schedule:
- cron: '16 5-20 * * 1-5' - cron: '16 5-20 * * 1-5'

View File

@@ -1,6 +1,7 @@
name: _STAGING RENDER name: _STAGING RENDER
on: on:
workflow_dispatch:
schedule: schedule:
- cron: '36 5-20 * * 1-5' - cron: '36 5-20 * * 1-5'

View File

@@ -1,6 +1,7 @@
name: _STAGING name: _STAGING
on: on:
workflow_dispatch:
schedule: schedule:
- cron: '36 5-20 * * 1-5' - cron: '36 5-20 * * 1-5'

View File

@@ -1,6 +1,7 @@
name: _TAG name: _TAG
on: on:
workflow_dispatch:
push: push:
tags: tags:
- '*' - '*'

View File

@@ -1,5 +1,25 @@
# CHANGELOG # CHANGELOG
## 2.15.0 (Unreleased)
### :boom: Breaking changes & Deprecations
### :rocket: Epics and highlights
### :heart: Community contributions (Thank you!)
### :sparkles: New features & Enhancements
- Add MCP server integration [Taiga #13112](https://tree.taiga.io/project/penpot/us/13112), [Taiga #13114](https://tree.taiga.io/project/penpot/us/13114)
- Add woff2 support on user uploaded fonts (by @Nivl) [Github #8248](https://github.com/penpot/penpot/pull/8248)
- Option to download custom fonts (by @dfelinto) [Github #8320](https://github.com/penpot/penpot/issues/8320)
- Add copy as image to clipboard option to workspace context menu (by @dfelinto) [Github #8313](https://github.com/penpot/penpot/pull/8313)
### :bug: Bugs fixed
- Fix Alt/Option to draw shapes from center point (by @offreal) [Github #8361](https://github.com/penpot/penpot/pull/8361)
## 2.14.0 (Unreleased) ## 2.14.0 (Unreleased)
### :sparkles: New features & Enhancements ### :sparkles: New features & Enhancements
@@ -28,6 +48,7 @@
- Fix boolean operators in menu for boards [Taiga #13174](https://tree.taiga.io/project/penpot/issue/13174) - Fix boolean operators in menu for boards [Taiga #13174](https://tree.taiga.io/project/penpot/issue/13174)
- Fix viewer can update library [Taiga #13186](https://tree.taiga.io/project/penpot/issue/13186) - Fix viewer can update library [Taiga #13186](https://tree.taiga.io/project/penpot/issue/13186)
- Fix remove fill affects different element than selected [Taiga #13128](https://tree.taiga.io/project/penpot/issue/13128) - Fix remove fill affects different element than selected [Taiga #13128](https://tree.taiga.io/project/penpot/issue/13128)
- Fix unable to finish the create account form using keyboard [Taiga #11333](https://tree.taiga.io/project/penpot/issue/11333)
## 2.13.3 ## 2.13.3

View File

@@ -53,6 +53,7 @@
::yres/status 200 ::yres/status 200
::yres/body (yres/stream-body ::yres/body (yres/stream-body
(fn [_ output] (fn [_ output]
(let [channel (sp/chan :buf buf :xf (keep encode)) (let [channel (sp/chan :buf buf :xf (keep encode))
listener (events/spawn-listener listener (events/spawn-listener
channel channel

View File

@@ -54,7 +54,7 @@
[:path ::fs/path] [:path ::fs/path]
[:mtype {:optional true} ::sm/text]]) [:mtype {:optional true} ::sm/text]])
(def ^:private check-input (def check-input
(sm/check-fn schema:input)) (sm/check-fn schema:input))
(defn validate-media-type! (defn validate-media-type!
@@ -381,6 +381,22 @@
(when (zero? (:exit res)) (when (zero? (:exit res))
(:out res)))) (:out res))))
(woff2->sfnt [data]
;; woff2_decompress outputs to same directory with .ttf extension
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix ".woff2")
foutput (fs/path (str/replace (str finput) #"\.woff2$" ".ttf"))]
(try
(io/write* finput data)
(let [res (sh/sh "woff2_decompress" (str finput))]
(if (zero? (:exit res))
foutput
(do
(when (fs/exists? foutput)
(fs/delete foutput))
nil)))
(finally
(fs/delete finput)))))
;; Documented here: ;; Documented here:
;; https://docs.microsoft.com/en-us/typography/opentype/spec/otff#table-directory ;; https://docs.microsoft.com/en-us/typography/opentype/spec/otff#table-directory
(get-sfnt-type [data] (get-sfnt-type [data]
@@ -430,4 +446,27 @@
(= stype :ttf) (= stype :ttf)
(-> (assoc "font/otf" (ttf->otf sfnt)) (-> (assoc "font/otf" (ttf->otf sfnt))
(assoc "font/ttf" sfnt))))))))) (assoc "font/ttf" sfnt)))))
(contains? current "font/woff2")
(let [data (get input "font/woff2")
foutput (woff2->sfnt data)]
(when-not foutput
(ex/raise :type :validation
:code :invalid-woff2-file
:hint "invalid woff2 file"))
(try
(let [sfnt (io/read* foutput)
type (get-sfnt-type sfnt)]
(cond-> input
(= type :otf)
(-> (assoc "font/otf" sfnt)
(assoc "font/ttf" (otf->ttf sfnt))
(update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt)))
(= type :ttf)
(-> (assoc "font/ttf" sfnt)
(assoc "font/otf" (ttf->otf sfnt))
(update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt)))))
(finally
(fs/delete foutput))))))))

View File

@@ -463,8 +463,10 @@
:fn (mg/resource "app/migrations/sql/0144-mod-server-error-report-table.sql")} :fn (mg/resource "app/migrations/sql/0144-mod-server-error-report-table.sql")}
{:name "0145-fix-plugins-uri-on-profile" {:name "0145-fix-plugins-uri-on-profile"
:fn mg0145/migrate}]) :fn mg0145/migrate}
{:name "0146-mod-access-token-table"
:fn (mg/resource "app/migrations/sql/0146-mod-access-token-table.sql")}])
(defn apply-migrations! (defn apply-migrations!
[pool name migrations] [pool name migrations]

View File

@@ -0,0 +1,2 @@
ALTER TABLE access_token
ADD COLUMN type text NULL;

View File

@@ -87,6 +87,10 @@
[:map [:map
[:valid ::sm/boolean]]) [:valid ::sm/boolean]])
(def ^:private schema:connectivity
[:map
[:licenses ::sm/boolean]])
(defn- get-team-org (defn- get-team-org
[cfg {:keys [team-id] :as params}] [cfg {:keys [team-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)] (let [baseuri (cf/get :nitrate-backend-uri)]
@@ -97,6 +101,11 @@
(let [baseuri (cf/get :nitrate-backend-uri)] (let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get (str baseuri "/api/users/" (str profile-id)) schema:user params))) (request-to-nitrate cfg :get (str baseuri "/api/users/" (str profile-id)) schema:user params)))
(defn- get-connectivity
[cfg params]
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get (str baseuri "/api/connectivity") schema:connectivity params)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; INITIALIZATION ;; INITIALIZATION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -105,7 +114,8 @@
[_ cfg] [_ cfg]
(when (contains? cf/flags :nitrate) (when (contains? cf/flags :nitrate)
{:get-team-org (partial get-team-org cfg) {:get-team-org (partial get-team-org cfg)
:is-valid-user (partial is-valid-user cfg)})) :is-valid-user (partial is-valid-user cfg)
:connectivity (partial get-connectivity cfg)}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; UTILS ;; UTILS
@@ -128,3 +138,7 @@
(let [params (assoc (or params {}) :team-id (:id team)) (let [params (assoc (or params {}) :team-id (:id team))
org (call cfg :get-team-org params)] org (call cfg :get-team-org params)]
(assoc team :organization-id (:id org) :organization-name (:name org)))) (assoc team :organization-id (:id org) :organization-name (:name org))))
(defn connectivity
[cfg]
(call cfg :connectivity {}))

View File

@@ -73,9 +73,13 @@
(if (nil? result) (if (nil? result)
204 204
200)) 200))
headers (cond-> (::http/headers mdata {})
(yres/stream-body? result) headers (::http/headers mdata {})
headers (cond-> headers
(and (yres/stream-body? result)
(not (contains? headers "content-type")))
(assoc "content-type" "application/octet-stream"))] (assoc "content-type" "application/octet-stream"))]
{::yres/status status {::yres/status status
::yres/headers headers ::yres/headers headers
::yres/body result}))] ::yres/body result}))]
@@ -258,6 +262,7 @@
'app.rpc.commands.ldap 'app.rpc.commands.ldap
'app.rpc.commands.management 'app.rpc.commands.management
'app.rpc.commands.media 'app.rpc.commands.media
'app.rpc.commands.nitrate
'app.rpc.commands.profile 'app.rpc.commands.profile
'app.rpc.commands.projects 'app.rpc.commands.projects
'app.rpc.commands.search 'app.rpc.commands.search

View File

@@ -23,7 +23,7 @@
(dissoc row :perms)) (dissoc row :perms))
(defn create-access-token (defn create-access-token
[{:keys [::db/conn] :as cfg} profile-id name expiration] [{:keys [::db/conn] :as cfg} profile-id name expiration type]
(let [token-id (uuid/next) (let [token-id (uuid/next)
expires-at (some-> expiration (ct/in-future)) expires-at (some-> expiration (ct/in-future))
created-at (ct/now) created-at (ct/now)
@@ -36,6 +36,7 @@
{:id token-id {:id token-id
:name name :name name
:token token :token token
:type type
:profile-id profile-id :profile-id profile-id
:created-at created-at :created-at created-at
:updated-at created-at :updated-at created-at
@@ -50,17 +51,18 @@
(def ^:private schema:create-access-token (def ^:private schema:create-access-token
[:map {:title "create-access-token"} [:map {:title "create-access-token"}
[:name [:string {:max 250 :min 1}]] [:name [:string {:max 250 :min 1}]]
[:expiration {:optional true} ::ct/duration]]) [:expiration {:optional true} ::ct/duration]
[:type {:optional true} :string]])
(sv/defmethod ::create-access-token (sv/defmethod ::create-access-token
{::doc/added "1.18" {::doc/added "1.18"
::sm/params schema:create-access-token} ::sm/params schema:create-access-token}
[cfg {:keys [::rpc/profile-id name expiration]}] [cfg {:keys [::rpc/profile-id name expiration type]}]
(quotes/check! cfg {::quotes/id ::quotes/access-tokens-per-profile (quotes/check! cfg {::quotes/id ::quotes/access-tokens-per-profile
::quotes/profile-id profile-id}) ::quotes/profile-id profile-id})
(db/tx-run! cfg create-access-token profile-id name expiration)) (db/tx-run! cfg create-access-token profile-id name expiration type))
(def ^:private schema:delete-access-token (def ^:private schema:delete-access-token
[:map {:title "delete-access-token"} [:map {:title "delete-access-token"}
@@ -83,5 +85,22 @@
(->> (db/query pool :access-token (->> (db/query pool :access-token
{:profile-id profile-id} {:profile-id profile-id}
{:order-by [[:expires-at :asc] [:created-at :asc]] {:order-by [[:expires-at :asc] [:created-at :asc]]
:columns [:id :name :perms :created-at :updated-at :expires-at]}) :columns [:id :name :perms :type :created-at :updated-at :expires-at]})
(mapv decode-row))) (mapv decode-row)))
(def ^:private schema:get-current-mcp-token
[:map {:title "get-current-mcp-token"}])
(sv/defmethod ::get-current-mcp-token
{::doc/added "2.15"
::sm/params schema:get-current-mcp-token}
[{:keys [::db/pool]} {:keys [::rpc/profile-id ::rpc/request-at]}]
(->> (db/query pool :access-token
{:profile-id profile-id
:type "mcp"}
{:order-by [[:expires-at :asc] [:created-at :asc]]
:columns [:token :expires-at]})
(remove #(ct/is-after? (:expires-at %) request-at))
(map decode-row)
(first)))

View File

@@ -9,12 +9,14 @@
[app.binfile.common :as bfc] [app.binfile.common :as bfc]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.media :as cmedia]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.time :as ct] [app.common.time :as ct]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.db :as db] [app.db :as db]
[app.db.sql :as-alias sql] [app.db.sql :as-alias sql]
[app.features.logical-deletion :as ldel] [app.features.logical-deletion :as ldel]
[app.http :as-alias http]
[app.loggers.audit :as-alias audit] [app.loggers.audit :as-alias audit]
[app.loggers.webhooks :as-alias webhooks] [app.loggers.webhooks :as-alias webhooks]
[app.media :as media] [app.media :as media]
@@ -34,7 +36,9 @@
java.io.InputStream java.io.InputStream
java.io.OutputStream java.io.OutputStream
java.io.SequenceInputStream java.io.SequenceInputStream
java.util.Collections)) java.util.Collections
java.util.zip.ZipEntry
java.util.zip.ZipOutputStream))
(set! *warn-on-reflection* true) (set! *warn-on-reflection* true)
@@ -296,3 +300,98 @@
(rph/with-meta (rph/wrap) (rph/with-meta (rph/wrap)
{::audit/props {:font-family (:font-family variant) {::audit/props {:font-family (:font-family variant)
:font-id (:font-id variant)}}))) :font-id (:font-id variant)}})))
;; --- DOWNLOAD FONT
(defn- make-temporal-storage-object
[cfg profile-id content]
(let [storage (sto/resolve cfg)
content (media/check-input content)
hash (sto/calculate-hash (:path content))
data (-> (sto/content (:path content))
(sto/wrap-with-hash hash))
mtype (:mtype content "application/octet-stream")
content {::sto/content data
::sto/deduplicate? true
::sto/touched-at (ct/in-future {:minutes 30})
:profile-id profile-id
:content-type mtype
:bucket "tempfile"}]
(sto/put-object! storage content)))
(defn- make-variant-filename
[v mtype]
(str (:font-family v) "-" (:font-weight v)
(when-not (= "normal" (:font-style v)) (str "-" (:font-style v)))
(cmedia/mtype->extension mtype)))
(def ^:private schema:download-font
[:map {:title "download-font"}
[:id ::sm/uuid]])
(sv/defmethod ::download-font
"Download the font file. Returns a http redirect to the asset resource uri."
{::doc/added "2.15"
::sm/params schema:download-font}
[{:keys [::sto/storage ::db/pool] :as cfg} {:keys [::rpc/profile-id id]}]
(let [variant (db/get pool :team-font-variant {:id id})]
(teams/check-read-permissions! pool profile-id (:team-id variant))
;; Try to get the best available font format (prefer TTF for broader compatibility).
(let [media-id (or (:ttf-file-id variant)
(:otf-file-id variant)
(:woff2-file-id variant)
(:woff1-file-id variant))
sobj (sto/get-object storage media-id)
mtype (-> sobj meta :content-type)]
{:id (:id sobj)
:uri (files/resolve-public-uri (:id sobj))
:name (make-variant-filename variant mtype)})))
(def ^:private schema:download-font-family
[:map {:title "download-font-family"}
[:font-id ::sm/uuid]])
(sv/defmethod ::download-font-family
"Download the entire font family as a zip file. Returns the zip
bytes on the body, without encoding it on transit or json."
{::doc/added "2.15"
::sm/params schema:download-font-family}
[{:keys [::sto/storage ::db/pool] :as cfg} {:keys [::rpc/profile-id font-id]}]
(let [variants (db/query pool :team-font-variant
{:font-id font-id
:deleted-at nil})]
(when-not (seq variants)
(ex/raise :type :not-found
:code :object-not-found))
(teams/check-read-permissions! pool profile-id (:team-id (first variants)))
(let [tempfile (tmp/tempfile :suffix ".zip")
ffamily (-> variants first :font-family)]
(with-open [^OutputStream output (io/output-stream tempfile)
^OutputStream output (ZipOutputStream. output)]
(doseq [v variants]
(let [media-id (or (:ttf-file-id v)
(:otf-file-id v)
(:woff2-file-id v)
(:woff1-file-id v))
sobj (sto/get-object storage media-id)
mtype (-> sobj meta :content-type)
name (make-variant-filename v mtype)]
(with-open [input (sto/get-object-data storage sobj)]
(.putNextEntry ^ZipOutputStream output (ZipEntry. ^String name))
(io/copy input output :size (:size sobj))
(.closeEntry ^ZipOutputStream output)))))
(let [{:keys [id] :as sobj} (make-temporal-storage-object cfg profile-id
{:mtype "application/zip"
:path tempfile})]
{:id id
:uri (files/resolve-public-uri id)
:name (str ffamily ".zip")}))))

View File

@@ -0,0 +1,20 @@
(ns app.rpc.commands.nitrate
(:require
[app.common.schema :as sm]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[app.rpc.doc :as-alias doc]
[app.util.services :as sv]))
(def schema:connectivity
[:map {:title "nitrate-connectivity"}
[:licenses ::sm/boolean]])
(sv/defmethod ::get-nitrate-connectivity
{::rpc/auth false
::doc/added "1.18"
::sm/params [:map]
::sm/result schema:connectivity}
[cfg _params]
(nitrate/connectivity cfg))

View File

@@ -48,6 +48,7 @@
(def schema:props (def schema:props
[:map {:title "ProfileProps"} [:map {:title "ProfileProps"}
[:plugins {:optional true} schema:plugin-registry] [:plugins {:optional true} schema:plugin-registry]
[:mcp-status {:optional true} ::sm/boolean]
[:newsletter-updates {:optional true} ::sm/boolean] [:newsletter-updates {:optional true} ::sm/boolean]
[:newsletter-news {:optional true} ::sm/boolean] [:newsletter-news {:optional true} ::sm/boolean]
[:onboarding-team-id {:optional true} ::sm/uuid] [:onboarding-team-id {:optional true} ::sm/uuid]

View File

@@ -102,7 +102,7 @@
(t/deftest access-token-authz (t/deftest access-token-authz
(let [profile (th/create-profile* 1) (let [profile (th/create-profile* 1)
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil) token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil nil)
handler (#'app.http.access-token/wrap-authz identity th/*system*)] handler (#'app.http.access-token/wrap-authz identity th/*system*)]
(let [response (handler nil)] (let [response (handler nil)]

View File

@@ -107,4 +107,18 @@
;; (th/print-result! out) ;; (th/print-result! out)
(t/is (nil? (:error out))) (t/is (nil? (:error out)))
(let [results (:result out)] (let [results (:result out)]
(t/is (= 2 (count results)))))))) (t/is (= 2 (count results))))))
(t/testing "get mcp token"
(let [_ (th/command! {::th/type :create-access-token
::rpc/profile-id (:id prof)
:type "mcp"
:name "token 1"
:perms ["get-profile"]})
{:keys [error result]}
(th/command! {::th/type :get-current-mcp-token
::rpc/profile-id (:id prof)})]
;; (th/print-result! result)
(t/is (nil? error))
(t/is (string? (:token result)))))))

View File

@@ -93,6 +93,41 @@
:font-weight :font-weight
:font-style)))) :font-style))))
(t/deftest woff2-font-upload-1
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
font-id (uuid/custom 10 1)
data (-> (io/resource "backend_tests/test_files/font-1.woff2")
(io/read*))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "somefont"
:font-weight 400
:font-style "normal"
:data {"font/woff2" data}}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (uuid? (:id result)))
(t/is (uuid? (:ttf-file-id result)))
(t/is (uuid? (:otf-file-id result)))
(t/is (uuid? (:woff1-file-id result)))
(t/is (uuid? (:woff2-file-id result)))
(t/are [k] (= (get params k)
(get result k))
:team-id
:font-id
:font-family
:font-weight
:font-style))))
(t/deftest font-deletion-1 (t/deftest font-deletion-1
(let [prof (th/create-profile* 1 {:is-active true}) (let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof) team-id (:default-team-id prof)

View File

Binary file not shown.

View File

@@ -152,7 +152,9 @@
:redis-cache :redis-cache
;; Activates the nitrate module ;; Activates the nitrate module
:nitrate}) :nitrate
:mcp})
(def all-flags (def all-flags
(set/union email login varia)) (set/union email login varia))

View File

@@ -12,6 +12,7 @@
(def font-types (def font-types
#{"font/ttf" #{"font/ttf"
"font/woff" "font/woff"
"font/woff2"
"font/otf" "font/otf"
"font/opentype"}) "font/opentype"})
@@ -81,21 +82,22 @@
(defn parse-font-weight (defn parse-font-weight
[variant] [variant]
(cond (cond
(re-seq #"(?i)(?:hairline|thin)" variant) 100 (re-seq #"(?i)(?:^|[-_\s])(hairline|thin)(?=(?:[-_\s]|$|italic\b))" variant) 100
(re-seq #"(?i)(?:extra\s*light|ultra\s*light)" variant) 200 (re-seq #"(?i)(?:^|[-_\s])(extra\s*light|ultra\s*light)(?=(?:[-_\s]|$|italic\b))" variant) 200
(re-seq #"(?i)(?:light)" variant) 300 (re-seq #"(?i)(?:^|[-_\s])(light)(?=(?:[-_\s]|$|italic\b))" variant) 300
(re-seq #"(?i)(?:normal|regular)" variant) 400 (re-seq #"(?i)(?:^|[-_\s])(normal|regular)(?=(?:[-_\s]|$|italic\b))" variant) 400
(re-seq #"(?i)(?:medium)" variant) 500 (re-seq #"(?i)(?:^|[-_\s])(medium)(?=(?:[-_\s]|$|italic\b))" variant) 500
(re-seq #"(?i)(?:semi\s*bold|demi\s*bold)" variant) 600 (re-seq #"(?i)(?:^|[-_\s])(semi\s*bold|demi\s*bold)(?=(?:[-_\s]|$|italic\b))" variant) 600
(re-seq #"(?i)(?:extra\s*bold|ultra\s*bold)" variant) 800 (re-seq #"(?i)(?:^|[-_\s])(extra\s*bold|ultra\s*bold)(?=(?:[-_\s]|$|italic\b))" variant) 800
(re-seq #"(?i)(?:bold)" variant) 700 (re-seq #"(?i)(?:^|[-_\s])(bold)(?=(?:[-_\s]|$|italic\b))" variant) 700
(re-seq #"(?i)(?:extra\s*black|ultra\s*black)" variant) 950 (re-seq #"(?i)(?:^|[-_\s])(extra\s*black|ultra\s*black)(?=(?:[-_\s]|$|italic\b))" variant) 950
(re-seq #"(?i)(?:black|heavy|solid)" variant) 900 (re-seq #"(?i)(?:^|[-_\s])(black|heavy|solid)(?=(?:[-_\s]|$|italic\b))" variant) 900
:else 400)) :else 400))
(defn parse-font-style (defn parse-font-style
[variant] [variant]
(if (re-seq #"(?i)(?:italic)" variant) (if (or (re-seq #"(?i)(?:^|[-_\s])(italic)(?:[-_\s]|$)" variant)
(re-seq #"(?i)italic$" variant))
"italic" "italic"
"normal")) "normal"))

View File

@@ -9,6 +9,39 @@
[app.common.media :as media] [app.common.media :as media]
[clojure.test :as t])) [clojure.test :as t]))
(t/deftest test-parse-font-weight
(t/testing "matches weight tokens with proper boundaries"
(t/is (= 700 (media/parse-font-weight "Roboto-Bold")))
(t/is (= 700 (media/parse-font-weight "Roboto_Bold")))
(t/is (= 700 (media/parse-font-weight "Roboto Bold")))
(t/is (= 700 (media/parse-font-weight "Bold")))
(t/is (= 800 (media/parse-font-weight "Roboto-ExtraBold")))
(t/is (= 600 (media/parse-font-weight "OpenSans-SemiBold")))
(t/is (= 300 (media/parse-font-weight "Lato-Light")))
(t/is (= 100 (media/parse-font-weight "Roboto-Thin")))
(t/is (= 200 (media/parse-font-weight "Roboto-ExtraLight")))
(t/is (= 500 (media/parse-font-weight "Roboto-Medium")))
(t/is (= 900 (media/parse-font-weight "Roboto-Black"))))
(t/testing "does not match weight tokens embedded in words"
(t/is (= 400 (media/parse-font-weight "Boldini")))
(t/is (= 400 (media/parse-font-weight "Lighthaus")))
(t/is (= 400 (media/parse-font-weight "Blackwood")))
(t/is (= 400 (media/parse-font-weight "Thinker")))
(t/is (= 400 (media/parse-font-weight "Mediaeval")))))
(t/deftest test-parse-font-style
(t/testing "matches italic with proper boundaries"
(t/is (= "italic" (media/parse-font-style "Roboto-Italic")))
(t/is (= "italic" (media/parse-font-style "Roboto_Italic")))
(t/is (= "italic" (media/parse-font-style "Roboto Italic")))
(t/is (= "italic" (media/parse-font-style "Italic")))
(t/is (= "italic" (media/parse-font-style "Roboto-BoldItalic"))))
(t/testing "does not match italic embedded in words"
(t/is (= "normal" (media/parse-font-style "Italica")))
(t/is (= "normal" (media/parse-font-style "Roboto-Regular")))))
(t/deftest test-strip-image-extension (t/deftest test-strip-image-extension
(t/testing "removes extension from supported image files" (t/testing "removes extension from supported image files"
(t/is (= (media/strip-image-extension "foo.png") "foo")) (t/is (= (media/strip-image-extension "foo.png") "foo"))

View File

@@ -50,6 +50,7 @@ services:
- 4400:4400 - 4400:4400
- 4401:4401 - 4401:4401
- 4402:4402 - 4402:4402
- 4403:4403
# Plugins # Plugins
- 4200:4200 - 4200:4200

View File

@@ -40,14 +40,16 @@
"watch:app:libs": "node ./scripts/build-libs.js --watch", "watch:app:libs": "node ./scripts/build-libs.js --watch",
"watch:app:main": "clojure -M:dev:shadow-cljs watch main worker storybook", "watch:app:main": "clojure -M:dev:shadow-cljs watch main worker storybook",
"clear:shadow-cache": "rm -rf .shadow-cljs", "clear:shadow-cache": "rm -rf .shadow-cljs",
"clear:wasm": "cargo clean --manifest-path ../render-wasm/Cargo.toml",
"watch": "exit 0", "watch": "exit 0",
"watch:app": "pnpm run clear:shadow-cache && pnpm run build:wasm && concurrently --kill-others-on-fail \"pnpm run watch:app:assets\" \"pnpm run watch:app:main\" \"pnpm run watch:app:libs\"", "watch:app": "pnpm run clear:shadow-cache && pnpm run clear:wasm && pnpm run build:wasm && concurrently --kill-others-on-fail \"pnpm run watch:app:assets\" \"pnpm run watch:app:main\" \"pnpm run watch:app:libs\"",
"watch:storybook": "pnpm run build:storybook:assets && concurrently --kill-others-on-fail \"storybook dev -p 6006 --no-open\" \"node ./scripts/watch-storybook.js\"" "watch:storybook": "pnpm run build:storybook:assets && concurrently --kill-others-on-fail \"storybook dev -p 6006 --no-open\" \"node ./scripts/watch-storybook.js\"",
"postinstall": "(cd ../plugins/libs/plugins-runtime; pnpm install; pnpm run build)"
}, },
"devDependencies": { "devDependencies": {
"@penpot/draft-js": "workspace:./packages/draft-js", "@penpot/draft-js": "workspace:./packages/draft-js",
"@penpot/mousetrap": "workspace:./packages/mousetrap", "@penpot/mousetrap": "workspace:./packages/mousetrap",
"@penpot/plugins-runtime": "1.4.2", "@penpot/plugins-runtime": "link:../plugins/dist/plugins-runtime",
"@penpot/svgo": "penpot/svgo#v3.2", "@penpot/svgo": "penpot/svgo#v3.2",
"@penpot/text-editor": "workspace:./text-editor", "@penpot/text-editor": "workspace:./text-editor",
"@penpot/tokenscript": "workspace:./packages/tokenscript", "@penpot/tokenscript": "workspace:./packages/tokenscript",

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
{
"~:file-id": "~u52c4e771-3853-8190-8007-9506c70e8100",
"~:id": "~u4173a29d-4020-80b4-8007-96527ba9d8af",
"~:created-at": "~m1771342236330",
"~:modified-at": "~m1771342236330",
"~:type": "fragment",
"~:backend": "db",
"~:data": {
"~:id": "~uecb0cfd0-0f0b-81f7-8007-950628f9665b",
"~:name": "Page 1",
"~:objects": {
"~#penpot/objects-map/v2": {
"~ude9c6736-45ce-80a1-8007-950643da554d": "[\"~#shape\",[\"^ \",\"~:y\",383.99998939037323,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"R1\",\"~:width\",74,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",831.999992787838,\"~:y\",383.99998939037323]],[\"^<\",[\"^ \",\"~:x\",905.999992787838,\"~:y\",383.99998939037323]],[\"^<\",[\"^ \",\"~:x\",905.999992787838,\"~:y\",429.99998664855957]],[\"^<\",[\"^ \",\"~:x\",831.999992787838,\"~:y\",429.99998664855957]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~uecb0cfd0-0f0b-81f7-8007-950628f9665b\",\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~ude9c6736-45ce-80a1-8007-950643da554d\",\"~:parent-id\",\"~ude9c6736-45ce-80a1-8007-95063a202e52\",\"~:frame-id\",\"~ude9c6736-45ce-80a1-8007-95063a202e52\",\"~:strokes\",[],\"~:x\",831.999992787838,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",831.999992787838,\"~:y\",383.99998939037323,\"^8\",74,\"~:height\",45.99999725818634,\"~:x1\",831.999992787838,\"~:y1\",383.99998939037323,\"~:x2\",905.999992787838,\"~:y2\",429.99998664855957]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^K\",45.99999725818634,\"~:flip-y\",null]]",
"~ude9c6736-45ce-80a1-8007-95065b4599ea": "[\"~#shape\",[\"^ \",\"~:y\",439.999969256975,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"R2\",\"~:width\",300.00007388362076,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",832.0000015918991,\"~:y\",439.99996925697496]],[\"^<\",[\"^ \",\"~:x\",1132.00007547552,\"~:y\",439.99996925697496]],[\"^<\",[\"^ \",\"~:x\",1132.00007547552,\"~:y\",589.9999658711585]],[\"^<\",[\"^ \",\"~:x\",832.0000015918991,\"~:y\",589.9999658711585]]],\"~:r2\",0,\"~:layout-item-h-sizing\",\"~:fix\",\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~uecb0cfd0-0f0b-81f7-8007-950628f9665b\",\"~:layout-item-v-sizing\",\"^?\",\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~ude9c6736-45ce-80a1-8007-95065b4599ea\",\"~:parent-id\",\"~ude9c6736-45ce-80a1-8007-95063a202e52\",\"~:frame-id\",\"~ude9c6736-45ce-80a1-8007-95063a202e52\",\"~:strokes\",[],\"~:x\",832.0000015918993,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",832.0000015918993,\"~:y\",439.999969256975,\"^8\",300.00007388362076,\"~:height\",149.9999966141835,\"~:x1\",832.0000015918993,\"~:y1\",439.999969256975,\"~:x2\",1132.0000754755201,\"~:y2\",589.9999658711586]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^N\",149.9999966141835,\"~:flip-y\",null]]",
"~u00000000-0000-0000-0000-000000000000": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"Root Frame\",\"~:width\",0.01,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0.0,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.01]],[\"^:\",[\"^ \",\"~:x\",0.0,\"~:y\",0.01]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~uecb0cfd0-0f0b-81f7-8007-950628f9665b\",\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1.0,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",0.01,\"~:height\",0.01,\"~:x1\",0,\"~:y1\",0,\"~:x2\",0.01,\"~:y2\",0.01]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#FFFFFF\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^I\",0.01,\"~:flip-y\",null,\"~:shapes\",[\"~ude9c6736-45ce-80a1-8007-95062aa41d6b\"]]]",
"~ude9c6736-45ce-80a1-8007-95062aa41d6b": "[\"~#shape\",[\"^ \",\"~:y\",342.0000208153068,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:layout-padding\",[\"^ \",\"~:p1\",0,\"~:p2\",0,\"~:p3\",0,\"~:p4\",0],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:nowrap\",\"~:grow-type\",\"~:fixed\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",false,\"~:name\",\"A\",\"~:layout-align-items\",\"~:start\",\"~:width\",392.9999889135361,\"~:layout-padding-type\",\"~:simple\",\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",787.9999763965607,\"~:y\",342.0000208153068]],[\"^L\",[\"^ \",\"~:x\",1180.9999653100967,\"~:y\",342.0000208153068]],[\"^L\",[\"^ \",\"~:x\",1180.9999653100967,\"~:y\",704.000018450968]],[\"^L\",[\"^ \",\"~:x\",787.9999763965607,\"~:y\",704.000018450968]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",0,\"~:column-gap\",0],\"~:transform-inverse\",[\"^:\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~uecb0cfd0-0f0b-81f7-8007-950628f9665b\",\"~:layout-item-v-sizing\",\"~:fix\",\"~:r3\",0,\"~:layout-justify-content\",\"^E\",\"~:r1\",0,\"~:id\",\"~ude9c6736-45ce-80a1-8007-95062aa41d6b\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:layout-flex-dir\",\"~:column\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",787.9999763965607,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",787.9999763965607,\"~:y\",342.0000208153068,\"^F\",392.9999889135361,\"~:height\",361.9999976356612,\"~:x1\",787.9999763965607,\"~:y1\",342.0000208153068,\"~:x2\",1180.9999653100967,\"~:y2\",704.000018450968]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#FFFFFF\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^19\",361.9999976356612,\"~:flip-y\",null,\"~:shapes\",[\"~ude9c6736-45ce-80a1-8007-95062fafbb88\"]]]",
"~ude9c6736-45ce-80a1-8007-95063a202e52": "[\"~#shape\",[\"^ \",\"~:y\",374.00001145409465,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:layout-padding\",[\"^ \",\"~:p1\",10,\"~:p2\",10,\"~:p3\",10,\"~:p4\",10],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:nowrap\",\"~:grow-type\",\"~:fixed\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"C\",\"~:layout-align-items\",\"~:start\",\"~:width\",320.00009969013945,\"~:layout-padding-type\",\"~:simple\",\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",821.9999915631233,\"~:y\",374.0000114540946]],[\"^L\",[\"^ \",\"~:x\",1142.0000912532628,\"~:y\",374.0000114540946]],[\"^L\",[\"^ \",\"~:x\",1142.0000912532628,\"~:y\",600.0000518384436]],[\"^L\",[\"^ \",\"~:x\",821.9999915631233,\"~:y\",600.0000518384436]]],\"~:r2\",0,\"~:layout-item-h-sizing\",\"~:auto\",\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",10,\"~:column-gap\",0],\"~:transform-inverse\",[\"^:\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~uecb0cfd0-0f0b-81f7-8007-950628f9665b\",\"~:layout-item-v-sizing\",\"^O\",\"~:r3\",0,\"~:layout-justify-content\",\"^E\",\"~:r1\",0,\"~:id\",\"~ude9c6736-45ce-80a1-8007-95063a202e52\",\"~:parent-id\",\"~ude9c6736-45ce-80a1-8007-95062fafbb88\",\"~:layout-flex-dir\",\"~:column\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~ude9c6736-45ce-80a1-8007-95062fafbb88\",\"~:strokes\",[[\"^ \",\"~:stroke-alignment\",\"~:inner\",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#000000\",\"~:stroke-opacity\",1,\"~:stroke-width\",1]],\"~:x\",821.9999915631233,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",821.9999915631233,\"~:y\",374.00001145409465,\"^F\",320.00009969013945,\"~:height\",226.000040384349,\"~:x1\",821.9999915631233,\"~:y1\",374.00001145409465,\"~:x2\",1142.0000912532628,\"~:y2\",600.0000518384436]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#FFFFFF\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^1A\",226.000040384349,\"~:flip-y\",null,\"~:shapes\",[\"~ude9c6736-45ce-80a1-8007-95065b4599ea\",\"~ude9c6736-45ce-80a1-8007-950643da554d\"]]]",
"~ude9c6736-45ce-80a1-8007-95062fafbb88": "[\"~#shape\",[\"^ \",\"~:y\",342.0000083402533,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:layout-padding\",[\"^ \",\"~:p1\",31.999953508377075,\"~:p2\",34.000026052944804,\"~:p3\",31.999953508377075,\"~:p4\",34.000026052944804],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:nowrap\",\"~:grow-type\",\"~:fixed\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"B\",\"~:layout-align-items\",\"~:start\",\"~:width\",392.9999979687366,\"~:layout-padding-type\",\"~:simple\",\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",788.0000295450811,\"~:y\",342.00000834025326]],[\"^L\",[\"^ \",\"~:x\",1181.0000275138177,\"~:y\",342.00000834025326]],[\"^L\",[\"^ \",\"~:x\",1181.0000275138177,\"~:y\",631.9999659712]],[\"^L\",[\"^ \",\"~:x\",788.0000295450811,\"~:y\",631.9999659712]]],\"~:r2\",0,\"~:layout-item-h-sizing\",\"~:fill\",\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",0,\"~:column-gap\",0],\"~:transform-inverse\",[\"^:\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~uecb0cfd0-0f0b-81f7-8007-950628f9665b\",\"~:layout-item-v-sizing\",\"~:auto\",\"~:r3\",0,\"~:layout-justify-content\",\"^E\",\"~:r1\",0,\"~:id\",\"~ude9c6736-45ce-80a1-8007-95062fafbb88\",\"~:parent-id\",\"~ude9c6736-45ce-80a1-8007-95062aa41d6b\",\"~:layout-flex-dir\",\"~:column\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~ude9c6736-45ce-80a1-8007-95062aa41d6b\",\"~:strokes\",[[\"^ \",\"~:stroke-alignment\",\"~:inner\",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#000000\",\"~:stroke-opacity\",1,\"~:stroke-width\",1]],\"~:x\",788.0000295450811,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",788.0000295450811,\"~:y\",342.0000083402533,\"^F\",392.9999979687366,\"~:height\",289.99995763094677,\"~:x1\",788.0000295450811,\"~:y1\",342.0000083402533,\"~:x2\",1181.0000275138177,\"~:y2\",631.9999659712]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#FFFFFF\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^1B\",289.99995763094677,\"~:flip-y\",null,\"~:shapes\",[\"~ude9c6736-45ce-80a1-8007-95063a202e52\"]]]"
}
}
}
}

View File

@@ -0,0 +1,131 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"design-tokens/v1",
"variants/v1",
"layout/grid",
"fdata/pointer-map",
"fdata/objects-map",
"components/v2",
"fdata/shape-data-type"
]
},
"~:team-id": "~ud715d0a5-a44e-8056-8005-a79999e18b64",
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "bug flex",
"~:revn": 33,
"~:modified-at": "~m1771342236324",
"~:vern": 0,
"~:id": "~u52c4e771-3853-8190-8007-9506c70e8100",
"~:is-shared": false,
"~:migrations": {
"~#ordered-set": [
"legacy-2",
"legacy-3",
"legacy-5",
"legacy-6",
"legacy-7",
"legacy-8",
"legacy-9",
"legacy-10",
"legacy-11",
"legacy-12",
"legacy-13",
"legacy-14",
"legacy-16",
"legacy-17",
"legacy-18",
"legacy-19",
"legacy-25",
"legacy-26",
"legacy-27",
"legacy-28",
"legacy-29",
"legacy-31",
"legacy-32",
"legacy-33",
"legacy-34",
"legacy-36",
"legacy-37",
"legacy-38",
"legacy-39",
"legacy-40",
"legacy-41",
"legacy-42",
"legacy-43",
"legacy-44",
"legacy-45",
"legacy-46",
"legacy-47",
"legacy-48",
"legacy-49",
"legacy-50",
"legacy-51",
"legacy-52",
"legacy-53",
"legacy-54",
"legacy-55",
"legacy-56",
"legacy-57",
"legacy-59",
"legacy-62",
"legacy-65",
"legacy-66",
"legacy-67",
"0001-remove-tokens-from-groups",
"0002-normalize-bool-content-v2",
"0002-clean-shape-interactions",
"0003-fix-root-shape",
"0003-convert-path-content-v2",
"0005-deprecate-image-type",
"0006-fix-old-texts-fills",
"0008-fix-library-colors-v4",
"0009-clean-library-colors",
"0009-add-partial-text-touched-flags",
"0010-fix-swap-slots-pointing-non-existent-shapes",
"0011-fix-invalid-text-touched-flags",
"0012-fix-position-data",
"0013-fix-component-path",
"0013-clear-invalid-strokes-and-fills",
"0014-fix-tokens-lib-duplicate-ids",
"0014-clear-components-nil-objects",
"0015-fix-text-attrs-blank-strings",
"0015-clean-shadow-color",
"0016-copy-fills-from-position-data-to-text-node"
]
},
"~:version": 67,
"~:project-id": "~u76eab896-accf-81a5-8007-2b264ebe7817",
"~:created-at": "~m1771255281717",
"~:backend": "legacy-db",
"~:data": {
"~:pages": [
"~uecb0cfd0-0f0b-81f7-8007-950628f9665b"
],
"~:pages-index": {
"~uecb0cfd0-0f0b-81f7-8007-950628f9665b": {
"~#penpot/pointer": [
"~u4173a29d-4020-80b4-8007-96527ba9d8af",
{
"~:created-at": "~m1771342236327"
}
]
}
},
"~:id": "~u52c4e771-3853-8190-8007-9506c70e8100",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
}
}
}

View File

@@ -0,0 +1,136 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"plugins/runtime",
"design-tokens/v1",
"variants/v1",
"layout/grid",
"styles/v2",
"fdata/objects-map",
"render-wasm/v1",
"components/v2",
"fdata/shape-data-type"
]
},
"~:team-id": "~u99e49e93-362f-80ef-8007-3450ea52c9a4",
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "BUG 13385",
"~:revn": 3,
"~:modified-at": "~m1771254407745",
"~:vern": 1173241426,
"~:id": "~u3ea49ce0-9d99-8197-8007-950361d24e43",
"~:is-shared": false,
"~:migrations": {
"~#ordered-set": [
"legacy-2",
"legacy-3",
"legacy-5",
"legacy-6",
"legacy-7",
"legacy-8",
"legacy-9",
"legacy-10",
"legacy-11",
"legacy-12",
"legacy-13",
"legacy-14",
"legacy-16",
"legacy-17",
"legacy-18",
"legacy-19",
"legacy-25",
"legacy-26",
"legacy-27",
"legacy-28",
"legacy-29",
"legacy-31",
"legacy-32",
"legacy-33",
"legacy-34",
"legacy-36",
"legacy-37",
"legacy-38",
"legacy-39",
"legacy-40",
"legacy-41",
"legacy-42",
"legacy-43",
"legacy-44",
"legacy-45",
"legacy-46",
"legacy-47",
"legacy-48",
"legacy-49",
"legacy-50",
"legacy-51",
"legacy-52",
"legacy-53",
"legacy-54",
"legacy-55",
"legacy-56",
"legacy-57",
"legacy-59",
"legacy-62",
"legacy-65",
"legacy-66",
"legacy-67",
"0001-remove-tokens-from-groups",
"0002-normalize-bool-content-v2",
"0002-clean-shape-interactions",
"0003-fix-root-shape",
"0003-convert-path-content-v2",
"0005-deprecate-image-type",
"0006-fix-old-texts-fills",
"0008-fix-library-colors-v4",
"0009-clean-library-colors",
"0009-add-partial-text-touched-flags",
"0010-fix-swap-slots-pointing-non-existent-shapes",
"0011-fix-invalid-text-touched-flags",
"0012-fix-position-data",
"0013-fix-component-path",
"0013-clear-invalid-strokes-and-fills",
"0014-fix-tokens-lib-duplicate-ids",
"0014-clear-components-nil-objects",
"0015-fix-text-attrs-blank-strings",
"0015-clean-shadow-color",
"0016-copy-fills-from-position-data-to-text-node"
]
},
"~:version": 67,
"~:project-id": "~ucd8f7672-e5d1-810f-8007-87e124eda82a",
"~:created-at": "~m1771254391625",
"~:backend": "legacy-db",
"~:data": {
"~:pages": [
"~u3ea49ce0-9d99-8197-8007-950361d24e44"
],
"~:pages-index": {
"~u3ea49ce0-9d99-8197-8007-950361d24e44": {
"~:objects": {
"~#penpot/objects-map/v2": {
"~u00000000-0000-0000-0000-000000000000": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"Root Frame\",\"~:width\",0.01,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0.0,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.01]],[\"^:\",[\"^ \",\"~:x\",0.0,\"~:y\",0.01]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1.0,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",0.01,\"~:height\",0.01,\"~:x1\",0,\"~:y1\",0,\"~:x2\",0.01,\"~:y2\",0.01]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#FFFFFF\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^H\",0.01,\"~:flip-y\",null,\"~:shapes\",[\"~ue0e81bed-4dc2-805c-8007-95036a4a3131\",\"~ue0e81bed-4dc2-805c-8007-95036c27428b\"]]]",
"~ue0e81bed-4dc2-805c-8007-95036a4a3131": "[\"~#shape\",[\"^ \",\"~:y\",252,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",177,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",156,\"~:y\",252]],[\"^<\",[\"^ \",\"~:x\",333,\"~:y\",252]],[\"^<\",[\"^ \",\"~:x\",333,\"~:y\",389]],[\"^<\",[\"^ \",\"~:x\",156,\"~:y\",389]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~ue0e81bed-4dc2-805c-8007-95036a4a3131\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",156,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",156,\"~:y\",252,\"^8\",177,\"~:height\",137,\"~:x1\",156,\"~:y1\",252,\"~:x2\",333,\"~:y2\",389]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^J\",137,\"~:flip-y\",null]]",
"~ue0e81bed-4dc2-805c-8007-95036c27428b": "[\"~#shape\",[\"^ \",\"~:y\",250,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Ellipse\",\"~:width\",148,\"~:type\",\"~:circle\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",362,\"~:y\",250]],[\"^<\",[\"^ \",\"~:x\",510,\"~:y\",250]],[\"^<\",[\"^ \",\"~:x\",510,\"~:y\",389]],[\"^<\",[\"^ \",\"~:x\",362,\"~:y\",389]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~ue0e81bed-4dc2-805c-8007-95036c27428b\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",362,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",362,\"~:y\",250,\"^8\",148,\"~:height\",139,\"~:x1\",362,\"~:y1\",250,\"~:x2\",510,\"~:y2\",389]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^F\",139,\"~:flip-y\",null]]"
}
},
"~:id": "~u3ea49ce0-9d99-8197-8007-950361d24e44",
"~:name": "Page 1"
}
},
"~:id": "~u3ea49ce0-9d99-8197-8007-950361d24e43",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
}
}
}

View File

@@ -0,0 +1,135 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"plugins/runtime",
"design-tokens/v1",
"variants/v1",
"layout/grid",
"styles/v2",
"fdata/objects-map",
"render-wasm/v1",
"components/v2",
"fdata/shape-data-type"
]
},
"~:team-id": "~u99e49e93-362f-80ef-8007-3450ea52c9a4",
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "BUG 13385",
"~:revn": 2,
"~:modified-at": "~m1771254464312",
"~:vern": 0,
"~:id": "~u3ea49ce0-9d99-8197-8007-950361d24e43",
"~:is-shared": false,
"~:migrations": {
"~#ordered-set": [
"legacy-2",
"legacy-3",
"legacy-5",
"legacy-6",
"legacy-7",
"legacy-8",
"legacy-9",
"legacy-10",
"legacy-11",
"legacy-12",
"legacy-13",
"legacy-14",
"legacy-16",
"legacy-17",
"legacy-18",
"legacy-19",
"legacy-25",
"legacy-26",
"legacy-27",
"legacy-28",
"legacy-29",
"legacy-31",
"legacy-32",
"legacy-33",
"legacy-34",
"legacy-36",
"legacy-37",
"legacy-38",
"legacy-39",
"legacy-40",
"legacy-41",
"legacy-42",
"legacy-43",
"legacy-44",
"legacy-45",
"legacy-46",
"legacy-47",
"legacy-48",
"legacy-49",
"legacy-50",
"legacy-51",
"legacy-52",
"legacy-53",
"legacy-54",
"legacy-55",
"legacy-56",
"legacy-57",
"legacy-59",
"legacy-62",
"legacy-65",
"legacy-66",
"legacy-67",
"0001-remove-tokens-from-groups",
"0002-normalize-bool-content-v2",
"0002-clean-shape-interactions",
"0003-fix-root-shape",
"0003-convert-path-content-v2",
"0005-deprecate-image-type",
"0006-fix-old-texts-fills",
"0008-fix-library-colors-v4",
"0009-clean-library-colors",
"0009-add-partial-text-touched-flags",
"0010-fix-swap-slots-pointing-non-existent-shapes",
"0011-fix-invalid-text-touched-flags",
"0012-fix-position-data",
"0013-fix-component-path",
"0013-clear-invalid-strokes-and-fills",
"0014-fix-tokens-lib-duplicate-ids",
"0014-clear-components-nil-objects",
"0015-fix-text-attrs-blank-strings",
"0015-clean-shadow-color",
"0016-copy-fills-from-position-data-to-text-node"
]
},
"~:version": 67,
"~:project-id": "~ucd8f7672-e5d1-810f-8007-87e124eda82a",
"~:created-at": "~m1771254391625",
"~:backend": "legacy-db",
"~:data": {
"~:pages": [
"~u3ea49ce0-9d99-8197-8007-950361d24e44"
],
"~:pages-index": {
"~u3ea49ce0-9d99-8197-8007-950361d24e44": {
"~:objects": {
"~#penpot/objects-map/v2": {
"~u00000000-0000-0000-0000-000000000000": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"Root Frame\",\"~:width\",0.01,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0.0,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.01]],[\"^:\",[\"^ \",\"~:x\",0.0,\"~:y\",0.01]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1.0,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",0.01,\"~:height\",0.01,\"~:x1\",0,\"~:y1\",0,\"~:x2\",0.01,\"~:y2\",0.01]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#FFFFFF\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^H\",0.01,\"~:flip-y\",null,\"~:shapes\",[\"~ue0e81bed-4dc2-805c-8007-95036a4a3131\"]]]",
"~ue0e81bed-4dc2-805c-8007-95036a4a3131": "[\"~#shape\",[\"^ \",\"~:y\",252,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",177,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",156,\"~:y\",252]],[\"^<\",[\"^ \",\"~:x\",333,\"~:y\",252]],[\"^<\",[\"^ \",\"~:x\",333,\"~:y\",389]],[\"^<\",[\"^ \",\"~:x\",156,\"~:y\",389]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~ue0e81bed-4dc2-805c-8007-95036a4a3131\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",156,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",156,\"~:y\",252,\"^8\",177,\"~:height\",137,\"~:x1\",156,\"~:y1\",252,\"~:x2\",333,\"~:y2\",389]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^J\",137,\"~:flip-y\",null]]"
}
},
"~:id": "~u3ea49ce0-9d99-8197-8007-950361d24e44",
"~:name": "Page 1"
}
},
"~:id": "~u3ea49ce0-9d99-8197-8007-950361d24e43",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
}
}
}

View File

@@ -0,0 +1,135 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"plugins/runtime",
"design-tokens/v1",
"variants/v1",
"layout/grid",
"styles/v2",
"fdata/objects-map",
"render-wasm/v1",
"components/v2",
"fdata/shape-data-type"
]
},
"~:team-id": "~u99e49e93-362f-80ef-8007-3450ea52c9a4",
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "BUG 13415",
"~:revn": 14,
"~:modified-at": "~m1771334256704",
"~:vern": 0,
"~:id": "~u0472abff-2573-8186-8007-961793e53f45",
"~:is-shared": false,
"~:migrations": {
"~#ordered-set": [
"legacy-2",
"legacy-3",
"legacy-5",
"legacy-6",
"legacy-7",
"legacy-8",
"legacy-9",
"legacy-10",
"legacy-11",
"legacy-12",
"legacy-13",
"legacy-14",
"legacy-16",
"legacy-17",
"legacy-18",
"legacy-19",
"legacy-25",
"legacy-26",
"legacy-27",
"legacy-28",
"legacy-29",
"legacy-31",
"legacy-32",
"legacy-33",
"legacy-34",
"legacy-36",
"legacy-37",
"legacy-38",
"legacy-39",
"legacy-40",
"legacy-41",
"legacy-42",
"legacy-43",
"legacy-44",
"legacy-45",
"legacy-46",
"legacy-47",
"legacy-48",
"legacy-49",
"legacy-50",
"legacy-51",
"legacy-52",
"legacy-53",
"legacy-54",
"legacy-55",
"legacy-56",
"legacy-57",
"legacy-59",
"legacy-62",
"legacy-65",
"legacy-66",
"legacy-67",
"0001-remove-tokens-from-groups",
"0002-normalize-bool-content-v2",
"0002-clean-shape-interactions",
"0003-fix-root-shape",
"0003-convert-path-content-v2",
"0005-deprecate-image-type",
"0006-fix-old-texts-fills",
"0008-fix-library-colors-v4",
"0009-clean-library-colors",
"0009-add-partial-text-touched-flags",
"0010-fix-swap-slots-pointing-non-existent-shapes",
"0011-fix-invalid-text-touched-flags",
"0012-fix-position-data",
"0013-fix-component-path",
"0013-clear-invalid-strokes-and-fills",
"0014-fix-tokens-lib-duplicate-ids",
"0014-clear-components-nil-objects",
"0015-fix-text-attrs-blank-strings",
"0015-clean-shadow-color",
"0016-copy-fills-from-position-data-to-text-node"
]
},
"~:version": 67,
"~:project-id": "~ucd8f7672-e5d1-810f-8007-87e124eda82a",
"~:created-at": "~m1771326794644",
"~:backend": "legacy-db",
"~:data": {
"~:pages": [
"~u0472abff-2573-8186-8007-961793e53f46"
],
"~:pages-index": {
"~u0472abff-2573-8186-8007-961793e53f46": {
"~:objects": {
"~#penpot/objects-map/v2": {
"~u00000000-0000-0000-0000-000000000000": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"Root Frame\",\"~:width\",0.01,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0.0,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.01]],[\"^:\",[\"^ \",\"~:x\",0.0,\"~:y\",0.01]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1.0,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",0.01,\"~:height\",0.01,\"~:x1\",0,\"~:y1\",0,\"~:x2\",0.01,\"~:y2\",0.01]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#FFFFFF\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^H\",0.01,\"~:flip-y\",null,\"~:shapes\",[\"~uaef184da-e9c1-80f8-8007-961cf253d534\"]]]",
"~uaef184da-e9c1-80f8-8007-961cf253d534": "[\"~#shape\",[\"^ \",\"~:y\",286,\"~:layout-grid-columns\",[[\"^ \",\"~:type\",\"~:flex\",\"~:value\",1],[\"^ \",\"^2\",\"^3\",\"^4\",1]],\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:layout-padding\",[\"^ \",\"~:p1\",0,\"~:p2\",0,\"~:p3\",0,\"~:p4\",0],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:layout\",\"~:grid\",\"~:hide-in-viewer\",false,\"~:name\",\"Board\",\"~:layout-align-items\",\"~:start\",\"~:width\",298,\"~:layout-grid-cells\",[\"^ \",\"~uaef184da-e9c1-80f8-8007-961cf50d67b4\",[\"^ \",\"~:justify-self\",\"~:auto\",\"~:column\",1,\"~:id\",\"~uaef184da-e9c1-80f8-8007-961cf50d67b4\",\"~:position\",\"^L\",\"~:column-span\",1,\"~:align-self\",\"^L\",\"~:row\",1,\"~:row-span\",1,\"~:shapes\",[]],\"~uaef184da-e9c1-80f8-8007-961cf50d67b5\",[\"^ \",\"^K\",\"^L\",\"^M\",2,\"^N\",\"~uaef184da-e9c1-80f8-8007-961cf50d67b5\",\"^O\",\"^L\",\"^P\",1,\"^Q\",\"^L\",\"^R\",1,\"^S\",1,\"^T\",[]],\"~uaef184da-e9c1-80f8-8007-961cf50d67b6\",[\"^ \",\"^K\",\"^L\",\"^M\",1,\"^N\",\"~uaef184da-e9c1-80f8-8007-961cf50d67b6\",\"^O\",\"^L\",\"^P\",1,\"^Q\",\"^L\",\"^R\",2,\"^S\",1,\"^T\",[]],\"~uaef184da-e9c1-80f8-8007-961cf50d67b7\",[\"^ \",\"^K\",\"^L\",\"^M\",2,\"^N\",\"~uaef184da-e9c1-80f8-8007-961cf50d67b7\",\"^O\",\"^L\",\"^P\",1,\"^Q\",\"^L\",\"^R\",2,\"^S\",1,\"^T\",[]]],\"~:layout-padding-type\",\"~:simple\",\"^2\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",322,\"~:y\",286]],[\"^10\",[\"^ \",\"~:x\",620,\"~:y\",286]],[\"^10\",[\"^ \",\"~:x\",620,\"~:y\",552]],[\"^10\",[\"^ \",\"~:x\",322,\"~:y\",552]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",0,\"~:column-gap\",0],\"~:transform-inverse\",[\"^>\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:layout-justify-content\",\"~:stretch\",\"~:r1\",0,\"^N\",\"~uaef184da-e9c1-80f8-8007-961cf253d534\",\"~:layout-justify-items\",\"^G\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:layout-align-content\",\"^19\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",322,\"~:proportion\",1,\"~:r4\",0,\"~:layout-grid-rows\",[[\"^ \",\"^2\",\"^3\",\"^4\",1],[\"^ \",\"^2\",\"^3\",\"^4\",1]],\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",322,\"~:y\",286,\"^H\",298,\"~:height\",266,\"~:x1\",322,\"~:y1\",286,\"~:x2\",620,\"~:y2\",552]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#FFFFFF\",\"~:fill-opacity\",1]],\"~:layout-grid-dir\",\"^R\",\"~:flip-x\",null,\"^1E\",266,\"~:flip-y\",null,\"^T\",[]]]"
}
},
"~:id": "~u0472abff-2573-8186-8007-961793e53f46",
"~:name": "Page 1"
}
},
"~:id": "~u0472abff-2573-8186-8007-961793e53f45",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
}
}
}

View File

@@ -0,0 +1,23 @@
[
{
"~:id": "~u3ea49ce0-9d99-8197-8007-95037190405b",
"~:label": "Version 000",
"~:revn": 1,
"~:version": 67,
"~:created-at": "~m1771254407745",
"~:modified-at": "~m1771254407745",
"~:created-by": "user",
"~:profile-id": "~u99e49e93-362f-80ef-8007-3450ea5204aa"
},
{
"~:revn": 0,
"~:modified-at": "~m1771254406526",
"~:deleted-at": "~m1771340806524",
"~:created-by": "system",
"~:label": "internal/snapshot/0",
"~:id": "~u3ea49ce0-9d99-8197-8007-9503705f8b9b",
"~:profile-id": "~u99e49e93-362f-80ef-8007-3450ea5204aa",
"~:version": 67,
"~:created-at": "~m1771254406526"
}
]

View File

@@ -0,0 +1,9 @@
[
{
"~:id": "~u99e49e93-362f-80ef-8007-3450ea5204aa",
"~:email": "belen@example.com",
"~:name": "Belén Albeza",
"~:fullname": "Belén Albeza",
"~:is-active": true
}
]

View File

@@ -0,0 +1,18 @@
{
"~:revn": 14,
"~:lagged": [
{
"~:id": "~u0472abff-2573-8186-8007-96347d525f65",
"~:revn": 15,
"~:file-id": "~u0472abff-2573-8186-8007-961793e53f45",
"~:session-id": "~uf25e6d2f-d10c-8021-8007-96344433f08d",
"~:changes": [
{
"~:type": "~:del-obj",
"~:page-id": "~u0472abff-2573-8186-8007-961793e53f46",
"~:id": "~uaef184da-e9c1-80f8-8007-961cf253d534"
}
]
}
]
}

View File

@@ -22,7 +22,7 @@ export class BasePage {
* @param {*} options * @param {*} options
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
static async mockRPC(page, path, jsonFilename, options) { static async mockRPC(page, path, jsonFilename = "", options = {}) {
if (!page) { if (!page) {
throw new TypeError("Invalid page argument. Must be a Playwright page."); throw new TypeError("Invalid page argument. Must be a Playwright page.");
} }
@@ -41,7 +41,7 @@ export class BasePage {
return page.route(url, (route) => return page.route(url, (route) =>
route.fulfill({ route.fulfill({
...interceptConfig, ...interceptConfig,
path: `playwright/data/${jsonFilename}`, path: jsonFilename ? `playwright/data/${jsonFilename}` : undefined,
}), }),
); );
} }

View File

@@ -35,8 +35,8 @@ export class WasmWorkspacePage extends WorkspacePage {
return WasmWorkspacePage.mockConfigFlags(this.page, flags); return WasmWorkspacePage.mockConfigFlags(this.page, flags);
} }
constructor(page) { constructor(page, options) {
super(page); super(page, options);
this.canvas = page.getByTestId("canvas-wasm-shapes"); this.canvas = page.getByTestId("canvas-wasm-shapes");
} }
@@ -54,6 +54,19 @@ export class WasmWorkspacePage extends WorkspacePage {
await this.hideUI(); await this.hideUI();
} }
async getRenderCount() {
return this.page.evaluate(() => window.wasmRenderCount || 0);
}
async waitForNextRender(previousCount = null) {
const baseCount =
previousCount === null ? await this.getRenderCount() : previousCount;
await this.page.waitForFunction(
(count) => (window.wasmRenderCount || 0) > count,
baseCount,
);
}
async hideUI() { async hideUI() {
await this.page.keyboard.press("\\"); await this.page.keyboard.press("\\");
await expect(this.pageName).not.toBeVisible(); await expect(this.pageName).not.toBeVisible();

View File

@@ -35,45 +35,9 @@ export class WorkspacePage extends BaseWebSocketPage {
} }
async waitForEditor() { async waitForEditor() {
return this.page.waitForSelector('[data-itype="editor"]'); const typographyInput =
} this.workspacePage.rightSidebar.getByLabel("Font Size");
await expect(typographyInput).toBeVisible();
async waitForRoot() {
return this.page.waitForSelector('[data-itype="root"]');
}
async waitForParagraph(nth) {
if (!nth) {
return this.page.waitForSelector('[data-itype="paragraph"]');
}
return this.page.waitForSelector(
`[data-itype="paragraph"]:nth-child(${nth})`,
);
}
async waitForParagraphStyle(nth, styleName) {
const paragraph = await this.waitForParagraph(nth);
return this.waitForStyle(paragraph, styleName);
}
async waitForTextSpan(nth = 0) {
if (!nth) {
return this.page.waitForSelector('[data-itype="span"]');
}
return this.page.waitForSelector(
`[data-itype="span"]:nth-child(${nth})`,
);
}
async waitForTextSpanContent(nth = 0) {
const textSpan = await this.waitForTextSpan(nth);
const textContent = await textSpan.textContent();
return textContent;
}
async waitForTextSpanStyle(nth, styleName) {
const textSpan = await this.waitForTextSpan(nth);
return this.waitForStyle(textSpan, styleName);
} }
async startEditing() { async startEditing() {
@@ -98,7 +62,7 @@ export class WorkspacePage extends BaseWebSocketPage {
} }
async moveFromStart(offset = 0) { async moveFromStart(offset = 0) {
await this.page.keyboard.press("ArrowLeft"); await this.page.keyboard.press("Home");
await this.moveToRight(offset); await this.moveToRight(offset);
} }
@@ -125,7 +89,7 @@ export class WorkspacePage extends BaseWebSocketPage {
await expect(locator).toBeVisible(); await expect(locator).toBeVisible();
await locator.focus(); await locator.focus();
await locator.fill(`${newValue}`); await locator.fill(`${newValue}`);
await locator.blur(); await this.page.keyboard.press("Enter");
} }
changeFontSize(newValue) { changeFontSize(newValue) {
@@ -317,7 +281,6 @@ export class WorkspacePage extends BaseWebSocketPage {
body, body,
}), }),
); );
// await this.mockRPC(/get\-file\?/, jsonFile);
} }
async mockGetAsset(regex, asset) { async mockGetAsset(regex, asset) {
@@ -391,10 +354,12 @@ export class WorkspacePage extends BaseWebSocketPage {
const timeToWait = options?.timeToWait ?? 100; const timeToWait = options?.timeToWait ?? 100;
await this.page.keyboard.press("T"); await this.page.keyboard.press("T");
await this.page.waitForTimeout(timeToWait); await this.page.waitForTimeout(timeToWait);
const layersCountBefore = await this.layers.getByTestId("layer-row").count();
await this.clickAndMove(x1, y1, x2, y2); await this.clickAndMove(x1, y1, x2, y2);
await expect(this.page.getByTestId("text-editor")).toBeVisible();
if (initialText) { if (initialText) {
await this.waitForSelectedShapeName("Text");
await this.page.keyboard.type(initialText); await this.page.keyboard.type(initialText);
} }
} }
@@ -494,10 +459,23 @@ export class WorkspacePage extends BaseWebSocketPage {
async expectSelectedLayer(name) { async expectSelectedLayer(name) {
await expect( await expect(
this.layers this.layers.getByRole("checkbox", { name, checked: true }),
.getByTestId("layer-row") ).toBeVisible();
.filter({ has: this.page.getByText(name) }), }
).toHaveClass(/selected/);
async getSelectedShapeName() {
const selectedLayer = this.layers
.getByRole("checkbox", { checked: true })
.first();
await selectedLayer.waitFor({ state: "visible" });
return (await selectedLayer.innerText()).trim();
}
async waitForSelectedShapeName(expectedName) {
const selectedLayer = this.layers
.getByRole("checkbox", { checked: true })
.first();
await expect(selectedLayer).toHaveText(expectedName);
} }
async expectHiddenToolbarOptions() { async expectHiddenToolbarOptions() {

View File

@@ -356,3 +356,39 @@ test("Renders shapes with multiple fills and blur", async ({
await expect(workspace.canvas).toHaveScreenshot(); await expect(workspace.canvas).toHaveScreenshot();
}); });
test("Keeps component visible when focusing after creating it", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockRPC(/get\-file\?/, "workspace/get-file-not-empty.json");
await workspace.mockRPC(
"update-file?id=*",
"workspace/update-file-create-rect.json",
);
await workspace.goToWorkspace({
fileId: "6191cd35-bb1f-81f7-8004-7cc63d087374",
pageId: "6191cd35-bb1f-81f7-8004-7cc63d087375",
});
await workspace.waitForFirstRender();
await workspace.clickLayers();
await workspace.clickLeafLayer("Rectangle");
await page.keyboard.press("ControlOrMeta+k");
const componentLayer = workspace.layers
.getByTestId("layer-row")
.filter({ has: page.getByTestId("icon-component") })
.first();
await expect(componentLayer).toBeVisible();
await componentLayer.click();
const previousRenderCount = await workspace.getRenderCount();
await page.keyboard.press("f");
await workspace.waitForNextRender(previousRenderCount);
await workspace.hideUI();
await expect(workspace.canvas).toHaveScreenshot();
});

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 KiB

After

Width:  |  Height:  |  Size: 348 KiB

View File

@@ -1,14 +1,14 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
import { Clipboard } from "../../helpers/Clipboard"; import { Clipboard } from "../../helpers/Clipboard";
import { WorkspacePage } from "../pages/WorkspacePage"; import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
const timeToWait = 100; const timeToWait = 100;
test.beforeEach(async ({ page, context }) => { test.beforeEach(async ({ page, context }) => {
await Clipboard.enable(context, Clipboard.Permission.ALL); await Clipboard.enable(context, Clipboard.Permission.ALL);
await WorkspacePage.init(page); await WasmWorkspacePage.init(page);
await WorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-v2"]); await WasmWorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-v2"]);
}); });
test.afterEach(async ({ context }) => { test.afterEach(async ({ context }) => {
@@ -17,22 +17,21 @@ test.afterEach(async ({ context }) => {
test("Create a new text shape", async ({ page }) => { test("Create a new text shape", async ({ page }) => {
const initialText = "Lorem ipsum"; const initialText = "Lorem ipsum";
const workspace = new WorkspacePage(page, { const workspace = new WasmWorkspacePage(page, {
textEditor: true, textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
await workspace.goToWorkspace(); await workspace.goToWorkspace();
await workspace.createTextShape(190, 150, 300, 200, initialText); await workspace.createTextShape(190, 150, 300, 200, initialText);
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe(initialText);
await workspace.textEditor.stopEditing(); await workspace.textEditor.stopEditing();
await workspace.waitForSelectedShapeName(initialText);
}); });
test("Create a new text shape from pasting text", async ({ page, context }) => { test("Create a new text shape from pasting text", async ({ page, context }) => {
const textToPaste = "Lorem ipsum"; const textToPaste = "Lorem ipsum";
const workspace = new WorkspacePage(page, { const workspace = new WasmWorkspacePage(page, {
textEditor: true, textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
@@ -43,13 +42,9 @@ test("Create a new text shape from pasting text", async ({ page, context }) => {
await workspace.clickAt(190, 150); await workspace.clickAt(190, 150);
await workspace.paste("keyboard"); await workspace.paste("keyboard");
await page.waitForTimeout(timeToWait);
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe(textToPaste);
await workspace.textEditor.stopEditing(); await workspace.textEditor.stopEditing();
await expect(workspace.layers.getByText(textToPaste)).toBeVisible();
}); });
test("Create a new text shape from pasting text using context menu", async ({ test("Create a new text shape from pasting text using context menu", async ({
@@ -57,7 +52,7 @@ test("Create a new text shape from pasting text using context menu", async ({
context, context,
}) => { }) => {
const textToPaste = "Lorem ipsum"; const textToPaste = "Lorem ipsum";
const workspace = new WorkspacePage(page, { const workspace = new WasmWorkspacePage(page, {
textEditor: true, textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
@@ -67,17 +62,15 @@ test("Create a new text shape from pasting text using context menu", async ({
await workspace.clickAt(190, 150); await workspace.clickAt(190, 150);
await workspace.paste("context-menu"); await workspace.paste("context-menu");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe(textToPaste);
await workspace.textEditor.stopEditing(); await workspace.textEditor.stopEditing();
await expect(workspace.layers.getByText(textToPaste)).toBeVisible();
}); });
test("Update an already created text shape by appending text", async ({ test("Update an already created text shape by appending text", async ({
page, page,
}) => { }) => {
const workspace = new WorkspacePage(page, { const workspace = new WasmWorkspacePage(page, {
textEditor: true, textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
@@ -87,15 +80,14 @@ test("Update an already created text shape by appending text", async ({
await workspace.textEditor.startEditing(); await workspace.textEditor.startEditing();
await workspace.textEditor.moveFromEnd(0); await workspace.textEditor.moveFromEnd(0);
await page.keyboard.type(" dolor sit amet"); await page.keyboard.type(" dolor sit amet");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe("Lorem ipsum dolor sit amet");
await workspace.textEditor.stopEditing(); await workspace.textEditor.stopEditing();
await workspace.waitForSelectedShapeName("Lorem ipsum dolor sit amet");
}); });
test("Update an already created text shape by prepending text", async ({ test("Update an already created text shape by prepending text", async ({
page, page,
}) => { }) => {
const workspace = new WorkspacePage(page, { const workspace = new WasmWorkspacePage(page, {
textEditor: true, textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
@@ -105,15 +97,14 @@ test("Update an already created text shape by prepending text", async ({
await workspace.textEditor.startEditing(); await workspace.textEditor.startEditing();
await workspace.textEditor.moveFromStart(0); await workspace.textEditor.moveFromStart(0);
await page.keyboard.type("Dolor sit amet "); await page.keyboard.type("Dolor sit amet ");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe("Dolor sit amet Lorem ipsum");
await workspace.textEditor.stopEditing(); await workspace.textEditor.stopEditing();
await workspace.waitForSelectedShapeName("Dolor sit amet Lorem ipsum");
}); });
test.skip("Update an already created text shape by inserting text in between", async ({ test.skip("Update an already created text shape by inserting text in between", async ({
page, page,
}) => { }) => {
const workspace = new WorkspacePage(page, { const workspace = new WasmWorkspacePage(page, {
textEditor: true, textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
@@ -123,9 +114,8 @@ test.skip("Update an already created text shape by inserting text in between", a
await workspace.textEditor.startEditing(); await workspace.textEditor.startEditing();
await workspace.textEditor.moveFromStart(5); await workspace.textEditor.moveFromStart(5);
await page.keyboard.type(" dolor sit amet"); await page.keyboard.type(" dolor sit amet");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe("Lorem dolor sit amet ipsum");
await workspace.textEditor.stopEditing(); await workspace.textEditor.stopEditing();
await workspace.waitForSelectedShapeName("Lorem dolor sit amet ipsum");
}); });
test("Update a new text shape appending text by pasting text", async ({ test("Update a new text shape appending text by pasting text", async ({
@@ -133,7 +123,7 @@ test("Update a new text shape appending text by pasting text", async ({
context, context,
}) => { }) => {
const textToPaste = " dolor sit amet"; const textToPaste = " dolor sit amet";
const workspace = new WorkspacePage(page, { const workspace = new WasmWorkspacePage(page, {
textEditor: true, textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
@@ -146,17 +136,16 @@ test("Update a new text shape appending text by pasting text", async ({
await workspace.textEditor.startEditing(); await workspace.textEditor.startEditing();
await workspace.textEditor.moveFromEnd(); await workspace.textEditor.moveFromEnd();
await workspace.paste("keyboard"); await workspace.paste("keyboard");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe("Lorem ipsum dolor sit amet");
await workspace.textEditor.stopEditing(); await workspace.textEditor.stopEditing();
}); await workspace.waitForSelectedShapeName("Lorem ipsum dolor sit amet");
});
test.skip("Update a new text shape prepending text by pasting text", async ({ test.skip("Update a new text shape prepending text by pasting text", async ({
page, page,
context, context,
}) => { }) => {
const textToPaste = "Dolor sit amet "; const textToPaste = "Dolor sit amet ";
const workspace = new WorkspacePage(page, { const workspace = new WasmWorkspacePage(page, {
textEditor: true, textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
@@ -169,16 +158,17 @@ test.skip("Update a new text shape prepending text by pasting text", async ({
await workspace.textEditor.startEditing(); await workspace.textEditor.startEditing();
await workspace.textEditor.moveFromStart(); await workspace.textEditor.moveFromStart();
await workspace.paste("keyboard"); await workspace.paste("keyboard");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe("Dolor sit amet Lorem ipsum");
await workspace.textEditor.stopEditing(); await workspace.textEditor.stopEditing();
await workspace.hideUI();
await expect(workspace.canvas).toHaveScreenshot();
}); });
test("Update a new text shape replacing (starting) text with pasted text", async ({ test("Update a new text shape replacing (starting) text with pasted text", async ({
page, page,
}) => { }) => {
const textToPaste = "Dolor sit amet"; const textToPaste = "Dolor sit amet";
const workspace = new WorkspacePage(page, { const workspace = new WasmWorkspacePage(page, {
textEditor: true, textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
@@ -192,17 +182,15 @@ test("Update a new text shape replacing (starting) text with pasted text", async
await workspace.paste("keyboard"); await workspace.paste("keyboard");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe("Dolor sit amet ipsum");
await workspace.textEditor.stopEditing(); await workspace.textEditor.stopEditing();
await workspace.waitForSelectedShapeName("Dolor sit amet ipsum");
}); });
test("Update a new text shape replacing (ending) text with pasted text", async ({ test("Update a new text shape replacing (ending) text with pasted text", async ({
page, page,
}) => { }) => {
const textToPaste = "dolor sit amet"; const textToPaste = "dolor sit amet";
const workspace = new WorkspacePage(page, { const workspace = new WasmWorkspacePage(page, {
textEditor: true, textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
@@ -216,17 +204,15 @@ test("Update a new text shape replacing (ending) text with pasted text", async (
await workspace.paste("keyboard"); await workspace.paste("keyboard");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe("Lorem dolor sit amet");
await workspace.textEditor.stopEditing(); await workspace.textEditor.stopEditing();
await workspace.waitForSelectedShapeName("Lorem dolor sit amet");
}); });
test("Update a new text shape replacing (in between) text with pasted text", async ({ test("Update a new text shape replacing (in between) text with pasted text", async ({
page, page,
}) => { }) => {
const textToPaste = "dolor sit amet"; const textToPaste = "dolor sit amet";
const workspace = new WorkspacePage(page, { const workspace = new WasmWorkspacePage(page, {
textEditor: true, textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
@@ -240,16 +226,14 @@ test("Update a new text shape replacing (in between) text with pasted text", asy
await workspace.paste("keyboard"); await workspace.paste("keyboard");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe("Lordolor sit ametsum");
await workspace.textEditor.stopEditing(); await workspace.textEditor.stopEditing();
await workspace.waitForSelectedShapeName("Lordolor sit ametsum");
}); });
test("Update text font size selecting a part of it (starting)", async ({ test("Update text font size selecting a part of it (starting)", async ({
page, page,
}) => { }) => {
const workspace = new WorkspacePage(page, { const workspace = new WasmWorkspacePage(page, {
textEditor: true, textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
@@ -260,18 +244,16 @@ test("Update text font size selecting a part of it (starting)", async ({
await workspace.textEditor.startEditing(); await workspace.textEditor.startEditing();
await workspace.textEditor.selectFromStart(5); await workspace.textEditor.selectFromStart(5);
await workspace.textEditor.changeFontSize(36); await workspace.textEditor.changeFontSize(36);
const textContent1 = await workspace.textEditor.waitForTextSpanContent(1);
expect(textContent1).toBe("Lorem");
const textContent2 = await workspace.textEditor.waitForTextSpanContent(2);
expect(textContent2).toBe(" ipsum");
await workspace.textEditor.stopEditing(); await workspace.textEditor.stopEditing();
await workspace.hideUI();
await expect(workspace.canvas).toHaveScreenshot();
}); });
test.skip("Update text line height selecting a part of it (starting)", async ({ test("Update text line height selecting a part of it (starting)", async ({
page, page,
}) => { }) => {
const workspace = new WorkspacePage(page, { const workspace = new WasmWorkspacePage(page, {
textEditor: true, textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
@@ -281,24 +263,17 @@ test.skip("Update text line height selecting a part of it (starting)", async ({
await workspace.clickLeafLayer("Lorem ipsum"); await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing(); await workspace.textEditor.startEditing();
await workspace.textEditor.selectFromStart(5); await workspace.textEditor.selectFromStart(5);
await workspace.textEditor.changeLineHeight(1.4); await workspace.textEditor.changeLineHeight(4.4);
const lineHeight = await workspace.textEditor.waitForParagraphStyle(
1,
"line-height",
);
expect(lineHeight).toBe("1.4");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe("Lorem ipsum");
await workspace.textEditor.stopEditing(); await workspace.textEditor.stopEditing();
await workspace.hideUI();
await expect(workspace.canvas).toHaveScreenshot();
}); });
test.skip("Update text letter spacing selecting a part of it (starting)", async ({ test.skip("Update text letter spacing selecting a part of it (starting)", async ({
page, page,
}) => { }) => {
const workspace = new WorkspacePage(page, { const workspace = new WasmWorkspacePage(page, {
textEditor: true, textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
@@ -309,16 +284,14 @@ test.skip("Update text letter spacing selecting a part of it (starting)", async
await workspace.textEditor.startEditing(); await workspace.textEditor.startEditing();
await workspace.textEditor.selectFromStart(5); await workspace.textEditor.selectFromStart(5);
await workspace.textEditor.changeLetterSpacing(10); await workspace.textEditor.changeLetterSpacing(10);
const textContent1 = await workspace.textEditor.waitForTextSpanContent(1);
expect(textContent1).toBe("Lorem");
const textContent2 = await workspace.textEditor.waitForTextSpanContent(2);
expect(textContent2).toBe(" ipsum");
await workspace.textEditor.stopEditing(); await workspace.textEditor.stopEditing();
await workspace.hideUI();
await expect(workspace.canvas).toHaveScreenshot();
}); });
test("BUG 11552 - Apply styles to the current caret", async ({ page }) => { test("BUG 11552 - Apply styles to the current caret", async ({ page }) => {
const workspace = new WorkspacePage(page); const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-11552.json"); await workspace.mockGetFile("text-editor/get-file-11552.json");
await workspace.mockRPC( await workspace.mockRPC(

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -112,3 +112,31 @@ test("BUG 11006 - Fix history panel shortcut", async ({ page }) => {
workspacePage.rightSidebar.getByText("There are no versions yet"), workspacePage.rightSidebar.getByText("There are no versions yet"),
).toBeVisible(); ).toBeVisible();
}); });
test("BUG 13385 - Fix viewport not updating when restoring version", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.mockGetFile("workspace/get-file-13385.json");
await workspacePage.mockRPC("get-profiles-for-file-comments?file-id=*", "workspace/get-profiles-for-file-comments-13385.json");
// navigate to workspace and check that the circle shape is not there
await workspacePage.goToWorkspace();
await expect(workspacePage.layers.getByText("Ellipse")).not.toBeVisible();
// mock network requests to restore the version
await workspacePage.mockGetFile("workspace/get-file-13385-2.json");
await workspacePage.mockRPC("get-file-snapshots?file-id=*", "workspace/get-file-snapshots-13385.json");
await workspacePage.mockRPC("restore-file-snapshot", "", {
status: 204,
});
// request to restore the version
await workspacePage.rightSidebar.getByRole("button", { name: "History" }).click();
await workspacePage.rightSidebar.getByRole("button", { name: "Open version menu" }).click();
await workspacePage.rightSidebar.getByRole("button", { name: "Restore" }).click();
// confirm modal
await workspacePage.page.getByRole("button", { name: /Restore/i }).click();
// assert that the circle shape exists
await expect(workspacePage.layers.getByText("Ellipse")).toBeVisible();
});

View File

@@ -23,4 +23,35 @@ test("BUG 13305 - Fix resize board to fit content", async ({ page }) => {
await expect(workspacePage.rightSidebar.getByTitle("Height").getByRole("textbox")).toHaveValue("630"); await expect(workspacePage.rightSidebar.getByTitle("Height").getByRole("textbox")).toHaveValue("630");
await expect(workspacePage.rightSidebar.getByTitle("X axis").getByRole("textbox")).toHaveValue("110"); await expect(workspacePage.rightSidebar.getByTitle("X axis").getByRole("textbox")).toHaveValue("110");
await expect(workspacePage.rightSidebar.getByTitle("Y axis").getByRole("textbox")).toHaveValue("110"); await expect(workspacePage.rightSidebar.getByTitle("Y axis").getByRole("textbox")).toHaveValue("110");
}); });
test("BUG 13382 - Fix problem with flex layout", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.mockGetFile("workspace/get-file-13382.json");
await workspacePage.mockRPC(
"get-file-fragment?file-id=*&fragment-id=*",
"workspace/get-file-13382-fragment.json",
);
await workspacePage.mockRPC("update-file?id=*", "workspace/update-file-empty.json");
await workspacePage.goToWorkspace({
fileId: "52c4e771-3853-8190-8007-9506c70e8100",
pageId: "ecb0cfd0-0f0b-81f7-8007-950628f9665b",
});
await workspacePage.clickToggableLayer("A");
await workspacePage.clickToggableLayer("B");
await workspacePage.clickToggableLayer("C");
await workspacePage.clickLeafLayer("R2");
const heightText = workspacePage.rightSidebar.getByTitle("Height").getByPlaceholder('--');
await heightText.fill("200");
await heightText.press("Enter");
await workspacePage.clickLeafLayer("B");
await expect(workspacePage.rightSidebar.getByTitle("Height").getByRole("textbox")).toHaveValue("340");
});

View File

@@ -483,3 +483,25 @@ test("Bug 8371 - Flatten option is not visible in context menu", async ({
.filter({ visible: true }), .filter({ visible: true }),
).toBeVisible(); ).toBeVisible();
}); });
test("BUG 13415 - Grid layout overlay is not removed when deleting a board", async ({
page,
}) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockGetFile("workspace/get-file-13415.json");
await workspacePage.mockRPC(
"update-file?id=*",
"workspace/update-file-13415.json",
);
await workspacePage.goToWorkspace();
await workspacePage.clickLeafLayer("Board");
const currentRenderCount = await workspacePage.getRenderCount();
await workspacePage.page.keyboard.press("Delete");
await workspacePage.waitForNextRender(currentRenderCount);
await workspacePage.hideUI();
await expect(workspacePage.canvas).toHaveScreenshot();
});

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -20,8 +20,8 @@ importers:
specifier: workspace:./packages/mousetrap specifier: workspace:./packages/mousetrap
version: link:packages/mousetrap version: link:packages/mousetrap
'@penpot/plugins-runtime': '@penpot/plugins-runtime':
specifier: 1.4.2 specifier: link:../plugins/dist/plugins-runtime
version: 1.4.2 version: link:../plugins/dist/plugins-runtime
'@penpot/svgo': '@penpot/svgo':
specifier: penpot/svgo#v3.2 specifier: penpot/svgo#v3.2
version: svgo@https://codeload.github.com/penpot/svgo/tar.gz/8c9b0e32e9cb5f106085260bd9375f3c91a5010b version: svgo@https://codeload.github.com/penpot/svgo/tar.gz/8c9b0e32e9cb5f106085260bd9375f3c91a5010b
@@ -581,15 +581,6 @@ packages:
'@dabh/diagnostics@2.0.8': '@dabh/diagnostics@2.0.8':
resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==}
'@endo/cache-map@1.1.0':
resolution: {integrity: sha512-owFGshs/97PDw9oguZqU/px8Lv1d0KjAUtDUiPwKHNXRVUE/jyettEbRoTbNJR1OaI8biMn6bHr9kVJsOh6dXw==}
'@endo/env-options@1.1.11':
resolution: {integrity: sha512-p9OnAPsdqoX4YJsE98e3NBVhIr2iW9gNZxHhAI2/Ul5TdRfoOViItzHzTqrgUVopw6XxA1u1uS6CykLMDUxarA==}
'@endo/immutable-arraybuffer@1.1.2':
resolution: {integrity: sha512-u+NaYB2aqEugQ3u7w3c5QNkPogf8q/xGgsPaqdY6pUiGWtYiTiFspKFcha6+oeZhWXWQ23rf0KrUq0kfuzqYyQ==}
'@esbuild/aix-ppc64@0.21.5': '@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -1258,12 +1249,6 @@ packages:
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
'@penpot/plugin-types@1.4.2':
resolution: {integrity: sha512-O8wU6RSYE8bIVU7g8cSTYi32ppxs3R13dq7X3Nn9tmDaJjBOKOBpVLuoRPIp3fJC65fv8/7om0sdrtFoL5v19g==}
'@penpot/plugins-runtime@1.4.2':
resolution: {integrity: sha512-y9TDZOnb96JBW9E33dHKpmTMeAPXLtHDIZruUVjtM8hBJWZK7RCv+vAGDGxeoZJC/OB2YAHrCZG+mukePBzcuQ==}
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -4636,9 +4621,6 @@ packages:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
ses@1.14.0:
resolution: {integrity: sha512-T07hNgOfVRTLZGwSS50RnhqrG3foWP+rM+Q5Du4KUQyMLFI3A8YA4RKl0jjZzhihC1ZvDGrWi/JMn4vqbgr/Jg==}
set-function-length@1.2.2: set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -5499,9 +5481,6 @@ packages:
peerDependencies: peerDependencies:
zod: ^3.25.0 || ^4.0.0 zod: ^3.25.0 || ^4.0.0
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zod@4.3.6: zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
@@ -5775,12 +5754,6 @@ snapshots:
enabled: 2.0.0 enabled: 2.0.0
kuler: 2.0.0 kuler: 2.0.0
'@endo/cache-map@1.1.0': {}
'@endo/env-options@1.1.11': {}
'@endo/immutable-arraybuffer@1.1.2': {}
'@esbuild/aix-ppc64@0.21.5': '@esbuild/aix-ppc64@0.21.5':
optional: true optional: true
@@ -6297,14 +6270,6 @@ snapshots:
'@parcel/watcher-win32-x64': 2.5.6 '@parcel/watcher-win32-x64': 2.5.6
optional: true optional: true
'@penpot/plugin-types@1.4.2': {}
'@penpot/plugins-runtime@1.4.2':
dependencies:
'@penpot/plugin-types': 1.4.2
ses: 1.14.0
zod: 3.25.76
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
optional: true optional: true
@@ -10000,12 +9965,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
ses@1.14.0:
dependencies:
'@endo/cache-map': 1.1.0
'@endo/env-options': 1.1.11
'@endo/immutable-arraybuffer': 1.1.2
set-function-length@1.2.2: set-function-length@1.2.2:
dependencies: dependencies:
define-data-property: 1.1.4 define-data-property: 1.1.4
@@ -10974,6 +10933,4 @@ snapshots:
dependencies: dependencies:
zod: 4.3.6 zod: 4.3.6
zod@3.25.76: {}
zod@4.3.6: {} zod@4.3.6: {}

View File

@@ -119,6 +119,10 @@
(normalize-uri (or (obj/get global "penpotPublicURI") (normalize-uri (or (obj/get global "penpotPublicURI")
(obj/get location "origin")))) (obj/get location "origin"))))
(def mcp-ws-uri
(or (some-> (obj/get global "penpotMcpServerURI") u/uri)
(u/join public-uri "mcp/ws")))
(def rasterizer-uri (def rasterizer-uri
(or (some-> (obj/get global "penpotRasterizerURI") normalize-uri) (or (some-> (obj/get global "penpotRasterizerURI") normalize-uri)
public-uri)) public-uri))
@@ -147,6 +151,9 @@
(let [f (obj/get global "initializeExternalConfigInfo")] (let [f (obj/get global "initializeExternalConfigInfo")]
(when (fn? f) (f)))) (when (fn? f) (f))))
(def mcp-server-url (-> public-uri u/ensure-path-slash (u/join "mcp") str))
(def mcp-help-center-uri "https://help.penpot.app/technical-guide/")
;; --- Helper Functions ;; --- Helper Functions
(defn ^boolean check-browser? [candidate] (defn ^boolean check-browser? [candidate]

View File

@@ -99,46 +99,65 @@
map with temporal ID's associated to each font entry." map with temporal ID's associated to each font entry."
[blobs team-id] [blobs team-id]
(letfn [(prepare [{:keys [font type name data] :as params}] (letfn [(prepare [{:keys [font type name data] :as params}]
(let [family (or (.getEnglishName ^js font "preferredFamily") (if font
(.getEnglishName ^js font "fontFamily")) ;; Font was parsed with opentype.js (ttf, otf, woff)
variant (or (.getEnglishName ^js font "preferredSubfamily") (let [family (or (.getEnglishName ^js font "preferredFamily")
(.getEnglishName ^js font "fontSubfamily")) (.getEnglishName ^js font "fontFamily"))
variant (or (.getEnglishName ^js font "preferredSubfamily")
(.getEnglishName ^js font "fontSubfamily"))
;; Vertical metrics determine the baseline in a text and the space between lines of ;; Vertical metrics determine the baseline in a text and the space between lines of
;; text. For historical reasons, there are three pairs of ascender/descender ;; text. For historical reasons, there are three pairs of ascender/descender
;; values, known as hhea, OS/2 and uSWin metrics. Depending on the font, operating ;; values, known as hhea, OS/2 and uSWin metrics. Depending on the font, operating
;; system and application a different set will be used to render text on the ;; system and application a different set will be used to render text on the
;; screen. On Mac, Safari and Chrome use the hhea values to render text. Firefox ;; screen. On Mac, Safari and Chrome use the hhea values to render text. Firefox
;; will respect the useTypoMetrics setting and will use the OS/2 if it is set. If ;; will respect the useTypoMetrics setting and will use the OS/2 if it is set. If
;; the useTypoMetrics is not set, Firefox will also use metrics from the hhea ;; the useTypoMetrics is not set, Firefox will also use metrics from the hhea
;; table. On Windows, all browsers use the usWin metrics, but respect the ;; table. On Windows, all browsers use the usWin metrics, but respect the
;; useTypoMetrics setting and if set will use the OS/2 values. ;; useTypoMetrics setting and if set will use the OS/2 values.
hhea-ascender (abs (-> ^js font .-tables .-hhea .-ascender)) hhea-ascender (abs (-> ^js font .-tables .-hhea .-ascender))
hhea-descender (abs (-> ^js font .-tables .-hhea .-descender)) hhea-descender (abs (-> ^js font .-tables .-hhea .-descender))
win-ascent (abs (-> ^js font .-tables .-os2 .-usWinAscent)) win-ascent (abs (-> ^js font .-tables .-os2 .-usWinAscent))
win-descent (abs (-> ^js font .-tables .-os2 .-usWinDescent)) win-descent (abs (-> ^js font .-tables .-os2 .-usWinDescent))
os2-ascent (abs (-> ^js font .-tables .-os2 .-sTypoAscender)) os2-ascent (abs (-> ^js font .-tables .-os2 .-sTypoAscender))
os2-descent (abs (-> ^js font .-tables .-os2 .-sTypoDescender)) os2-descent (abs (-> ^js font .-tables .-os2 .-sTypoDescender))
;; useTypoMetrics can be read from the 7th bit ;; useTypoMetrics can be read from the 7th bit
f-selection (-> ^js font .-tables .-os2 .-fsSelection (bit-test 7)) f-selection (-> ^js font .-tables .-os2 .-fsSelection (bit-test 7))
height-warning? (or (not= hhea-ascender win-ascent) height-warning? (or (not= hhea-ascender win-ascent)
(not= hhea-descender win-descent) (not= hhea-descender win-descent)
(and f-selection (or (and f-selection (or
(not= hhea-ascender os2-ascent) (not= hhea-ascender os2-ascent)
(not= hhea-descender os2-descent)))) (not= hhea-descender os2-descent))))
data (js/Uint8Array. data)] data (js/Uint8Array. data)]
{:content {:data (chunk-array data default-chunk-size) {:content {:data (chunk-array data default-chunk-size)
:name name :name name
:type type} :type type}
:font-family (or family "") :font-family (or family "")
:font-weight (cm/parse-font-weight variant) :font-weight (cm/parse-font-weight variant)
:font-style (cm/parse-font-style variant) :font-style (cm/parse-font-style variant)
:height-warning? height-warning?})) :height-warning? height-warning?})
;; Font could not be parsed (woff2), extract metadata from filename
(let [base-name (str/replace name #"\.[^.]+$" "")
;; Strip known weight/style tokens and separators to derive family name
;; Use word boundaries to avoid matching substrings (e.g. "Boldini" should not match "bold")
raw-family-name (-> base-name
(str/replace #"(?i)(^|[-_\s])(extra\s*black|ultra\s*black|extra\s*bold|ultra\s*bold|semi\s*bold|demi\s*bold|extra\s*light|ultra\s*light|hairline|thin|light|normal|regular|medium|bold|black|heavy|solid|italic)([-_\s]|$)" "$1$3")
(str/replace #"[-_\s]+" " ")
(str/trim))
family-name (if (str/blank? raw-family-name) base-name raw-family-name)
data (js/Uint8Array. data)]
{:content {:data (chunk-array data default-chunk-size)
:name name
:type type}
:font-family family-name
:font-weight (cm/parse-font-weight base-name)
:font-style (cm/parse-font-style base-name)
:height-warning? false})))
(join [res {:keys [content] :as font}] (join [res {:keys [content] :as font}]
(let [key-fn (juxt :font-family :font-weight :font-style) (let [key-fn (juxt :font-family :font-weight :font-style)
@@ -166,14 +185,18 @@
(case sg (case sg
"117 124 124 117" "font/otf" "117 124 124 117" "font/otf"
"0 1 0 0" "font/ttf" "0 1 0 0" "font/ttf"
"167 117 106 106" "font/woff"))) "167 117 106 106" "font/woff"
"167 117 106 62" "font/woff2")))
(parse-font [{:keys [data] :as params}] (parse-font [{:keys [data type name] :as params}]
(try (if (= type "font/woff2")
(assoc params :font (ot/parse data)) ;; woff2 cannot be parsed by opentype.js, extract metadata from filename
(catch :default _e (assoc params :font nil)
(log/warn :msg (str/fmt "skipping file %s, unsupported format" (:name params))) (try
nil))) (assoc params :font (ot/parse data))
(catch :default _e
(log/warn :msg (str/fmt "skipping file %s, unsupported format" name))
nil))))
(read-blob [blob] (read-blob [blob]
(->> (wa/read-file-as-array-buffer blob) (->> (wa/read-file-as-array-buffer blob)

View File

@@ -0,0 +1,15 @@
(ns app.main.data.nitrate
(:require
[app.main.data.modal :as modal]
[app.main.repo :as rp]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
(defn show-nitrate-popup
[]
(ptk/reify ::show-nitrate-popup
ptk/WatchEvent
(watch [_ _ _]
(->> (rp/cmd! ::get-nitrate-connectivity {})
(rx/map (fn [connectivity]
(modal/show :nitrate-form (or connectivity {}))))))))

View File

@@ -65,8 +65,23 @@
(update [_ state] (update [_ state]
(update-in state [:workspace-local :open-plugins] (fnil disj #{}) id)))) (update-in state [:workspace-local :open-plugins] (fnil disj #{}) id))))
(defn start-plugin!
[{:keys [plugin-id name version description host code permissions allow-background]} ^js extensions]
(.ɵloadPlugin
^js ug/global
#js {:pluginId plugin-id
:name name
:version version
:description description
:host host
:code code
:allowBackground (boolean allow-background)
:permissions (apply array permissions)}
nil
extensions))
(defn- load-plugin! (defn- load-plugin!
[{:keys [plugin-id name description host code icon permissions]}] [{:keys [plugin-id name description host code icon permissions] :as params}]
(try (try
(st/emit! (save-current-plugin plugin-id) (st/emit! (save-current-plugin plugin-id)
(reset-plugin-flags plugin-id)) (reset-plugin-flags plugin-id))

View File

@@ -498,4 +498,3 @@
(->> (rp/cmd! :delete-access-token params) (->> (rp/cmd! :delete-access-token params)
(rx/tap on-success) (rx/tap on-success)
(rx/catch on-error)))))) (rx/catch on-error))))))

View File

@@ -52,6 +52,7 @@
[app.main.data.workspace.layers :as dwly] [app.main.data.workspace.layers :as dwly]
[app.main.data.workspace.layout :as layout] [app.main.data.workspace.layout :as layout]
[app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.mcp :as mcp]
[app.main.data.workspace.notifications :as dwn] [app.main.data.workspace.notifications :as dwn]
[app.main.data.workspace.pages :as dwpg] [app.main.data.workspace.pages :as dwpg]
[app.main.data.workspace.path :as dwdp] [app.main.data.workspace.path :as dwdp]
@@ -212,7 +213,8 @@
ptk/WatchEvent ptk/WatchEvent
(watch [_ _ _] (watch [_ _ _]
(rx/of (dp/check-open-plugin) (rx/of (dp/check-open-plugin)
(fdf/fix-deleted-fonts-for-local-library file-id))))) (fdf/fix-deleted-fonts-for-local-library file-id)
(mcp/init-mcp-connexion)))))
(defn- bundle-fetched (defn- bundle-fetched
[{:keys [file file-id thumbnails] :as bundle}] [{:keys [file file-id thumbnails] :as bundle}]
@@ -222,9 +224,16 @@
ptk/UpdateEvent ptk/UpdateEvent
(update [_ state] (update [_ state]
(-> state (let [pending-version-id (:workspace-pending-file-version-id state)
(assoc :thumbnails thumbnails) state (-> state
(update :files assoc file-id file))))) (assoc :thumbnails thumbnails)
(update :files assoc file-id file)
(dissoc :workspace-pending-file-version-id))]
(cond-> state
(some? pending-version-id)
(assoc :workspace-file-version-id pending-version-id)
(nil? pending-version-id)
(dissoc :workspace-file-version-id))))))
(defn zoom-to-frame (defn zoom-to-frame
[] []
@@ -280,192 +289,197 @@
(wasm.api/process-object shape)))))) (wasm.api/process-object shape))))))
(defn initialize-workspace (defn initialize-workspace
[team-id file-id] ([team-id file-id]
(assert (uuid? team-id) "expected valud uuid for `team-id`") (initialize-workspace team-id file-id nil))
(assert (uuid? file-id) "expected valud uuid for `file-id`") ([team-id file-id version-id]
(assert (uuid? team-id) "expected valud uuid for `team-id`")
(assert (uuid? file-id) "expected valud uuid for `file-id`")
(ptk/reify ::initialize-workspace (ptk/reify ::initialize-workspace
ptk/UpdateEvent ptk/UpdateEvent
(update [_ state] (update [_ state]
(-> state (-> state
(assoc :recent-colors (:recent-colors storage/user)) (assoc :recent-colors (:recent-colors storage/user))
(assoc :recent-fonts (:recent-fonts storage/user)) (assoc :recent-fonts (:recent-fonts storage/user))
(assoc :current-file-id file-id) (assoc :current-file-id file-id)
(assoc :workspace-presence {}))) (assoc :workspace-presence {})
;; Store pending version-id; bundle-fetched will set workspace-file-version-id
;; when the new bundle is applied so the viewport re-inits with new data
(assoc :workspace-pending-file-version-id version-id)))
ptk/WatchEvent ptk/WatchEvent
(watch [_ state stream] (watch [_ state stream]
(let [stoper-s (rx/filter (ptk/type? ::finalize-workspace) stream) (let [stoper-s (rx/filter (ptk/type? ::finalize-workspace) stream)
rparams (rt/get-params state) rparams (rt/get-params state)
features (features/get-enabled-features state team-id) features (features/get-enabled-features state team-id)
render-wasm? (contains? features "render-wasm/v1")] render-wasm? (contains? features "render-wasm/v1")]
(log/debug :hint "initialize-workspace" (log/debug :hint "initialize-workspace"
:team-id (dm/str team-id) :team-id (dm/str team-id)
:file-id (dm/str file-id)) :file-id (dm/str file-id))
(->> (rx/merge (->> (rx/merge
(rx/concat (rx/concat
;; Fetch all essential data that should be loaded before the file ;; Fetch all essential data that should be loaded before the file
(rx/merge (rx/merge
(if ^boolean render-wasm? (if ^boolean render-wasm?
(->> (rx/from @wasm/module) (->> (rx/from @wasm/module)
(rx/filter true?) (rx/filter true?)
(rx/tap (fn [_] (rx/tap (fn [_]
(let [event (ug/event "penpot:wasm:loaded")] (let [event (ug/event "penpot:wasm:loaded")]
(ug/dispatch! event)))) (ug/dispatch! event))))
(rx/ignore)) (rx/ignore))
(rx/empty)) (rx/empty))
(->> stream (->> stream
(rx/filter (ptk/type? ::df/fonts-loaded)) (rx/filter (ptk/type? ::df/fonts-loaded))
(rx/take 1) (rx/take 1)
(rx/ignore)) (rx/ignore))
(rx/of (ntf/hide) (rx/of (ntf/hide)
(dcmt/retrieve-comment-threads file-id) (dcmt/retrieve-comment-threads file-id)
(dcmt/fetch-profiles) (dcmt/fetch-profiles)
(df/fetch-fonts team-id))) (df/fetch-fonts team-id)))
;; Once the essential data is fetched, lets proceed to ;; Once the essential data is fetched, lets proceed to
;; fetch teh file bunldle ;; fetch teh file bunldle
(rx/of (fetch-bundle file-id features))) (rx/of (fetch-bundle file-id features)))
(->> stream (->> stream
(rx/filter (ptk/type? ::bundle-fetched)) (rx/filter (ptk/type? ::bundle-fetched))
(rx/take 1) (rx/take 1)
(rx/map deref) (rx/map deref)
(rx/mapcat (rx/mapcat
(fn [{:keys [file]}] (fn [{:keys [file]}]
(log/debug :hint "bundle fetched" (log/debug :hint "bundle fetched"
:team-id (dm/str team-id) :team-id (dm/str team-id)
:file-id (dm/str file-id)) :file-id (dm/str file-id))
(rx/of (dpj/initialize-project (:project-id file)) (rx/of (dpj/initialize-project (:project-id file))
(dwn/initialize team-id file-id) (dwn/initialize team-id file-id)
(dwsl/initialize-shape-layout) (dwsl/initialize-shape-layout)
(fetch-libraries file-id features) (fetch-libraries file-id features)
(-> (workspace-initialized file-id) (-> (workspace-initialized file-id)
(with-meta {:team-id team-id (with-meta {:team-id team-id
:file-id file-id})))))) :file-id file-id}))))))
;; Install dev perf observers once the workspace is ready ;; Install dev perf observers once the workspace is ready
(when (contains? cf/flags :perf-logs) (when (contains? cf/flags :perf-logs)
(->> stream (->> stream
(rx/filter (ptk/type? ::workspace-initialized)) (rx/filter (ptk/type? ::workspace-initialized))
(rx/take 1) (rx/take 1)
(rx/tap (fn [_] (perf/setup))))) (rx/tap (fn [_] (perf/setup)))))
(->> stream (->> stream
(rx/filter (ptk/type? ::dps/persistence-notification)) (rx/filter (ptk/type? ::dps/persistence-notification))
(rx/take 1) (rx/take 1)
(rx/map dwc/set-workspace-visited)) (rx/map dwc/set-workspace-visited))
(when-let [component-id (some-> rparams :component-id uuid/parse)] (when-let [component-id (some-> rparams :component-id uuid/parse)]
(->> stream (->> stream
(rx/filter (ptk/type? ::workspace-initialized)) (rx/filter (ptk/type? ::workspace-initialized))
(rx/observe-on :async) (rx/observe-on :async)
(rx/take 1) (rx/take 1)
(rx/map #(dwl/go-to-local-component :id component-id :update-layout? (:update-layout rparams))))) (rx/map #(dwl/go-to-local-component :id component-id :update-layout? (:update-layout rparams)))))
(when (:board-id rparams) (when (:board-id rparams)
(->> stream (->> stream
(rx/filter (ptk/type? ::dwv/initialize-viewport)) (rx/filter (ptk/type? ::dwv/initialize-viewport))
(rx/take 1) (rx/take 1)
(rx/map zoom-to-frame))) (rx/map zoom-to-frame)))
(when-let [comment-id (some-> rparams :comment-id uuid/parse)] (when-let [comment-id (some-> rparams :comment-id uuid/parse)]
(->> stream (->> stream
(rx/filter (ptk/type? ::workspace-initialized)) (rx/filter (ptk/type? ::workspace-initialized))
(rx/observe-on :async) (rx/observe-on :async)
(rx/take 1) (rx/take 1)
(rx/map #(dwcm/navigate-to-comment-id comment-id)))) (rx/map #(dwcm/navigate-to-comment-id comment-id))))
(when render-wasm? (when render-wasm?
(->> stream (->> stream
(rx/filter dch/commit?) (rx/filter dch/commit?)
(rx/map deref) (rx/map deref)
(rx/mapcat (rx/mapcat
(fn [{:keys [redo-changes]}] (fn [{:keys [redo-changes]}]
(let [added (->> redo-changes (let [added (->> redo-changes
(filter #(= (:type %) :add-obj)) (filter #(= (:type %) :add-obj))
(map :id))] (map :id))]
(->> (rx/from added) (->> (rx/from added)
(rx/map process-wasm-object))))))) (rx/map process-wasm-object)))))))
(when render-wasm? (when render-wasm?
(let [local-commits-s (let [local-commits-s
(->> stream (->> stream
(rx/filter dch/commit?) (rx/filter dch/commit?)
(rx/map deref) (rx/map deref)
(rx/filter #(and (= :local (:source %)) (rx/filter #(and (= :local (:source %))
(not (contains? (:tags %) :position-data)))) (not (contains? (:tags %) :position-data))))
(rx/filter (complement empty?))) (rx/filter (complement empty?)))
notifier-s notifier-s
(rx/merge (rx/merge
(->> local-commits-s (rx/debounce 1000)) (->> local-commits-s (rx/debounce 1000))
(->> stream (rx/filter dps/force-persist?))) (->> stream (rx/filter dps/force-persist?)))
objects-s objects-s
(rx/from-atom refs/workspace-page-objects {:emit-current-value? true}) (rx/from-atom refs/workspace-page-objects {:emit-current-value? true})
current-page-id-s current-page-id-s
(rx/from-atom refs/current-page-id {:emit-current-value? true})] (rx/from-atom refs/current-page-id {:emit-current-value? true})]
(->> local-commits-s (->> local-commits-s
(rx/buffer-until notifier-s) (rx/buffer-until notifier-s)
(rx/with-latest-from objects-s) (rx/with-latest-from objects-s)
(rx/map (rx/map
(fn [[commits objects]] (fn [[commits objects]]
(->> commits (->> commits
(mapcat :redo-changes) (mapcat :redo-changes)
(filter #(contains? #{:mod-obj :add-obj} (:type %))) (filter #(contains? #{:mod-obj :add-obj} (:type %)))
(filter #(cfh/text-shape? objects (:id %))) (filter #(cfh/text-shape? objects (:id %)))
(map #(vector (map #(vector
(:id %) (:id %)
(wasm.api/calculate-position-data (get objects (:id %)))))))) (wasm.api/calculate-position-data (get objects (:id %))))))))
(rx/with-latest-from current-page-id-s) (rx/with-latest-from current-page-id-s)
(rx/map (rx/map
(fn [[text-position-data page-id]] (fn [[text-position-data page-id]]
(let [changes (let [changes
(->> text-position-data (->> text-position-data
(mapv (fn [[id position-data]] (mapv (fn [[id position-data]]
{:type :mod-obj {:type :mod-obj
:id id :id id
:page-id page-id :page-id page-id
:operations :operations
[{:type :set [{:type :set
:attr :position-data :attr :position-data
:val position-data :val position-data
:ignore-touched true :ignore-touched true
:ignore-geometry true}]})))] :ignore-geometry true}]})))]
(when (d/not-empty? changes) (when (d/not-empty? changes)
(dch/commit-changes (dch/commit-changes
{:redo-changes changes :undo-changes [] {:redo-changes changes :undo-changes []
:save-undo? false :save-undo? false
:tags #{:position-data}}))))) :tags #{:position-data}})))))
(rx/take-until stoper-s)))) (rx/take-until stoper-s))))
(->> stream (->> stream
(rx/filter dch/commit?) (rx/filter dch/commit?)
(rx/map deref) (rx/map deref)
(rx/mapcat (rx/mapcat
(fn [{:keys [save-undo? undo-changes redo-changes undo-group tags stack-undo?]}] (fn [{:keys [save-undo? undo-changes redo-changes undo-group tags stack-undo?]}]
(if (and save-undo? (seq undo-changes)) (if (and save-undo? (seq undo-changes))
(let [entry {:undo-changes undo-changes (let [entry {:undo-changes undo-changes
:redo-changes redo-changes :redo-changes redo-changes
:undo-group undo-group :undo-group undo-group
:tags tags}] :tags tags}]
(rx/of (dwu/append-undo entry stack-undo?))) (rx/of (dwu/append-undo entry stack-undo?)))
(rx/empty)))))) (rx/empty))))))
(rx/take-until stoper-s)))) (rx/take-until stoper-s))))
ptk/EffectEvent ptk/EffectEvent
(effect [_ _ _] (effect [_ _ _]
(let [name (dm/str "workspace-" file-id)] (let [name (dm/str "workspace-" file-id)]
(unchecked-set ug/global "name" name))))) (unchecked-set ug/global "name" name))))))
(defn finalize-workspace (defn finalize-workspace
[_team-id file-id] [_team-id file-id]
@@ -1434,6 +1448,7 @@
(dm/export dwcp/paste-shapes) (dm/export dwcp/paste-shapes)
(dm/export dwcp/paste-data-valid?) (dm/export dwcp/paste-data-valid?)
(dm/export dwcp/copy-link-to-clipboard) (dm/export dwcp/copy-link-to-clipboard)
(dm/export dwcp/copy-as-image)
;; Drawing ;; Drawing
(dm/export dwd/select-for-drawing) (dm/export dwd/select-for-drawing)

View File

@@ -1039,3 +1039,55 @@
ptk/WatchEvent ptk/WatchEvent
(watch [_ _ _] (watch [_ _ _]
(clipboard/to-clipboard (rt/get-current-href))))) (clipboard/to-clipboard (rt/get-current-href)))))
(defn copy-as-image
[]
(ptk/reify ::copy-as-image
ptk/WatchEvent
(watch [_ state _]
(let [file-id (:current-file-id state)
page-id (:current-page-id state)
selected (first (dsh/lookup-selected state))
export {:file-id file-id
:page-id page-id
:object-id selected
;; webp would be preferrable, but PNG is the most supported image MIME type by clipboard APIs.
:type :png
;; Always use 2 to ensure good enough quality for wireframes.
:scale 2
:suffix ""
:enabled true
:name ""}
params {:exports [export]
:profile-id (:profile-id state)
:cmd :export-shapes
:wait true}]
(rx/concat
;; Ensure current state persisted before exporting.
(rx/of ::dps/force-persist)
(->> (rx/from-atom refs/persistence-state {:emit-current-value? true})
(rx/filter #(or (nil? %) (= :saved %)))
(rx/first)
(rx/timeout 400 (rx/empty)))
;; Exporting itself can time its time, better to notify that we are busy.
(rx/of (ntf/info (tr "workspace.clipboard.copying")))
;; Call exporter to get image URI, then fetch and copy blob.
(->> (rp/cmd! :export params)
(rx/mapcat (fn [{:keys [uri]}]
(http/send! {:method :get
:uri uri
:response-type :blob})))
(rx/map :body)
(rx/tap (fn [blob]
(clipboard/to-clipboard-promise "image/png" (p/resolved blob))))
(rx/map (fn [_]
(ntf/success (tr "workspace.clipboard.image-copied"))))
(rx/catch (fn [e]
(js/console.error "clipboard blocked:" e)
(ntf/error (tr "workspace.clipboard.image-copy-failed"))
(rx/empty)))))))))

View File

@@ -6,6 +6,7 @@
(ns app.main.data.workspace.drawing.box (ns app.main.data.workspace.drawing.box
(:require (:require
[app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.geom.point :as gpt] [app.common.geom.point :as gpt]
[app.common.geom.rect :as grc] [app.common.geom.rect :as grc]
@@ -28,9 +29,9 @@
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
[potok.v2.core :as ptk])) [potok.v2.core :as ptk]))
(defn adjust-ratio (defn- adjust-ratio
[point initial] [point initial]
(let [v (gpt/to-vec point initial) (let [v (gpt/to-vec point initial)
dx (mth/abs (:x v)) dx (mth/abs (:x v))
dy (mth/abs (:y v)) dy (mth/abs (:y v))
sx (mth/sign (:x v)) sx (mth/sign (:x v))
@@ -43,32 +44,43 @@
(> dy dx) (> dy dx)
(assoc :x (- (:x point) (* sx (- dy dx))))))) (assoc :x (- (:x point) (* sx (- dy dx)))))))
(defn resize-shape [{:keys [x y width height] :as shape} initial point lock? mod? snap-pixel?] (defn- resize-shape
[{:keys [x y width height] :as shape} initial point lock? mod? snap-pixel?]
(if (and (some? x) (some? y) (some? width) (some? height)) (if (and (some? x) (some? y) (some? width) (some? height))
(let [draw-rect (cond-> (grc/make-rect initial (cond-> point lock? (adjust-ratio initial))) (let [p2
snap-pixel? (cond-> point lock? (adjust-ratio initial))
(-> (update :width max 1)
(update :height max 1)))
shape-rect (grc/make-rect x y width height) p1
(if mod?
(gpt/point (- (* 2 (:x initial)) (:x p2))
(- (* 2 (:y initial)) (:y p2)))
initial)
scalev (gpt/point (/ (:width draw-rect) draw-rect
(:width shape-rect)) (cond-> (grc/make-rect p1 p2)
(/ (:height draw-rect) snap-pixel?
(:height shape-rect))) (-> (update :width d/max 1)
(update :height d/max 1)))
movev (gpt/to-vec (gpt/point shape-rect) shape-rect
(gpt/point draw-rect))] (grc/make-rect x y width height)
scalev
(gpt/point (/ (:width draw-rect) (:width shape-rect))
(/ (:height draw-rect) (:height shape-rect)))
movev
(gpt/to-vec (gpt/point shape-rect) (gpt/point draw-rect))]
(-> shape (-> shape
(assoc :click-draw? false) (assoc :click-draw? false)
(vary-meta merge {:mod? mod?})
(gsh/transform-shape (-> (ctm/empty) (gsh/transform-shape (-> (ctm/empty)
(ctm/resize scalev (gpt/point x y)) (ctm/resize scalev (gpt/point x y))
(ctm/move movev))))) (ctm/move movev)))))
shape)) shape))
(defn- update-drawing [state initial point lock? mod? snap-pixel?] (defn- update-drawing
[state initial point lock? mod? snap-pixel?]
(update-in state [:workspace-drawing :object] resize-shape initial point lock? mod? snap-pixel?)) (update-in state [:workspace-drawing :object] resize-shape initial point lock? mod? snap-pixel?))
(defn move-drawing (defn move-drawing
@@ -128,7 +140,7 @@
;; Take until before the snap calculation otherwise we could cancel the snap in the worker ;; Take until before the snap calculation otherwise we could cancel the snap in the worker
;; and its a problem for fast moving drawing ;; and its a problem for fast moving drawing
(rx/take-until stopper) (rx/take-until stopper)
(rx/with-latest-from ms/mouse-position-shift ms/mouse-position-mod) (rx/with-latest-from ms/mouse-position-shift ms/mouse-position-alt)
(rx/switch-map (rx/switch-map
(fn [[point :as current]] (fn [[point :as current]]
(->> (snap/closest-snap-point page-id [shape] objects layout zoom focus point) (->> (snap/closest-snap-point page-id [shape] objects layout zoom focus point)

View File

@@ -0,0 +1,60 @@
;; 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.data.workspace.mcp
(:require
[app.common.logging :as log]
[app.common.uri :as u]
[app.config :as cf]
[app.main.data.plugins :as dp]
[app.main.repo :as rp]
[app.plugins.register :refer [mcp-plugin-id]]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
(log/set-level! :info)
(def ^:private default-manifest
{:code "plugin.js"
:name "Penpot MCP Plugin"
:version 2
:plugin-id mcp-plugin-id
:description "This plugin enables interaction with the Penpot MCP server"
:allow-background true
:permissions
#{"library:read" "library:write"
"comment:read" "comment:write"
"content:write" "content:read"}})
(defn init-mcp!
[]
(->> (rp/cmd! :get-current-mcp-token)
(rx/subs!
(fn [{:keys [token]}]
(when token
(dp/start-plugin!
(assoc default-manifest
:url (str (u/join cf/public-uri "plugins/mcp/manifest.json"))
:host (str (u/join cf/public-uri "plugins/mcp/")))
;; API extension for MCP server
#js {:mcp
#js
{:getToken (constantly token)
:getServerUrl #(str cf/mcp-ws-uri)
:setMcpStatus
(fn [status]
;; TODO: Visual feedback
(log/info :hint "MCP STATUS" :status status))}}))))))
(defn init-mcp-connexion
[]
(ptk/reify ::init-mcp-connexion
ptk/EffectEvent
(effect [_ state _]
(when (and (contains? cf/flags :mcp)
(-> state :profile :props :mcp-status))
(init-mcp!)))))

View File

@@ -108,7 +108,7 @@
(rx/take 1) (rx/take 1)
(rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id id})) (rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id id}))
(rx/tap #(th/clear-queue!)) (rx/tap #(th/clear-queue!))
(rx/map #(dw/initialize-workspace team-id file-id))) (rx/map #(dw/initialize-workspace team-id file-id id)))
(case origin (case origin
:version :version
(rx/of (ptk/event ::ev/event {::ev/name "restore-pin-version"})) (rx/of (ptk/event ::ev/event {::ev/name "restore-pin-version"}))
@@ -231,7 +231,7 @@
(rx/filter #(or (nil? %) (= :saved %))) (rx/filter #(or (nil? %) (= :saved %)))
(rx/take 1) (rx/take 1)
(rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id id})) (rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id id}))
(rx/map #(dw/initialize-workspace team-id file-id))) (rx/map #(dw/initialize-workspace team-id file-id id)))
(->> (rx/of 1) (->> (rx/of 1)
(rx/tap resolve) (rx/tap resolve)

View File

@@ -256,6 +256,9 @@
(def workspace-layout (def workspace-layout
(l/derived :workspace-layout st/state)) (l/derived :workspace-layout st/state))
(def workspace-file-version-id
(l/derived :workspace-file-version-id st/state))
(def snap-pixel? (def snap-pixel?
(l/derived #(contains? % :snap-pixel-grid) workspace-layout)) (l/derived #(contains? % :snap-pixel-grid) workspace-layout))

View File

@@ -197,7 +197,7 @@
:settings-options :settings-options
:settings-feedback :settings-feedback
:settings-subscription :settings-subscription
:settings-access-tokens :settings-integrations
:settings-notifications) :settings-notifications)
(let [params (get params :query) (let [params (get params :query)
error-report-id (some-> params :error-report-id uuid/parse*)] error-report-id (some-> params :error-report-id uuid/parse*)]

View File

@@ -78,7 +78,7 @@
(kbd/enter? event) (kbd/enter? event)
(let [selected (dom/get-active)] (let [selected (dom/get-active)]
(dom/prevent-default event) (dom/prevent-default event)
(dom/click! selected)) (dom/click selected))
(kbd/tab? event) (kbd/tab? event)
(on-close)))))] (on-close)))))]

View File

@@ -32,6 +32,7 @@
input-name (get props :name) input-name (get props :name)
more-classes (get props :class) more-classes (get props :class)
auto-focus? (get props :auto-focus? false) auto-focus? (get props :auto-focus? false)
input-ref (mf/use-ref nil)
data-testid (d/nilv data-testid input-name) data-testid (d/nilv data-testid input-name)
@@ -82,7 +83,6 @@
(swap! form assoc-in [:touched input-name] true) (swap! form assoc-in [:touched input-name] true)
(fm/on-input-change form input-name value trim) (fm/on-input-change form input-name value trim)
(on-change-value name value))) (on-change-value name value)))
on-blur on-blur
(fn [_] (fn [_]
(reset! focus? false)) (reset! focus? false))
@@ -92,9 +92,18 @@
(when-not (get-in @form [:touched input-name]) (when-not (get-in @form [:touched input-name])
(swap! form assoc-in [:touched input-name] true))) (swap! form assoc-in [:touched input-name] true)))
on-key-press
(mf/use-fn
(mf/deps input-ref)
(fn [e]
(dom/prevent-default e)
(when (kbd/space? e)
(dom/click (mf/ref-val input-ref)))))
props (-> props props (-> props
(dissoc :help-icon :form :trim :children :show-success? :auto-focus? :label) (dissoc :help-icon :form :trim :children :show-success? :auto-focus? :label)
(assoc :id (name input-name) (assoc :id (name input-name)
:ref input-ref
:value value :value value
:auto-focus auto-focus? :auto-focus auto-focus?
:on-click (when (or is-radio? is-checkbox?) on-click) :on-click (when (or is-radio? is-checkbox?) on-click)
@@ -131,7 +140,7 @@
:for (name input-name)} label :for (name input-name)} label
(when is-checkbox? (when is-checkbox?
[:span {:class (stl/css-case :global/checked checked?)} (when checked? deprecated-icon/status-tick)]) [:span {:class (stl/css-case :global/checked checked?) :tab-index "0" :on-key-press on-key-press} (when checked? deprecated-icon/status-tick)])
(if is-checkbox? (if is-checkbox?
[:> :input props] [:> :input props]

View File

@@ -9,14 +9,17 @@
(:require (:require
[app.main.data.modal :as modal] [app.main.data.modal :as modal]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]]
[app.main.ui.ds.notifications.context-notification :refer [context-notification*]] [app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
[app.main.ui.icons :as deprecated-icon]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]] [app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as k] [app.util.keyboard :as k]
[goog.events :as events] [goog.events :as events]
[rumext.v2 :as mf]) [rumext.v2 :as mf])
(:import goog.events.EventType)) (:import
goog.events.EventType))
(mf/defc confirm-dialog (mf/defc confirm-dialog
{::mf/register modal/components {::mf/register modal/components
@@ -68,8 +71,11 @@
[:div {:class (stl/css :modal-container)} [:div {:class (stl/css :modal-container)}
[:div {:class (stl/css :modal-header)} [:div {:class (stl/css :modal-header)}
[:h2 {:class (stl/css :modal-title)} title] [:h2 {:class (stl/css :modal-title)} title]
[:button {:class (stl/css :modal-close-btn) [:div {:class (stl/css :modal-close-btn)}
:on-click cancel-fn} deprecated-icon/close]] [:> icon-button* {:variant "ghost"
:aria-label (tr "labels.close")
:on-click cancel-fn
:icon i/close}]]]
[:div {:class (stl/css :modal-content)} [:div {:class (stl/css :modal-content)}
(when (and (string? message) (not= message "")) (when (and (string? message) (not= message ""))
@@ -87,24 +93,19 @@
[:ul {:class (stl/css :component-list)} [:ul {:class (stl/css :component-list)}
(for [item items] (for [item items]
[:li {:class (stl/css :modal-item-element)} [:li {:class (stl/css :modal-item-element)}
[:span {:class (stl/css :modal-component-icon)} [:> icon* {:icon-id i/component
deprecated-icon/component] :class (stl/css :modal-component-icon)
:size "s"}]
[:span {:class (stl/css :modal-component-name)} [:span {:class (stl/css :modal-component-name)}
(:name item)]])]])] (:name item)]])]])]
[:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons)} [:div {:class (stl/css :action-buttons)}
(when-not (= cancel-label :omit) (when-not (= cancel-label :omit)
[:input [:> button* {:variant "secondary"
{:class (stl/css :cancel-button) :on-click cancel-fn}
:type "button" cancel-label])
:value cancel-label [:> button* {:variant (cond (= accept-style :danger) "destructive"
:on-click cancel-fn}]) (= accept-style :primary) "primary")
:on-click accept-fn}
[:input accept-label]]]]]))
{:class (stl/css-case :accept-btn true
:danger (= accept-style :danger)
:primary (= accept-style :primary))
:type "button"
:value accept-label
:on-click accept-fn}]]]]]))

View File

@@ -15,10 +15,9 @@
.modal-container { .modal-container {
@extend .modal-container-base; @extend .modal-container-base;
} display: flex;
flex-direction: column;
.modal-header { gap: var(--sp-xxl);
margin-bottom: deprecated.$s-24;
} }
.modal-title { .modal-title {
@@ -27,12 +26,13 @@
} }
.modal-close-btn { .modal-close-btn {
@extend .modal-close-btn-base; position: absolute;
top: var(--sp-m);
right: var(--sp-m);
} }
.modal-content { .modal-content {
@include deprecated.bodyLargeTypography; @include deprecated.bodyLargeTypography;
margin-bottom: deprecated.$s-24;
} }
.modal-item-element { .modal-item-element {
@@ -41,32 +41,18 @@
.modal-component-icon { .modal-component-icon {
@include deprecated.flexCenter; @include deprecated.flexCenter;
height: deprecated.$s-16; color: var(--color-foreground-secondary);
width: deprecated.$s-16;
svg {
@extend .button-icon-small;
stroke: var(--color);
}
} }
.modal-component-name { .modal-component-name {
@include deprecated.bodyLargeTypography; @include deprecated.bodyLargeTypography;
color: var(--color-foreground-secondary);
} }
.action-buttons { .action-buttons {
@extend .modal-action-btns; @extend .modal-action-btns;
} }
.cancel-button {
@extend .modal-cancel-btn;
}
.accept-btn {
@extend .modal-accept-btn;
&.danger {
@extend .modal-danger-btn;
}
}
.modal-scd-msg, .modal-scd-msg,
.modal-subtitle, .modal-subtitle,
.modal-msg { .modal-msg {

View File

@@ -7,7 +7,9 @@
(ns app.main.ui.dashboard.fonts (ns app.main.ui.dashboard.fonts
(:require-macros [app.main.style :as stl]) (:require-macros [app.main.style :as stl])
(:require (:require
[app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.media :as cm] [app.common.media :as cm]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf] [app.config :as cf]
@@ -22,6 +24,7 @@
[app.main.ui.icons :as deprecated-icon] [app.main.ui.icons :as deprecated-icon]
[app.main.ui.notifications.context-notification :refer [context-notification]] [app.main.ui.notifications.context-notification :refer [context-notification]]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.http :as http]
[app.util.i18n :as i18n :refer [tr]] [app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd] [app.util.keyboard :as kbd]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
@@ -32,7 +35,7 @@
(def ^:private accept-font-types (def ^:private accept-font-types
(str (str/join "," cm/font-types) (str (str/join "," cm/font-types)
;; A workaround to solve a problem with chrome input selector ;; A workaround to solve a problem with chrome input selector
",.ttf,application/font-woff,woff,.otf")) ",.ttf,application/font-woff,.woff,.woff2,.otf"))
(defn- use-page-title (defn- use-page-title
[team section] [team section]
@@ -116,10 +119,10 @@
(swap! fonts* dissoc id) (swap! fonts* dissoc id)
(swap! uploading* disj id) (swap! uploading* disj id)
(st/emit! (df/add-font font))) (st/emit! (df/add-font font)))
(fn [error] (fn [cause]
(st/emit! (ntf/error (tr "errors.bad-font" (first (:names item))))) (st/emit! (ntf/error (tr "errors.bad-font" (first (:names item)))))
(swap! fonts* dissoc id) (swap! fonts* dissoc id)
(js/console.log "error" error)))))) (ex/print-throwable cause))))))
on-upload on-upload
(mf/use-fn (mf/use-fn
@@ -259,11 +262,14 @@
(mf/defc installed-font-context-menu (mf/defc installed-font-context-menu
{::mf/props :obj {::mf/props :obj
::mf/private true} ::mf/private true}
[{:keys [is-open on-close on-edit on-delete]}] [{:keys [is-open on-close on-edit on-download on-delete]}]
(let [options (mf/with-memo [on-edit on-delete] (let [options (mf/with-memo [on-edit on-download on-delete]
[{:name (tr "labels.edit") [{:name (tr "labels.edit")
:id "font-edit" :id "font-edit"
:handler on-edit} :handler on-edit}
{:name (tr "labels.download-simple")
:id "font-download"
:handler on-download}
{:name (tr "labels.delete") {:name (tr "labels.delete")
:id "font-delete" :id "font-delete"
:handler on-delete}])] :handler on-delete}])]
@@ -345,6 +351,26 @@
(st/emit! (df/delete-font font-id)))}] (st/emit! (df/delete-font font-id)))}]
(st/emit! (modal/show options))))) (st/emit! (modal/show options)))))
on-download
(mf/use-fn
(mf/deps variants)
(fn [_event]
(let [variant (first variants)
variant-id (:id variant)
multiple? (> (count variants) 1)
cmd (if multiple? :download-font-family :download-font)
params (if multiple? {:font-id font-id} {:id variant-id})]
(->> (rp/cmd! cmd params)
(rx/mapcat (fn [{:keys [name uri]}]
(->> (http/send! {:uri uri :method :get :response-type :blob})
(rx/map :body)
(rx/map (fn [blob] (d/vec2 name blob))))))
(rx/subs! (fn [[filename blob]]
(dom/trigger-download filename blob))
(fn [error]
(js/console.error "error downloading font" error)
(st/emit! (ntf/error (tr "errors.download-font")))))))))
on-delete-variant on-delete-variant
(mf/use-fn (mf/use-fn
(fn [event] (fn [event]
@@ -407,6 +433,7 @@
{:on-close on-menu-close {:on-close on-menu-close
:is-open menu-open? :is-open menu-open?
:on-delete on-delete-font :on-delete on-delete-font
:on-download on-download
:on-edit on-edit}]]))])) :on-edit on-edit}]]))]))
(mf/defc installed-fonts* (mf/defc installed-fonts*

View File

@@ -77,7 +77,7 @@
(mf/use-ref nil) (mf/use-ref nil)
on-import-files on-import-files
(fn [] (dom/click! (mf/ref-val file-input))) (fn [] (dom/click (mf/ref-val file-input)))
on-finish-import on-finish-import
(mf/use-fn (mf/use-fn

View File

@@ -16,6 +16,7 @@
[app.main.data.dashboard :as dd] [app.main.data.dashboard :as dd]
[app.main.data.event :as ev] [app.main.data.event :as ev]
[app.main.data.modal :as modal] [app.main.data.modal :as modal]
[app.main.data.nitrate :as dnt]
[app.main.data.notifications :as ntf] [app.main.data.notifications :as ntf]
[app.main.data.team :as dtm] [app.main.data.team :as dtm]
[app.main.refs :as refs] [app.main.refs :as refs]
@@ -30,10 +31,13 @@
[app.main.ui.dashboard.subscription :refer [dashboard-cta* [app.main.ui.dashboard.subscription :refer [dashboard-cta*
get-subscription-type get-subscription-type
menu-team-icon* menu-team-icon*
nitrate-sidebar*
show-subscription-dashboard-banner? show-subscription-dashboard-banner?
subscription-sidebar*]] subscription-sidebar*]]
[app.main.ui.dashboard.team-form] [app.main.ui.dashboard.team-form]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]]
[app.main.ui.icons :as deprecated-icon] [app.main.ui.icons :as deprecated-icon]
[app.main.ui.nitrate.nitrate-form] [app.main.ui.nitrate.nitrate-form]
[app.util.dom :as dom] [app.util.dom :as dom]
@@ -74,6 +78,8 @@
(def ^:private exit-icon (def ^:private exit-icon
(deprecated-icon/icon-xref :exit (stl/css :exit-icon))) (deprecated-icon/icon-xref :exit (stl/css :exit-icon)))
(def ^:private ^:svg-id penpot-logo-icon "penpot-logo-icon")
(mf/defc sidebar-project* (mf/defc sidebar-project*
{::mf/private true} {::mf/private true}
[{:keys [item is-selected]}] [{:keys [item is-selected]}]
@@ -299,7 +305,7 @@
(if (:nitrate-licence profile) (if (:nitrate-licence profile)
;; TODO update when org creation route is ready ;; TODO update when org creation route is ready
(dom/open-new-window "/control-center/org/create") (dom/open-new-window "/control-center/org/create")
(st/emit! (modal/show :nitrate-form {})))))] (st/emit! (dnt/show-nitrate-popup)))))]
[:> dropdown-menu* props [:> dropdown-menu* props
@@ -497,18 +503,23 @@
(mf/defc sidebar-org-switch* (mf/defc sidebar-org-switch*
[{:keys [team profile]}] [{:keys [team profile]}]
(let [teams (->> (mf/deref refs/teams) (let [teams (mf/deref refs/teams)
vals orgs (mf/with-memo [teams]
(group-by :organization-id) (let [orgs (->> teams
(map (fn [[_group entries]] (first entries))) vals
vec (group-by :organization-id)
(d/index-by :id)) (map (fn [[_group entries]] (first entries)))
vec
(d/index-by :id))]
(update-vals orgs
(fn [t]
(assoc t :name (str "ORG: " (:organization-name t)))))))
teams (update-vals teams empty? (= (count orgs) 1)
(fn [t]
(assoc t :name (str "ORG: " (:organization-name t)))))
team (assoc team :name (str "ORG: " (:organization-name team)))
current-org (mf/with-memo [team]
(assoc team :name (str "ORG: " (:organization-name team))))
show-teams-menu* show-teams-menu*
(mf/use-state false) (mf/use-state false)
@@ -530,36 +541,53 @@
(dom/prevent-default event) (dom/prevent-default event)
(dom/stop-propagation event) (dom/stop-propagation event)
(some-> (dom/get-current-target event) (some-> (dom/get-current-target event)
(dom/click!))))) (dom/click)))))
close-teams-menu close-teams-menu
(mf/use-fn #(reset! show-teams-menu* false))] (mf/use-fn #(reset! show-teams-menu* false))
[:div {:class (stl/css :sidebar-team-switch)} on-create-org-click
[:div {:class (stl/css :switch-content)} (mf/use-fn
[:button {:class (stl/css :current-team) (fn []
:on-click on-show-teams-click (if (:nitrate-licence profile)
:on-key-down on-show-teams-keydown} ;; TODO update when org creation route is ready
(dom/open-new-window "/control-center/org/create")
(st/emit! (dnt/show-nitrate-popup)))))]
(if empty?
[:div {:class (stl/css :nitrate-orgs-empty)}
[:span {:class (stl/css :nitrate-penpot-icon)}
[:> raw-svg* {:id penpot-logo-icon}]]
"Penpot"
[:> button* {:variant "ghost"
:type "button"
:class (stl/css :nitrate-create-org)
:on-click on-create-org-click} (tr "dashboard.create-new-org")]]
[:div {:class (stl/css :team-name)} [:div {:class (stl/css :sidebar-team-switch)}
[:img {:src (cf/resolve-team-photo-url team) [:div {:class (stl/css :switch-content)}
:class (stl/css :team-picture) [:button {:class (stl/css :current-team)
:alt (:name team)}] :on-click on-show-teams-click
[:span {:class (stl/css :team-text) :title (:name team)} (:name team)]] :on-key-down on-show-teams-keydown}
arrow-icon]] [:div {:class (stl/css :team-name)}
[:img {:src (cf/resolve-team-photo-url current-org)
:class (stl/css :team-picture)
:alt (:name current-org)}]
[:span {:class (stl/css :team-text) :title (:name current-org)} (:name current-org)]]
;; Teams Dropdown arrow-icon]]
[:> teams-selector-dropdown* {:show show-teams-menu? ;; Teams Dropdown
:on-close close-teams-menu
:id "organizations-list" [:> teams-selector-dropdown* {:show show-teams-menu?
:class (stl/css :dropdown :teams-dropdown) :on-close close-teams-menu
:team team :id "organizations-list"
:profile profile :class (stl/css :dropdown :teams-dropdown)
:teams teams :team current-org
:show-default-team false :profile profile
:allow-create-teams false :teams orgs
:allow-create-org true}]])) :show-default-team false
:allow-create-teams false
:allow-create-org true}]])))
(mf/defc sidebar-team-switch* (mf/defc sidebar-team-switch*
[{:keys [team profile]}] [{:keys [team profile]}]
@@ -601,7 +629,7 @@
(dom/prevent-default event) (dom/prevent-default event)
(dom/stop-propagation event) (dom/stop-propagation event)
(some-> (dom/get-current-target event) (some-> (dom/get-current-target event)
(dom/click!))))) (dom/click)))))
close-team-options-menu close-team-options-menu
(mf/use-fn #(reset! show-team-options-menu* false)) (mf/use-fn #(reset! show-team-options-menu* false))
@@ -621,7 +649,7 @@
(dom/stop-propagation event) (dom/stop-propagation event)
(some-> (dom/get-current-target event) (some-> (dom/get-current-target event)
(dom/click!))))) (dom/click)))))
close-teams-menu close-teams-menu
(mf/use-fn #(reset! show-teams-menu* false))] (mf/use-fn #(reset! show-teams-menu* false))]
@@ -705,6 +733,8 @@
overflow* (mf/use-state false) overflow* (mf/use-state false)
overflow? (deref overflow*) overflow? (deref overflow*)
nitrate? (contains? cf/flags :nitrate)
go-projects go-projects
(mf/use-fn #(st/emit! (dcm/go-to-dashboard-recent))) (mf/use-fn #(st/emit! (dcm/go-to-dashboard-recent)))
@@ -793,70 +823,71 @@
(reset! overflow* (> scroll-height client-height)))) (reset! overflow* (> scroll-height client-height))))
[:* [:*
[:div {:class (stl/css-case :sidebar-content true) [:div {:ref container}
:ref container} (when nitrate?
(when (contains? cf/flags :nitrate) [:div {:class (stl/css :nitrate-orgs-container)}
[:> sidebar-org-switch* {:team team :profile profile}]) [:> sidebar-org-switch* {:team team :profile profile}]])
[:> sidebar-team-switch* {:team team :profile profile}] [:div {:class (stl/css-case :sidebar-content true :sidebar-content-nitrate nitrate?)}
[:> sidebar-team-switch* {:team team :profile profile}]
[:> sidebar-search* {:search-term search-term [:> sidebar-search* {:search-term search-term
:team-id (:id team)}] :team-id (:id team)}]
[:div {:class (stl/css :sidebar-content-section)} [:div {:class (stl/css :sidebar-content-section)}
[:ul {:class (stl/css :sidebar-nav)} [:ul {:class (stl/css :sidebar-nav)}
[:li {:class (stl/css-case :recent-projects true [:li {:class (stl/css-case :recent-projects true
:sidebar-nav-item true :sidebar-nav-item true
:current projects?)} :current projects?)}
[:& link {:action go-projects [:& link {:action go-projects
:class (stl/css :sidebar-link) :class (stl/css :sidebar-link)
:keyboard-action go-projects-with-key} :keyboard-action go-projects-with-key}
[:span {:class (stl/css :element-title)} (tr "labels.projects")]]] [:span {:class (stl/css :element-title)} (tr "labels.projects")]]]
[:li {:class (stl/css-case :current drafts? [:li {:class (stl/css-case :current drafts?
:sidebar-nav-item true)} :sidebar-nav-item true)}
[:& link {:action go-drafts [:& link {:action go-drafts
:class (stl/css :sidebar-link) :class (stl/css :sidebar-link)
:keyboard-action go-drafts-with-key} :keyboard-action go-drafts-with-key}
[:span {:class (stl/css :element-title)} (tr "labels.drafts")]]]]] [:span {:class (stl/css :element-title)} (tr "labels.drafts")]]]]]
[:div {:class (stl/css :sidebar-content-section)} [:div {:class (stl/css :sidebar-content-section)}
[:div {:class (stl/css :sidebar-section-title)} [:div {:class (stl/css :sidebar-section-title)}
(tr "labels.sources")] (tr "labels.sources")]
[:ul {:class (stl/css :sidebar-nav)} [:ul {:class (stl/css :sidebar-nav)}
[:li {:class (stl/css-case :sidebar-nav-item true [:li {:class (stl/css-case :sidebar-nav-item true
:current fonts?)} :current fonts?)}
[:& link {:action go-fonts [:& link {:action go-fonts
:class (stl/css :sidebar-link) :class (stl/css :sidebar-link)
:keyboard-action go-fonts-with-key :keyboard-action go-fonts-with-key
:data-testid "fonts"} :data-testid "fonts"}
[:span {:class (stl/css :element-title)} (tr "labels.fonts")]]] [:span {:class (stl/css :element-title)} (tr "labels.fonts")]]]
[:li {:class (stl/css-case :current libs? [:li {:class (stl/css-case :current libs?
:sidebar-nav-item true)} :sidebar-nav-item true)}
[:& link {:action go-libs [:& link {:action go-libs
:data-testid "libs-link-sidebar" :data-testid "libs-link-sidebar"
:class (stl/css :sidebar-link) :class (stl/css :sidebar-link)
:keyboard-action go-libs-with-key} :keyboard-action go-libs-with-key}
[:span {:class (stl/css :element-title)} (tr "labels.shared-libraries")]]]]] [:span {:class (stl/css :element-title)} (tr "labels.shared-libraries")]]]]]
[:div {:class (stl/css :sidebar-content-section) [:div {:class (stl/css :sidebar-content-section)
:data-testid "pinned-projects"} :data-testid "pinned-projects"}
[:div {:class (stl/css :sidebar-section-title)} [:div {:class (stl/css :sidebar-section-title)}
(tr "labels.pinned-projects")] (tr "labels.pinned-projects")]
(if (some? pinned-projects) (if (some? pinned-projects)
[:ul {:class (stl/css :sidebar-nav :pinned-projects)} [:ul {:class (stl/css :sidebar-nav :pinned-projects)}
(for [item pinned-projects] (for [item pinned-projects]
[:> sidebar-project* [:> sidebar-project*
{:item item {:item item
:key (dm/str (:id item)) :key (dm/str (:id item))
:id (:id item) :id (:id item)
:team-id (:id team) :team-id (:id team)
:is-selected (= (:id item) (:id project))}])] :is-selected (= (:id item) (:id project))}])]
[:div {:class (stl/css :sidebar-empty-placeholder)} [:div {:class (stl/css :sidebar-empty-placeholder)}
pin-icon pin-icon
[:span {:class (stl/css :empty-text)} (tr "dashboard.no-projects-placeholder")]])]] [:span {:class (stl/css :empty-text)} (tr "dashboard.no-projects-placeholder")]])]]
[:div {:class (stl/css-case :separator true :overflow-separator overflow?)}]])) [:div {:class (stl/css-case :separator true :overflow-separator overflow?)}]]]))
(mf/defc help-learning-menu* (mf/defc help-learning-menu*
{::mf/props :obj {::mf/props :obj
@@ -1056,10 +1087,13 @@
(dom/open-new-window "https://penpot.app/pricing")))] (dom/open-new-window "https://penpot.app/pricing")))]
[:* [:*
(when (contains? cf/flags :subscriptions) (if (contains? cf/flags :nitrate)
(if (show-subscription-dashboard-banner? profile) (when-not (:nitrate-licence profile)
[:> dashboard-cta* {:profile profile}] [:> nitrate-sidebar* {:profile profile}])
[:> subscription-sidebar* {:profile profile}])) (when (contains? cf/flags :subscriptions)
(if (show-subscription-dashboard-banner? profile)
[:> dashboard-cta* {:profile profile}]
[:> subscription-sidebar* {:profile profile}])))
;; TODO remove this block when subscriptions is full implemented ;; TODO remove this block when subscriptions is full implemented
(when (contains? cf/flags :subscriptions-old) (when (contains? cf/flags :subscriptions-old)

View File

@@ -40,6 +40,11 @@
overflow-y: auto; overflow-y: auto;
} }
.sidebar-content-nitrate {
padding: var(--sp-m) 0 0 0;
border-block-start: $b-1 solid var(--color-background-quaternary);
}
.separator { .separator {
height: var(--sp-xxs); height: var(--sp-xxs);
width: 94%; width: 94%;
@@ -514,3 +519,44 @@
@include t.use-typography("body-small"); @include t.use-typography("body-small");
color: var(--color-accent-tertiary); color: var(--color-accent-tertiary);
} }
.nitrate-orgs-container {
align-items: center;
display: flex;
height: calc(2 * var(--sp-xxxl));
max-height: calc(2 * var(--sp-xxxl));
justify-content: space-between;
padding: var(--sp-xs) var(--sp-l) var(--sp-xs) var(--sp-s);
// border-block-end: $b-1 solid var(--color-background-quaternary);
}
.nitrate-orgs-empty {
@include t.use-typography("body-medium");
color: var(--color-foreground-primary);
width: 100%;
margin: var(--sp-xs) var(--sp-l);
display: flex;
align-items: center;
gap: var(--sp-s);
}
.nitrate-create-org {
margin-inline-start: auto;
text-transform: uppercase;
}
.nitrate-penpot-icon {
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
height: var(--sp-xxxl);
width: var(--sp-xxxl);
background-color: var(--color-foreground-primary);
svg {
fill: var(--icon-stroke-color);
width: var(--sp-xxl);
height: var(--sp-xxl);
}
}

View File

@@ -6,6 +6,7 @@
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.config :as cf] [app.config :as cf]
[app.main.data.event :as ev] [app.main.data.event :as ev]
[app.main.data.nitrate :as dnt]
[app.main.router :as rt] [app.main.router :as rt]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.components.dropdown-menu :refer [dropdown-menu-item*]] [app.main.ui.components.dropdown-menu :refer [dropdown-menu-item*]]
@@ -115,6 +116,26 @@
:has-dropdown false :has-dropdown false
:is-highlighted false}])))) :is-highlighted false}]))))
(mf/defc nitrate-sidebar*
[]
(let [handle-click
(mf/use-fn
(fn []
(st/emit! (dnt/show-nitrate-popup))))]
;; TODO add translations for this texts when we have the definitive ones
[:div {:class (stl/css :nitrate-banner :highlighted)}
[:div {:class (stl/css :nitrate-content)}
[:span {:class (stl/css :nitrate-title)} "Unlock Nitrate features"]]
[:div {:class (stl/css :nitrate-content)}
[:span {:class (stl/css :nitrate-info)} "Some further information and explanation."]
[:> button* {:variant "primary"
:type "button"
:class (stl/css :cta-bottom-button)
:on-click handle-click} "UPGRADE TO NITRATE"]]]))
(mf/defc team* (mf/defc team*
[{:keys [is-owner team]}] [{:keys [is-owner team]}]
(let [subscription (:subscription team) (let [subscription (:subscription team)

View File

@@ -205,3 +205,28 @@
overflow-wrap: break-word; overflow-wrap: break-word;
} }
} }
.nitrate-banner {
display: flex;
border-radius: var(--sp-s);
flex-direction: column;
margin: var(--sp-m);
background: var(--color-background-quaternary);
border: $b-1 solid var(--color-accent-primary-muted);
padding: var(--sp-l);
}
.nitrate-title {
@include t.use-typography("body-large");
color: var(--color-foreground-primary);
}
.nitrate-info {
@include t.use-typography("body-medium");
color: var(--color-foreground-secondary);
}
.nitrate-content {
display: flex;
flex-direction: column;
}

View File

@@ -18,6 +18,7 @@ $sz-32: px2rem(32);
$sz-36: px2rem(36); $sz-36: px2rem(36);
$sz-40: px2rem(40); $sz-40: px2rem(40);
$sz-48: px2rem(48); $sz-48: px2rem(48);
$sz-64: px2rem(64);
$sz-88: px2rem(88); $sz-88: px2rem(88);
$sz-96: px2rem(96); $sz-96: px2rem(96);
$sz-120: px2rem(120); $sz-120: px2rem(120);

View File

@@ -8,7 +8,6 @@
(:require-macros [app.main.style :as stl]) (:require-macros [app.main.style :as stl])
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm]
[app.main.constants :refer [max-input-length]] [app.main.constants :refer [max-input-length]]
[app.main.ui.ds.controls.utilities.hint-message :refer [hint-message*]] [app.main.ui.ds.controls.utilities.hint-message :refer [hint-message*]]
[app.main.ui.ds.controls.utilities.input-field :refer [input-field*]] [app.main.ui.ds.controls.utilities.input-field :refer [input-field*]]
@@ -52,10 +51,11 @@
:has-hint has-hint :has-hint has-hint
:hint-type hint-type :hint-type hint-type
:variant variant})] :variant variant})]
[:div {:class (dm/str class " " (stl/css-case :input-wrapper true
:variant-dense (= variant "dense") [:div {:class [class (stl/css-case :input-wrapper true
:variant-comfortable (= variant "comfortable") :variant-dense (= variant "dense")
:has-hint has-hint))} :variant-comfortable (= variant "comfortable")
:has-hint has-hint)]}
(when has-label (when has-label
[:> label* {:for id :is-optional is-optional} label]) [:> label* {:for id :is-optional is-optional} label])
[:> input-field* props] [:> input-field* props]
@@ -64,4 +64,3 @@
:class hint-class :class hint-class
:message hint-message :message hint-message
:type hint-type}])])) :type hint-type}])]))

View File

@@ -84,6 +84,7 @@
:on-click on-icon-click}]) :on-click on-icon-click}])
(if aria-label (if aria-label
[:> tooltip* {:content aria-label [:> tooltip* {:content aria-label
:class (stl/css :tooltip-wrapper)
:id tooltip-id} :id tooltip-id}
[:> "input" props]] [:> "input" props]]
[:> "input" props]) [:> "input" props])

View File

@@ -120,3 +120,7 @@
color: var(--color-foreground-secondary); color: var(--color-foreground-secondary);
min-inline-size: var(--sp-l); min-inline-size: var(--sp-l);
} }
.tooltip-wrapper {
inline-size: 100%;
}

View File

@@ -8,6 +8,7 @@
(:require (:require
[app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.controls.input :refer [input*]] [app.main.ui.ds.controls.input :refer [input*]]
[app.main.ui.ds.controls.select :refer [select*]]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.forms :as fm] [app.util.forms :as fm]
[app.util.keyboard :as k] [app.util.keyboard :as k]
@@ -47,6 +48,23 @@
[:> input* props])) [:> input* props]))
(mf/defc form-select*
[{:keys [name] :as props}]
(let [select-name name
form (mf/use-ctx context)
value (get-in @form [:data select-name] "")
handle-change
(fn [event]
(let [value (if (string? event) event (dom/get-target-val event))]
(fm/on-input-change form select-name value)))
props
(mf/spread-props props {:on-change handle-change
:value value})]
[:> select* props]))
(mf/defc form-submit* (mf/defc form-submit*
[{:keys [disabled on-submit] :rest props}] [{:keys [disabled on-submit] :rest props}]
(let [form (mf/use-ctx context) (let [form (mf/use-ctx context)
@@ -79,4 +97,4 @@
(when (fn? on-submit) (when (fn? on-submit)
(on-submit form event))))] (on-submit form event))))]
[:> (mf/provider context) {:value form} [:> (mf/provider context) {:value form}
[:form {:class class :on-submit on-submit'} children]])) [:form {:class class :on-submit on-submit'} children]]))

View File

@@ -7,42 +7,85 @@
(ns app.main.ui.nitrate.nitrate-form (ns app.main.ui.nitrate.nitrate-form
(:require-macros [app.main.style :as stl]) (:require-macros [app.main.style :as stl])
(:require (:require
[app.common.schema :as sm]
[app.main.data.modal :as modal] [app.main.data.modal :as modal]
[app.main.ui.components.forms :as fm]
[app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.icons :as deprecated-icon] [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]]
[app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]]
[app.util.dom :as dom] [app.util.dom :as dom]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
;; FIXME: rename to `form` (remove the nitrate prefix from namespace, (def ^:private schema:nitrate-form
;; because it is already under nitrate) [:map {:title "NitrateForm"}
[:subscription [::sm/one-of #{:monthly :yearly}]]])
(mf/defc nitrate-form-modal* (mf/defc nitrate-form-modal*
{::mf/register modal/components {::mf/register modal/components
::mf/register-as :nitrate-form} ::mf/register-as :nitrate-form
[] ::mf/wrap-props true}
(let [on-click [connectivity]
(let [show-buttons (:licenses connectivity)
initial (mf/with-memo []
{:subscription "yearly"})
form (fm/use-form :schema schema:nitrate-form
:initial initial)
on-click
(mf/use-fn (mf/use-fn
(fn [] (fn []
;; TODO Start licenses with selected type
(dom/open-new-window "/control-center/licenses/start")))] (dom/open-new-window "/control-center/licenses/start")))]
[:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)} [:div {:class (stl/css :modal-dialog :subscription-success)}
[:div {:class (stl/css :nitrate-form)} [:button {:class (stl/css :close-btn) :on-click modal/hide!}
[:> icon* {:icon-id "close"
:size "m"}]]
[:div {:class (stl/css :modal-success-content)}
[:div {:class (stl/css :modal-start)}
;; TODO this svg is a placeholder. Use the proper one when created
[:> raw-svg* {:id "logo-subscription"}]]
[:div {:class (stl/css :modal-header)} [:div {:class (stl/css :modal-end)}
[:h2 {:class (stl/css :modal-title)} [:div {:class (stl/css :modal-title)}
"BUY NITRATE"] "Unlock Nitrate Features"]
[:button {:class (stl/css :modal-close-btn) [:p {:class (stl/css :modal-text-large)}
:on-click modal/hide!} deprecated-icon/close]] "Prow scuttle parrel provost."]
[:p {:class (stl/css :modal-text-large)}
"Sail ho shrouds spirits boom mizzenmast yardarm. Pinnace holystone mizzenmast quarter crow's nest nipperkin grog yardarm hempen halter furl."]
[:p {:class (stl/css :modal-text-large)}
"Deadlights jack lad schooner scallywag dance the hempen jig carouser broadside cable strike colors."]
(if show-buttons
[:& fm/form {:form form}
[:p {:class (stl/css :modal-text-large)}
[:div {:class (stl/css :modal-content)} [:& fm/radio-buttons
"Nitrate is so cool! You should buy it!"] {:options [{:label "Price Tag Montly" :value "monthly"}
{:label "Price Tag Yearly (Discount)" :value "yearly"}]
:name :subscription
:class (stl/css :radio-btns)}]]
[:div {:class (stl/css :modal-footer)} [:p {:class (stl/css :modal-text-large :modal-buttons-section)}
[:div {:class (stl/css :action-buttons)} [:div {:class (stl/css :modal-buttons-section)}
[:> button* {:variant "primary" [:> button* {:variant "primary"
:on-click on-click} :on-click on-click
"BUY NOW!"]]]]]])) :class (stl/css :modal-button)}
"UPGRADE TO NITRATE"]
[:div {:class (stl/css :modal-text-small :modal-info)}
"Cancel anytime before your next billing cycle."]]]
[:p {:class (stl/css :modal-text-medium)}
[:a {:class (stl/css :link)}
"See my current plan"]]]
[:div {:class (stl/css :contact)}
[:p {:class (stl/css :modal-text-large)}
"Contact us to upgrade to Nitrate:"]
[:p {:class (stl/css :modal-text-large)}
[:a {:class (stl/css :link) :href "mailto:sales@penpot.app"}
"sales@penpot.app"]]])]]]]))

View File

@@ -5,48 +5,92 @@
// Copyright (c) KALEIDOS INC // Copyright (c) KALEIDOS INC
@use "refactor/common-refactor.scss" as deprecated; @use "refactor/common-refactor.scss" as deprecated;
@use "ds/typography.scss" as t;
@use "ds/_borders.scss" as *;
@use "ds/spacing.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_utils.scss" as *;
.modal-overlay { .modal-overlay {
@extend .modal-overlay-base; @extend .modal-overlay-base;
z-index: var(--z-index-notifications);
} }
.modal-container { .modal-dialog {
@extend .modal-container-base; @extend .modal-container-base;
max-block-size: initial;
min-inline-size: px2rem(648);
} }
.modal-header { .close-btn {
margin-bottom: deprecated.$s-24;
}
.modal-title {
@include deprecated.uppercaseTitleTipography;
color: var(--modal-title-foreground-color);
}
.modal-close-btn {
@extend .modal-close-btn-base; @extend .modal-close-btn-base;
} }
.modal-content { .modal-title {
margin-bottom: deprecated.$s-24; @include t.use-typography("title-large");
margin-block-end: var(--sp-xxxl);
color: var(--modal-title-foreground-color);
display: flex;
gap: var(--sp-m);
} }
.nitrate-form { .modal-text-large {
min-width: deprecated.$s-400; @include t.use-typography("body-large");
} }
.action-buttons { .modal-text-medium {
@extend .modal-action-btns; @include t.use-typography("body-medium");
} }
.cancel-button { .modal-text-small {
@extend .modal-cancel-btn; @include t.use-typography("body-small");
} }
.accept-btn { .modal-info {
@extend .modal-accept-btn; margin-block-start: var(--sp-s);
width: 40%;
}
&.danger { .modal-content,
@extend .modal-danger-btn; .modal-end {
color: var(--color-foreground-secondary);
display: flex;
flex-direction: column;
}
.modal-success-content {
display: flex;
gap: $sz-40;
}
.modal-start {
display: flex;
justify-content: center;
max-inline-size: $sz-224;
svg {
inline-size: 100%;
block-size: auto;
}
@media (max-inline-size: 992px) {
display: none;
} }
} }
.radio-btns {
label {
@include t.use-typography("body-large");
padding: 0;
}
display: flex;
flex-direction: column;
padding: var(--sp-l) 0 0 0;
gap: 0;
}
.contact {
margin-block-start: $sz-96;
color: var(--color-foreground-primary);
}

View File

@@ -36,7 +36,7 @@
["/feedback" :settings-feedback] ["/feedback" :settings-feedback]
["/options" :settings-options] ["/options" :settings-options]
["/subscriptions" :settings-subscription] ["/subscriptions" :settings-subscription]
["/access-tokens" :settings-access-tokens] ["/integrations" :settings-integrations]
["/notifications" :settings-notifications]] ["/notifications" :settings-notifications]]
["/frame-preview" :frame-preview] ["/frame-preview" :frame-preview]

View File

@@ -13,10 +13,10 @@
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.hooks :as hooks] [app.main.ui.hooks :as hooks]
[app.main.ui.modal :refer [modal-container*]] [app.main.ui.modal :refer [modal-container*]]
[app.main.ui.settings.access-tokens :refer [access-tokens-page]]
[app.main.ui.settings.change-email] [app.main.ui.settings.change-email]
[app.main.ui.settings.delete-account] [app.main.ui.settings.delete-account]
[app.main.ui.settings.feedback :refer [feedback-page*]] [app.main.ui.settings.feedback :refer [feedback-page*]]
[app.main.ui.settings.integrations :refer [integrations-page*]]
[app.main.ui.settings.notifications :refer [notifications-page*]] [app.main.ui.settings.notifications :refer [notifications-page*]]
[app.main.ui.settings.options :refer [options-page]] [app.main.ui.settings.options :refer [options-page]]
[app.main.ui.settings.password :refer [password-page]] [app.main.ui.settings.password :refer [password-page]]
@@ -73,8 +73,8 @@
:settings-subscription :settings-subscription
[:> subscription-page* {:profile profile}] [:> subscription-page* {:profile profile}]
:settings-access-tokens :settings-integrations
[:& access-tokens-page] [:> integrations-page*]
:settings-notifications :settings-notifications
[:& notifications-page* {:profile profile}])]]]])) [:& notifications-page* {:profile profile}])]]]]))

View File

@@ -1,291 +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.settings.access-tokens
(:require-macros [app.main.style :as stl])
(:require
[app.common.schema :as sm]
[app.common.time :as ct]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.data.profile :as du]
[app.main.store :as st]
[app.main.ui.components.context-menu-a11y :refer [context-menu*]]
[app.main.ui.components.forms :as fm]
[app.main.ui.icons :as deprecated-icon]
[app.util.clipboard :as clipboard]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[okulary.core :as l]
[rumext.v2 :as mf]))
(def ^:private clipboard-icon
(deprecated-icon/icon-xref :clipboard (stl/css :clipboard-icon)))
(def ^:private close-icon
(deprecated-icon/icon-xref :close (stl/css :close-icon)))
(def ^:private menu-icon
(deprecated-icon/icon-xref :menu (stl/css :menu-icon)))
(def tokens-ref
(l/derived :access-tokens st/state))
(def token-created-ref
(l/derived :access-token-created st/state))
(def ^:private schema:form
[:map {:title "AccessTokenForm"}
[:name [::sm/text {:max 250}]]
[:expiration-date [::sm/text {:max 250}]]])
(def initial-data
{:name "" :expiration-date "never"})
(mf/defc access-token-modal
{::mf/register modal/components
::mf/register-as :access-token}
[]
(let [form (fm/use-form
:initial initial-data
:schema schema:form)
created (mf/deref token-created-ref)
created? (mf/use-state false)
on-success
(mf/use-fn
(mf/deps created)
(fn [_]
(let [message (tr "dashboard.access-tokens.create.success")]
(st/emit! (du/fetch-access-tokens)
(ntf/success message)
(reset! created? true)))))
on-close
(mf/use-fn
(mf/deps created)
(fn [_]
(reset! created? false)
(st/emit! (modal/hide))))
on-error
(mf/use-fn
(fn [_]
(st/emit! (ntf/error (tr "errors.generic"))
(modal/hide))))
on-submit
(mf/use-fn
(fn [form]
(let [cdata (:clean-data @form)
mdata {:on-success (partial on-success form)
:on-error (partial on-error form)}
expiration (:expiration-date cdata)
params (cond-> {:name (:name cdata)
:perms (:perms cdata)}
(not= "never" expiration) (assoc :expiration expiration))]
(st/emit! (du/create-access-token
(with-meta params mdata))))))
copy-token
(mf/use-fn
(mf/deps created)
(fn [event]
(dom/prevent-default event)
(clipboard/to-clipboard (:token created))
(st/emit! (ntf/show {:level :info
:type :toast
:content (tr "dashboard.access-tokens.copied-success")
:timeout 7000}))))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
[:& fm/form {:form form :on-submit on-submit}
[:div {:class (stl/css :modal-header)}
[:h2 {:class (stl/css :modal-title)} (tr "modals.create-access-token.title")]
[:button {:class (stl/css :modal-close-btn)
:on-click on-close}
close-icon]]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :fields-row)}
[:& fm/input {:type "text"
:auto-focus? true
:form form
:name :name
:disabled @created?
:label (tr "modals.create-access-token.name.label")
:show-success? true
:placeholder (tr "modals.create-access-token.name.placeholder")}]]
[:div {:class (stl/css :fields-row)}
[:div {:class (stl/css :select-title)}
(tr "modals.create-access-token.expiration-date.label")]
[:& fm/select {:options [{:label (tr "dashboard.access-tokens.expiration-never") :value "never" :key "never"}
{:label (tr "dashboard.access-tokens.expiration-30-days") :value "720h" :key "720h"}
{:label (tr "dashboard.access-tokens.expiration-60-days") :value "1440h" :key "1440h"}
{:label (tr "dashboard.access-tokens.expiration-90-days") :value "2160h" :key "2160h"}
{:label (tr "dashboard.access-tokens.expiration-180-days") :value "4320h" :key "4320h"}]
:default "never"
:disabled @created?
:name :expiration-date}]
(when @created?
[:span {:class (stl/css :token-created-info)}
(if (:expires-at created)
(tr "dashboard.access-tokens.token-will-expire" (ct/format-inst (:expires-at created) "PPP"))
(tr "dashboard.access-tokens.token-will-not-expire"))])]
[:div {:class (stl/css :fields-row)}
(when @created?
[:div {:class (stl/css :custon-input-wrapper)}
[:input {:type "text"
:value (:token created "")
:class (stl/css :custom-input-token)
:read-only true}]
[:button {:title (tr "modals.create-access-token.copy-token")
:class (stl/css :copy-btn)
:on-click copy-token}
clipboard-icon]])
#_(when @created?
[:button {:class (stl/css :copy-btn)
:title (tr "modals.create-access-token.copy-token")
:on-click copy-token}
[:span {:class (stl/css :token-value)} (:token created "")]
[:span {:class (stl/css :icon)}
i/clipboard]])]]
[:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons)}
(if @created?
[:input {:class (stl/css :cancel-button)
:type "button"
:value (tr "labels.close")
:on-click modal/hide!}]
[:*
[:input {:class (stl/css :cancel-button)
:type "button"
:value (tr "labels.cancel")
:on-click modal/hide!}]
[:> fm/submit-button*
{:large? false :label (tr "modals.create-access-token.submit-label")}]])]]]]]))
(mf/defc access-tokens-hero
[]
(let [on-click (mf/use-fn #(st/emit! (modal/show :access-token {})))]
[:div {:class (stl/css :access-tokens-hero)}
[:h2 {:class (stl/css :hero-title)} (tr "dashboard.access-tokens.personal")]
[:p {:class (stl/css :hero-desc)} (tr "dashboard.access-tokens.personal.description")]
[:button {:class (stl/css :hero-btn)
:on-click on-click}
(tr "dashboard.access-tokens.create")]]))
(mf/defc access-token-actions
[{:keys [on-delete]}]
(let [local (mf/use-state {:menu-open false})
show? (:menu-open @local)
options (mf/with-memo [on-delete]
[{:name (tr "labels.delete")
:id "access-token-delete"
:handler on-delete}])
menu-ref (mf/use-ref)
on-menu-close
(mf/use-fn #(swap! local assoc :menu-open false))
on-menu-click
(mf/use-fn
(fn [event]
(dom/prevent-default event)
(swap! local assoc :menu-open true)))
on-keydown
(mf/use-fn
(mf/deps on-menu-click)
(fn [event]
(when (kbd/enter? event)
(dom/stop-propagation event)
(on-menu-click event))))]
[:button {:class (stl/css :menu-btn)
:tab-index "0"
:ref menu-ref
:on-click on-menu-click
:on-key-down on-keydown}
menu-icon
[:> context-menu*
{:on-close on-menu-close
:show show?
:fixed true
:min-width true
:top "auto"
:left "auto"
:options options}]]))
(mf/defc access-token-item
{::mf/wrap [mf/memo]}
[{:keys [token] :as props}]
(let [expires-at (:expires-at token)
expires-txt (some-> expires-at (ct/format-inst "PPP"))
expired? (and (some? expires-at) (> (ct/now) expires-at))
delete-fn
(mf/use-fn
(mf/deps token)
(fn []
(let [params {:id (:id token)}
mdata {:on-success #(st/emit! (du/fetch-access-tokens))}]
(st/emit! (du/delete-access-token (with-meta params mdata))))))
on-delete
(mf/use-fn
(mf/deps delete-fn)
(fn []
(st/emit! (modal/show
{:type :confirm
:title (tr "modals.delete-acces-token.title")
:message (tr "modals.delete-acces-token.message")
:accept-label (tr "modals.delete-acces-token.accept")
:on-accept delete-fn}))))]
[:div {:class (stl/css :table-row)}
[:div {:class (stl/css :table-field :field-name)}
(str (:name token))]
[:div {:class (stl/css-case :expiration-date true
:expired expired?)}
(cond
(nil? expires-at) (tr "dashboard.access-tokens.no-expiration")
expired? (tr "dashboard.access-tokens.expired-on" expires-txt)
:else (tr "dashboard.access-tokens.expires-on" expires-txt))]
[:div {:class (stl/css :table-field :actions)}
[:& access-token-actions
{:on-delete on-delete}]]]))
(mf/defc access-tokens-page
[]
(let [tokens (mf/deref tokens-ref)]
(mf/with-effect []
(dom/set-html-title (tr "title.settings.access-tokens"))
(st/emit! (du/fetch-access-tokens)))
[:div {:class (stl/css :dashboard-access-tokens)}
[:& access-tokens-hero]
(if (empty? tokens)
[:div {:class (stl/css :access-tokens-empty)}
[:div (tr "dashboard.access-tokens.empty.no-access-tokens")]
[:div (tr "dashboard.access-tokens.empty.add-one")]]
[:div {:class (stl/css :dashboard-table)}
[:div {:class (stl/css :table-rows)}
(for [token tokens]
[:& access-token-item {:token token :key (:id token)}])]])]))

View File

@@ -1,202 +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;
// ACCESS TOKENS PAGE
.dashboard-access-tokens {
display: grid;
grid-template-rows: auto 1fr;
margin: deprecated.$s-80 auto deprecated.$s-120 auto;
gap: deprecated.$s-32;
width: deprecated.$s-800;
}
// hero
.access-tokens-hero {
display: grid;
grid-template-rows: auto auto 1fr;
gap: deprecated.$s-32;
width: deprecated.$s-500;
font-size: deprecated.$fs-14;
margin: deprecated.$s-16 auto 0 auto;
}
.hero-title {
@include deprecated.bigTitleTipography;
color: var(--title-foreground-color-hover);
}
.hero-desc {
color: var(--title-foreground-color);
margin-bottom: 0;
font-size: deprecated.$fs-14;
}
.hero-btn {
@extend .button-primary;
}
// table empty
.access-tokens-empty {
display: grid;
place-items: center;
align-content: center;
height: deprecated.$s-156;
max-width: deprecated.$s-1000;
width: 100%;
padding: deprecated.$s-32;
border: deprecated.$s-1 solid var(--panel-border-color);
border-radius: deprecated.$br-8;
color: var(--dashboard-list-text-foreground-color);
}
// Access tokens table
.dashboard-table {
height: fit-content;
}
.table-rows {
display: grid;
grid-auto-rows: deprecated.$s-64;
gap: deprecated.$s-16;
width: 100%;
height: 100%;
max-width: deprecated.$s-1000;
margin-top: deprecated.$s-16;
color: var(--title-foreground-color);
}
.table-row {
display: grid;
grid-template-columns: 43% 1fr auto;
align-items: center;
height: deprecated.$s-64;
width: 100%;
padding: 0 deprecated.$s-16;
border-radius: deprecated.$br-8;
background-color: var(--dashboard-list-background-color);
color: var(--dashboard-list-foreground-color);
}
.field-name {
@include deprecated.textEllipsis;
display: grid;
width: 43%;
min-width: deprecated.$s-300;
}
.expiration-date {
@include deprecated.flexCenter;
min-width: deprecated.$s-76;
width: fit-content;
height: deprecated.$s-24;
border-radius: deprecated.$br-8;
color: var(--dashboard-list-text-foreground-color);
}
.expired {
@include deprecated.headlineSmallTypography;
padding: 0 deprecated.$s-6;
color: var(--pill-foreground-color);
background-color: var(--status-widget-background-color-warning);
}
.actions {
position: relative;
}
.menu-icon {
@extend .button-icon;
stroke: var(--icon-foreground);
}
.menu-btn {
@include deprecated.buttonStyle;
}
// Create access token modal
.modal-overlay {
@extend .modal-overlay-base;
}
.modal-container {
@extend .modal-container-base;
min-width: deprecated.$s-408;
}
.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 {
@include deprecated.flexColumn;
gap: deprecated.$s-24;
@include deprecated.bodySmallTypography;
margin-bottom: deprecated.$s-24;
}
.select-title {
@include deprecated.bodySmallTypography;
color: var(--modal-title-foreground-color);
}
.custon-input-wrapper {
@include deprecated.flexRow;
border-radius: deprecated.$br-8;
height: deprecated.$s-32;
background-color: var(--input-background-color);
}
.custom-input-token {
@extend .input-element;
@include deprecated.bodySmallTypography;
margin: 0;
flex-grow: 1;
&:focus {
outline: none;
border: deprecated.$s-1 solid var(--input-border-color-active);
}
}
.token-value {
@include deprecated.textEllipsis;
@include deprecated.bodySmallTypography;
flex-grow: 1;
}
.copy-btn {
@include deprecated.flexCenter;
@extend .button-secondary;
height: deprecated.$s-28;
width: deprecated.$s-28;
}
.clipboard-icon {
@extend .button-icon-small;
}
.token-created-info {
color: var(--modal-text-foreground-color);
}
.action-buttons {
@extend .modal-action-btns;
button {
@extend .modal-accept-btn;
}
}
.cancel-button {
@extend .modal-cancel-btn;
}

View File

@@ -0,0 +1,573 @@
;; 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.settings.integrations
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.config :as cf]
[app.main.data.event :as ev]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.data.profile :as du]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.context-menu-a11y :refer [context-menu*]]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.input :refer [input*]]
[app.main.ui.ds.controls.switch :refer [switch*]]
[app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]]
[app.main.ui.ds.foundations.typography :as t]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.main.ui.ds.notifications.shared.notification-pill :refer [notification-pill*]]
[app.main.ui.ds.tooltip :refer [tooltip*]]
[app.main.ui.forms :as fc]
[app.util.clipboard :as clipboard]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :as i18n :refer [tr]]
[okulary.core :as l]
[rumext.v2 :as mf]))
(def tokens-ref
(l/derived :access-tokens st/state))
(def token-created-ref
(l/derived :access-token-created st/state))
(def notification-timeout 7000)
(def ^:private schema:form
[:map
[:name [::sm/text {:max 250}]]
[:expiration-date [::sm/text {:max 250}]]])
(def form-initial-data
{:name ""
:expiration-date "never"})
(mf/defc token-created*
{::mf/private true}
[{:keys [title]}]
(let [token-created (mf/deref token-created-ref)
on-copy-to-clipboard
(mf/use-fn
(mf/deps token-created)
(fn [event]
(dom/prevent-default event)
(clipboard/to-clipboard (:token token-created))
(st/emit! (ntf/show {:level :info
:type :toast
:content (tr "integrations.notification.success.copied")
:timeout notification-timeout}))))]
[:div {:class (stl/css :modal-form)}
[:> text* {:as "h2"
:typography t/headline-large
:class (stl/css :color-primary)}
title]
[:> notification-pill* {:level :info
:type :context}
(tr "integrations.info.non-recuperable")]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-token)}
[:> input* {:type "text"
:default-value (:token token-created "")
:read-only true}]
[:div {:class (stl/css :modal-token-button)}
[:> icon-button* {:variant "secondary"
:aria-label (tr "integrations.copy-token")
:on-click on-copy-to-clipboard
:icon i/clipboard}]]]
[:> text* {:as "div"
:typography t/body-small
:class (stl/css :color-secondary)}
(if (:expires-at token-created)
(tr "integrations.token-will-expire" (ct/format-inst (:expires-at token-created) "PPP"))
(tr "integrations.token-will-not-expire"))]]
[:div {:class (stl/css :modal-footer)}
[:> button* {:variant "secondary"
:on-click modal/hide!}
(tr "labels.close")]]]))
(mf/defc create-token*
{::mf/private true}
[{:keys [title info mcp-key? on-created]}]
(let [form (fm/use-form
:initial form-initial-data
:schema schema:form)
on-error
(mf/use-fn
#(st/emit! (ntf/error (tr "errors.generic"))
(modal/hide)))
on-success
(mf/use-fn
#(st/emit! (du/fetch-access-tokens)
(ntf/success (tr "integrations.notification.success.created"))
(on-created)))
on-submit
(mf/use-fn
(fn [form]
(let [cdata (:clean-data @form)
mdata {:on-success (partial on-success form)
:on-error (partial on-error form)}
expiration (:expiration-date cdata)
params (cond-> {:name (:name cdata)
:perms (:perms cdata)}
(not= "never" expiration) (assoc :expiration expiration)
(true? mcp-key?) (assoc :type "mcp"))]
(st/emit! (du/create-access-token (with-meta params mdata))))))]
[:> fc/form* {:form form
:class (stl/css :modal-form)
:on-submit on-submit}
[:> text* {:as "h2"
:typography t/headline-large
:class (stl/css :color-primary)}
title]
(when (some? info)
[:> notification-pill* {:level :info
:type :context}
info])
[:div {:class (stl/css :modal-content)}
[:> fc/form-input* {:type "text"
:auto-focus? true
:form form
:name :name
:label (tr "integrations.name.label")
:placeholder (tr "integrations.name.placeholder")}]]
[:div {:class (stl/css :modal-content)}
[:> text* {:as "label"
:typography t/body-small
:for :expiration-date
:class (stl/css :color-primary)}
(tr "integrations.expiration-date.label")]
[:> fc/form-select* {:options [{:label (tr "integrations.expiration-never") :value "never" :id "never"}
{:label (tr "integrations.expiration-30-days") :value "720h" :id "720h"}
{:label (tr "integrations.expiration-60-days") :value "1440h" :id "1440h"}
{:label (tr "integrations.expiration-90-days") :value "2160h" :id "2160h"}
{:label (tr "integrations.expiration-180-days") :value "4320h" :id "4320h"}]
:default-selected "never"
:name :expiration-date}]]
[:div {:class (stl/css :modal-footer)}
[:> button* {:variant "secondary"
:on-click modal/hide!}
(tr "labels.cancel")]
[:> fc/form-submit* {:variant "primary"}
title]]]))
(mf/defc create-access-token-modal
{::mf/register modal/components
::mf/register-as :create-access-token}
[]
(let [created? (mf/use-state false)
on-close
(mf/use-fn
(fn []
(reset! created? false)
(st/emit! (modal/hide))))
on-created
(mf/use-fn
#(reset! created? true))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
[:div {:class (stl/css :modal-close-button)}
[:> icon-button* {:variant "ghost"
:aria-label (tr "labels.close")
:on-click on-close
:icon i/close}]]
(if @created?
[:> token-created* {:title (tr "integrations.create-access-token.title.created")}]
[:> create-token* {:title (tr "integrations.create-access-token.title")
:on-created on-created}])]]))
(mf/defc create-mcp-key-modal
{::mf/register modal/components
::mf/register-as :create-mcp-key}
[]
(let [created? (mf/use-state false)
on-close
(mf/use-fn
(fn []
(reset! created? false)
(st/emit! (modal/hide))))
on-created
(mf/use-fn
(fn []
(st/emit! (du/update-profile-props {:mcp-status true})
(ev/event {::ev/name "create-mcp-key"
::ev/origin "integrations"})
(ev/event {::ev/name "enable-mcp"
::ev/origin "integrations"
:source "key-creation"}))
(reset! created? true)))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
[:div {:class (stl/css :modal-close-button)}
[:> icon-button* {:variant "ghost"
:aria-label (tr "labels.close")
:on-click on-close
:icon i/close}]]
(if @created?
[:> token-created* {:title (tr "integrations.create-mcp-key.title.created")}]
[:> create-token* {:title (tr "integrations.create-mcp-key.title")
:mcp-key? true
:on-created on-created}])]]))
(mf/defc regenerate-mcp-key-modal
{::mf/register modal/components
::mf/register-as :regenerate-mcp-key}
[]
(let [created? (mf/use-state false)
tokens (mf/deref tokens-ref)
mcp-key (some #(when (= (:type %) "mcp") %) tokens)
mcp-key-id (:id mcp-key)
on-close
(mf/use-fn
(fn []
(reset! created? false)
(st/emit! (modal/hide))))
on-created
(mf/use-fn
(fn []
(st/emit! (du/delete-access-token {:id mcp-key-id})
(du/update-profile-props {:mcp-status true})
(ev/event {::ev/name "regenerate-mcp-key"
::ev/origin "integrations"}))
(reset! created? true)))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
[:div {:class (stl/css :modal-close-button)}
[:> icon-button* {:variant "ghost"
:aria-label (tr "labels.close")
:on-click on-close
:icon i/close}]]
(if @created?
[:> token-created* {:title (tr "integrations.regenerate-mcp-key.title.created")}]
[:> create-token* {:title (tr "integrations.regenerate-mcp-key.title")
:info (tr "integrations.regenerate-mcp-key.info")
:mcp-key? true
:on-created on-created}])]]))
(mf/defc token-item*
{::mf/private true
::mf/wrap [mf/memo]}
[{:keys [name expires-at on-delete]}]
(let [expires-txt (some-> expires-at (ct/format-inst "PPP"))
expired? (and (some? expires-at) (> (ct/now) expires-at))
menu-open* (mf/use-state false)
menu-open? (deref menu-open*)
handle-menu-close
(mf/use-fn
#(reset! menu-open* false))
handle-menu-click
(mf/use-fn
#(reset! menu-open* (not menu-open?)))
handle-open-confirm-modal
(mf/use-fn
(mf/deps on-delete)
(fn []
(st/emit! (modal/show {:type :confirm
:title (tr "integrations.delete-token.title")
:message (tr "integrations.delete-token.message")
:accept-label (tr "integrations.delete-token.accept")
:on-accept on-delete}))))
options
(mf/with-memo [on-delete]
[{:name (tr "labels.delete")
:id "token-delete"
:handler handle-open-confirm-modal}])]
[:div {:class (stl/css :item)}
[:> text* {:as "div"
:typography t/body-medium
:title name
:class (stl/css :item-title)}
name]
[:> text* {:as "div"
:typography t/body-small
:class (stl/css-case :item-subtitle true
:warning expired?)}
(cond
(nil? expires-at) (tr "integrations.no-expiration")
expired? (tr "integrations.expired-on" expires-txt)
:else (tr "integrations.expires-on" expires-txt))]
[:div {:class (stl/css :item-actions)}
[:> icon-button* {:variant "ghost"
:class (stl/css :item-button)
:aria-pressed menu-open?
:aria-label (tr "labels.options")
:on-click handle-menu-click
:icon i/menu}]
[:> context-menu* {:on-close handle-menu-close
:show menu-open?
:min-width true
:top -10
:left -138
:options options}]]]))
(mf/defc mcp-server-section*
{::mf/private true}
[]
(let [tokens (mf/deref tokens-ref)
profile (mf/deref refs/profile)
mcp-key (some #(when (= (:type %) "mcp") %) tokens)
mcp-active? (d/nilv (-> profile :props :mcp-status) false)
expires-at (:expires-at mcp-key)
expired? (and (some? expires-at) (> (ct/now) expires-at))
tooltip-id
(mf/use-id)
handle-mcp-status-change
(mf/use-fn
(fn [mcp-status]
(st/emit! (du/update-profile-props {:mcp-status mcp-status})
(ntf/show {:level :info
:type :toast
:content (if (true? mcp-status)
(tr "integrations.notification.success.mcp-server-enabled")
(tr "integrations.notification.success.mcp-server-disabled"))
:timeout notification-timeout})
(ev/event {::ev/name (if (true? mcp-status) "enable-mcp" "disable-mcp")
::ev/origin "integrations"
:source "toggle"}))))
handle-initial-mcp-status
(mf/use-fn
#(st/emit! (modal/show {:type :create-mcp-key})))
handle-regenerate-mcp-key
(mf/use-fn
#(st/emit! (modal/show {:type :regenerate-mcp-key})))
handle-delete
(mf/use-fn
(mf/deps mcp-key)
(fn []
(let [params {:id (:id mcp-key)}
mdata {:on-success #(st/emit! (du/fetch-access-tokens))}]
(st/emit! (du/delete-access-token (with-meta params mdata))
(du/update-profile-props {:mcp-status false})))))
on-copy-to-clipboard
(mf/use-fn
(fn [event]
(dom/prevent-default event)
(clipboard/to-clipboard cf/mcp-server-url)
(st/emit! (ntf/show {:level :info
:type :toast
:content (tr "integrations.notification.success.copied-link")
:timeout notification-timeout})
(ev/event {::ev/name "copy-mcp-url"
::ev/origin "integrations"}))))]
[:section {:class (stl/css :mcp-server-section)}
[:div
[:div {:class (stl/css :title)}
[:> heading* {:level 2
:typography t/title-medium
:class (stl/css :color-primary :mcp-server-title)}
(tr "integrations.mcp-server.title")]
[:> text* {:as "span"
:typography t/body-small
:class (stl/css :beta)}
(tr "integrations.mcp-server.title.beta")]]
[:> text* {:as "div"
:typography t/body-medium
:class (stl/css :color-secondary)}
(tr "integrations.mcp-server.description")]]
[:div
[:> text* {:as "h3"
:typography t/headline-small
:class (stl/css :color-primary)}
(tr "integrations.mcp-server.status")]
[:div {:class (stl/css :mcp-server-block)}
(when expired?
[:> notification-pill* {:level :error
:type :context}
[:div {:class (stl/css :mcp-server-notification)}
[:> text* {:as "div"
:typography t/body-medium
:class (stl/css :color-primary)}
(tr "integrations.mcp-server.status.expired.0")]
[:> text* {:as "div"
:typography t/body-medium
:class (stl/css :color-primary)}
(tr "integrations.mcp-server.status.expired.1")]]])
[:div {:class (stl/css :mcp-server-switch)}
[:> switch* {:label (if mcp-active?
(tr "integrations.mcp-server.status.enabled")
(tr "integrations.mcp-server.status.disabled"))
:default-checked mcp-active?
:on-change handle-mcp-status-change}]
(when (and (false? mcp-active?) (nil? mcp-key))
[:div {:class (stl/css :mcp-server-switch-cover)
:on-click handle-initial-mcp-status}])]]]
(when (some? mcp-key)
[:div {:class (stl/css :mcp-server-key)}
[:> text* {:as "h3"
:typography t/headline-small
:class (stl/css :color-primary)}
(tr "integrations.mcp-server.mcp-keys.title")]
[:div {:class (stl/css :mcp-server-block)}
[:div {:class (stl/css :mcp-server-regenerate)}
[:> button* {:variant "primary"
:class (stl/css :fit-content)
:on-click handle-regenerate-mcp-key}
(tr "integrations.mcp-server.mcp-keys.regenerate")]
[:> tooltip* {:content (tr "integrations.mcp-server.mcp-keys.tootip")
:id tooltip-id}
[:> icon* {:icon-id i/info
:class (stl/css :color-secondary)}]]]
[:div {:class (stl/css :list)}
[:> token-item* {:key (:id mcp-key)
:name (:name mcp-key)
:expires-at (:expires-at mcp-key)
:on-delete handle-delete}]]]])
[:> notification-pill* {:level :default
:type :context}
[:div {:class (stl/css :mcp-server-notification)}
[:> text* {:as "div"
:typography t/body-medium
:class (stl/css :color-secondary)}
(tr "integrations.mcp-server.mcp-keys.info")]
[:div {:class (stl/css :mcp-server-notification-line)}
[:> text* {:as "div"
:typography t/body-medium
:class (stl/css :color-primary)}
cf/mcp-server-url]
[:> text* {:as "div"
:typography t/body-medium
:on-click on-copy-to-clipboard
:class (stl/css :mcp-server-notification-link)}
[:> icon* {:icon-id i/clipboard}] (tr "integrations.mcp-server.mcp-keys.copy")]]
[:> text* {:as "div"
:typography t/body-medium
:class (stl/css :color-secondary)}
[:a {:href cf/mcp-help-center-uri
:class (stl/css :mcp-server-notification-link)}
(tr "integrations.mcp-server.mcp-keys.help") [:> icon* {:icon-id i/open-link}]]]]]]))
(mf/defc access-tokens-section*
{::mf/private true}
[]
(let [tokens (mf/deref tokens-ref)
handle-click
(mf/use-fn
#(st/emit! (modal/show {:type :create-access-token})))
handle-delete
(mf/use-fn
(fn [token-id]
(let [params {:id token-id}
mdata {:on-success #(st/emit! (du/fetch-access-tokens))}]
(st/emit! (du/delete-access-token (with-meta params mdata))))))]
[:section {:class (stl/css :access-tokens-section)}
[:> heading* {:level 2
:typography t/title-medium
:class (stl/css :color-primary)}
(tr "integrations.access-tokens.personal")]
[:> text* {:as "div"
:typography t/body-medium
:class (stl/css :color-secondary)}
(tr "integrations.access-tokens.personal.description")]
[:> button* {:variant "primary"
:class (stl/css :fit-content)
:on-click handle-click}
(tr "integrations.access-tokens.create")]
(if (empty? tokens)
[:div {:class (stl/css :frame)}
[:> text* {:as "div"
:typography t/body-medium
:class (stl/css :color-secondary :text-center)}
[:div (tr "integrations.access-tokens.empty.no-access-tokens")]
[:div (tr "integrations.access-tokens.empty.add-one")]]]
[:div {:class (stl/css :list)}
(for [token tokens]
(when (nil? (:type token))
[:> token-item* {:key (:id token)
:name (:name token)
:expires-at (:expires-at token)
:on-delete (partial handle-delete (:id token))}]))])]))
(mf/defc integrations-page*
[]
(mf/with-effect []
(dom/set-html-title (tr "title.settings.integrations"))
(st/emit! (du/fetch-access-tokens)))
[:div {:class (stl/css :integrations)}
[:> heading* {:level 1
:typography t/title-large
:class (stl/css :color-primary)}
(tr "integrations.title")]
(when (contains? cf/flags :mcp)
[:> mcp-server-section*])
(when (and (contains? cf/flags :mcp)
(contains? cf/flags :access-tokens))
[:hr {:class (stl/css :separator)}])
(when (contains? cf/flags :access-tokens)
[:> access-tokens-section*])])

View File

@@ -0,0 +1,221 @@
// 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;
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/spacing.scss" as *;
@use "ds/mixins.scss" as *;
.color-primary {
color: var(--color-foreground-primary);
}
.color-secondary {
color: var(--color-foreground-secondary);
}
.text-center {
text-align: center;
}
.fit-content {
inline-size: fit-content;
}
.beta {
color: var(--color-accent-primary);
border: $b-1 solid var(--color-accent-primary);
inline-size: fit-content;
padding: var(--sp-xxs) var(--sp-s);
border-radius: $br-4;
}
.title {
display: flex;
flex-direction: row;
align-items: baseline;
gap: var(--sp-s);
}
.modal-overlay {
@extend .modal-overlay-base;
}
.modal-container {
@extend .modal-container-base;
inline-size: $sz-400;
position: relative;
}
.modal-content {
display: flex;
flex-direction: column;
gap: var(--sp-xs);
}
.modal-form {
display: flex;
flex-direction: column;
gap: var(--sp-xxxl);
}
.modal-close-button {
position: absolute;
top: var(--sp-s);
right: var(--sp-s);
}
.modal-footer {
display: flex;
justify-content: right;
gap: var(--sp-s);
}
.modal-token {
position: relative;
}
.modal-token-button {
position: absolute;
top: 0;
right: 0;
border-start-start-radius: 0;
border-end-start-radius: 0;
}
.integrations {
display: grid;
grid-template-rows: auto 1fr;
margin: $sz-88 auto $sz-120 auto;
gap: $sz-32;
inline-size: $sz-500;
}
.access-tokens-section {
display: grid;
grid-template-rows: auto auto 1fr;
gap: var(--sp-m);
}
.mcp-server-section {
display: flex;
flex-direction: column;
gap: var(--sp-l);
}
.mcp-server-key {
display: flex;
flex-direction: column;
}
.mcp-server-notification {
display: flex;
flex-direction: column;
gap: var(--sp-s);
}
.mcp-server-notification-line {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--sp-m);
}
.mcp-server-notification-link {
cursor: pointer;
color: var(--color-accent-primary);
display: flex;
flex-direction: row;
align-items: center;
gap: var(--sp-xs);
}
.mcp-server-title {
margin: var(--sp-s) 0;
}
.mcp-server-block {
display: flex;
flex-direction: column;
gap: var(--sp-l);
}
.mcp-server-regenerate {
display: flex;
align-items: center;
gap: var(--sp-s);
}
.mcp-server-switch {
position: relative;
}
.mcp-server-switch-cover {
position: absolute;
inset-block: 0;
inset-inline: 0;
}
.separator {
border: $b-1 solid var(--color-background-quaternary);
margin: var(--sp-s) 0;
}
.frame {
border: $b-1 solid var(--color-background-quaternary);
padding: var(--sp-m);
border-radius: $br-8;
}
.list {
display: grid;
grid-auto-rows: $sz-64;
gap: var(--sp-m);
}
.item {
display: grid;
grid-template-columns: 45% 1fr auto;
align-items: center;
background-color: var(--color-background-tertiary);
border-radius: $br-8;
}
.item-title {
@include textEllipsis;
align-content: center;
block-size: $sz-64;
padding: 0 var(--sp-l);
color: var(--color-foreground-primary);
}
.item-subtitle {
align-content: center;
block-size: $sz-64;
color: var(--color-foreground-secondary);
&.warning {
padding: var(--sp-s) var(--sp-m);
block-size: fit-content;
inline-size: fit-content;
color: var(--color-foreground-primary);
background-color: var(--color-background-warning);
border: $b-1 solid var(--color-accent-warning);
border-radius: $br-8;
}
}
.item-actions {
position: relative;
}
.item-button {
block-size: $sz-64;
inline-size: $sz-48;
border-radius: 0 var(--sp-s) var(--sp-s) 0;
}

View File

@@ -43,8 +43,8 @@
(def ^:private go-settings-subscription (def ^:private go-settings-subscription
#(st/emit! (rt/nav :settings-subscription))) #(st/emit! (rt/nav :settings-subscription)))
(def ^:private go-settings-access-tokens (def ^:private go-settings-integrations
#(st/emit! (rt/nav :settings-access-tokens))) #(st/emit! (rt/nav :settings-integrations)))
(def ^:private go-settings-notifications (def ^:private go-settings-notifications
#(st/emit! (rt/nav :settings-notifications))) #(st/emit! (rt/nav :settings-notifications)))
@@ -66,7 +66,7 @@
options? (= section :settings-options) options? (= section :settings-options)
feedback? (= section :settings-feedback) feedback? (= section :settings-feedback)
subscription? (= section :settings-subscription) subscription? (= section :settings-subscription)
access-tokens? (= section :settings-access-tokens) integrations? (= section :settings-integrations)
notifications? (= section :settings-notifications) notifications? (= section :settings-notifications)
team-id (or (dtm/get-last-team-id) team-id (or (dtm/get-last-team-id)
(:default-team-id profile)) (:default-team-id profile))
@@ -115,12 +115,13 @@
:data-testid "settings-subscription"} :data-testid "settings-subscription"}
[:span {:class (stl/css :element-title)} (tr "subscription.labels")]]) [:span {:class (stl/css :element-title)} (tr "subscription.labels")]])
(when (contains? cf/flags :access-tokens) (when (or (contains? cf/flags :access-tokens)
[:li {:class (stl/css-case :current access-tokens? (contains? cf/flags :mcp))
[:li {:class (stl/css-case :current integrations?
:settings-item true) :settings-item true)
:on-click go-settings-access-tokens :on-click go-settings-integrations
:data-testid "settings-access-tokens"} :data-testid "settings-integrations"}
[:span {:class (stl/css :element-title)} (tr "labels.access-tokens")]]) [:span {:class (stl/css :element-title)} (tr "labels.integrations")]])
[:hr {:class (stl/css :sidebar-separator)}] [:hr {:class (stl/css :sidebar-separator)}]

View File

@@ -4,6 +4,7 @@
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.time :as ct] [app.common.time :as ct]
[app.config :as cf]
[app.main.data.auth :as da] [app.main.data.auth :as da]
[app.main.data.event :as ev] [app.main.data.event :as ev]
[app.main.data.modal :as modal] [app.main.data.modal :as modal]
@@ -562,7 +563,7 @@
:recommended (= subscription-type "professional") :recommended (= subscription-type "professional")
:show-button-cta (= subscription-type "professional")}]) :show-button-cta (= subscription-type "professional")}])
(when (not= subscription-type "enterprise") (when (and (not= subscription-type "enterprise") (not (contains? cf/flags :nitrate)))
[:> plan-card* {:card-title (tr "subscription.settings.enterprise") [:> plan-card* {:card-title (tr "subscription.settings.enterprise")
:card-title-icon i/character-e :card-title-icon i/character-e
:price-value "$950" :price-value "$950"
@@ -575,5 +576,21 @@
:cta-link #(open-subscription-modal "enterprise" subscription) :cta-link #(open-subscription-modal "enterprise" subscription)
:cta-text-with-icon (tr "subscription.settings.more-information") :cta-text-with-icon (tr "subscription.settings.more-information")
:cta-link-with-icon go-to-pricing-page :cta-link-with-icon go-to-pricing-page
:show-button-cta (= subscription-type "professional")}])]]])) :show-button-cta (= subscription-type "professional")}])
;; TODO add translations for this texts when we have the definitive ones
(when (and (contains? cf/flags :nitrate) (not (:nitrate-licence profile)))
[:> plan-card* {:card-title "Business Nitrate"
:card-title-icon i/character-n
:price-value "$25"
:price-period "org member"
:benefits-title (tr "subscription.settings.benefits.all-unlimited-benefits")
:benefits ["Crea organizaciones y añade personas, que usarán Penpot con las reglas que configures."
"Acceso exclusivo al Control Center"
"Lorem ipsum"]
:cta-text (tr "subscription.settings.subscribe")
;; TODO add link to open nitrate modal
:cta-link #(dom/open-new-window "https://penpot.app/nitrate")
:cta-text-with-icon (tr "subscription.settings.more-information")
:cta-link-with-icon go-to-pricing-page}])]]]))

View File

@@ -50,7 +50,7 @@
(mf/defc workspace-content* (mf/defc workspace-content*
{::mf/private true} {::mf/private true}
[{:keys [file layout page wglobal]}] [{:keys [file layout page wglobal file-version-id]}]
(let [palete-size (mf/use-state nil) (let [palete-size (mf/use-state nil)
selected (mf/deref refs/selected-shapes) selected (mf/deref refs/selected-shapes)
@@ -109,6 +109,7 @@
:wglobal wglobal :wglobal wglobal
:selected selected :selected selected
:layout layout :layout layout
:file-version-id file-version-id
:palete-size :palete-size
(when (and (or colorpalette? textpalette?) (not hide-ui?)) (when (and (or colorpalette? textpalette?) (not hide-ui?))
@palete-size)}]]] @palete-size)}]]]
@@ -168,7 +169,7 @@
(mf/defc workspace-inner* (mf/defc workspace-inner*
{::mf/private true} {::mf/private true}
[{:keys [page-id file-id file layout wglobal]}] [{:keys [page-id file-id file layout wglobal file-version-id]}]
(let [page-ref (mf/with-memo [file-id page-id] (let [page-ref (mf/with-memo [file-id page-id]
(make-page-ref file-id page-id)) (make-page-ref file-id page-id))
page (mf/deref page-ref)] page (mf/deref page-ref)]
@@ -187,7 +188,8 @@
[:> workspace-content* {:file file [:> workspace-content* {:file file
:page page :page page
:wglobal wglobal :wglobal wglobal
:layout layout}] :layout layout
:file-version-id file-version-id}]
[:> workspace-loader*]))) [:> workspace-loader*])))
(mf/defc workspace* (mf/defc workspace*
@@ -199,6 +201,7 @@
layout (mf/deref refs/workspace-layout) layout (mf/deref refs/workspace-layout)
wglobal (mf/deref refs/workspace-global) wglobal (mf/deref refs/workspace-global)
file-version-id (mf/deref refs/workspace-file-version-id)
team-ref (mf/with-memo [team-id] team-ref (mf/with-memo [team-id]
(make-team-ref team-id)) (make-team-ref team-id))
@@ -274,7 +277,8 @@
:file-id file-id :file-id file-id
:file file :file file
:wglobal wglobal :wglobal wglobal
:layout layout}]) :layout layout
:file-version-id file-version-id}])
(when (or (not (and file-loaded? page-id)) (when (or (not (and file-loaded? page-id))
;; in wasm renderer, extend the pixel loader until the first frame is rendered ;; in wasm renderer, extend the pixel loader until the first frame is rendered
;; but do not apply it when switching pages ;; but do not apply it when switching pages

View File

@@ -150,7 +150,9 @@
{::mf/props :obj {::mf/props :obj
::mf/private true} ::mf/private true}
[{:keys [shapes]}] [{:keys [shapes]}]
(let [do-copy #(st/emit! (dw/copy-selected)) (let [multiple? (> (count shapes) 1)
do-copy #(st/emit! (dw/copy-selected))
do-copy-link #(st/emit! (dw/copy-link-to-clipboard)) do-copy-link #(st/emit! (dw/copy-link-to-clipboard))
do-cut #(st/emit! (dw/copy-selected) do-cut #(st/emit! (dw/copy-selected)
@@ -178,6 +180,9 @@
handle-copy-text handle-copy-text
(mf/use-callback #(st/emit! (dw/copy-selected-text))) (mf/use-callback #(st/emit! (dw/copy-selected-text)))
handle-copy-as-image
(mf/use-callback #(st/emit! (dw/copy-as-image)))
handle-hover-copy-paste handle-hover-copy-paste
(mf/use-callback (mf/use-callback
(fn [] (fn []
@@ -222,6 +227,11 @@
[:> menu-entry* {:title (tr "workspace.shape.menu.copy-svg") [:> menu-entry* {:title (tr "workspace.shape.menu.copy-svg")
:on-click handle-copy-svg}] :on-click handle-copy-svg}]
(when (some cfh/frame-shape? shapes)
[:> menu-entry* {:title (tr "workspace.shape.menu.copy-as-image")
:disabled multiple?
:on-click handle-copy-as-image}])
[:> menu-separator* {}] [:> menu-separator* {}]
[:> menu-entry* {:title (tr "workspace.shape.menu.copy-text") [:> menu-entry* {:title (tr "workspace.shape.menu.copy-text")
@@ -229,7 +239,7 @@
[:> menu-entry* {:title (tr "workspace.shape.menu.copy-props") [:> menu-entry* {:title (tr "workspace.shape.menu.copy-props")
:shortcut (sc/get-tooltip :copy-props) :shortcut (sc/get-tooltip :copy-props)
:disabled (> (count shapes) 1) :disabled multiple?
:on-click handle-copy-props}] :on-click handle-copy-props}]
[:> menu-entry* {:title (tr "workspace.shape.menu.paste-props") [:> menu-entry* {:title (tr "workspace.shape.menu.paste-props")
:shortcut (sc/get-tooltip :paste-props) :shortcut (sc/get-tooltip :paste-props)

View File

@@ -84,6 +84,8 @@
:on-click on-select-shape :on-click on-select-shape
:on-context-menu on-context-menu :on-context-menu on-context-menu
:data-testid "layer-row" :data-testid "layer-row"
:role "checkbox"
:aria-checked selected?
:class (stl/css-case :class (stl/css-case
:layer-row true :layer-row true
:highlight highlighted? :highlight highlighted?

View File

@@ -73,7 +73,7 @@
objects))) objects)))
(mf/defc viewport* (mf/defc viewport*
[{:keys [selected wglobal wlocal layout file page palete-size]}] [{:keys [selected wglobal wlocal layout file page palete-size file-version-id]}]
(let [;; When adding data from workspace-local revisit `app.main.ui.workspace` to check (let [;; When adding data from workspace-local revisit `app.main.ui.workspace` to check
;; that the new parameter is sent ;; that the new parameter is sent
{:keys [edit-path {:keys [edit-path
@@ -141,6 +141,7 @@
canvas-ref (mf/use-ref nil) canvas-ref (mf/use-ref nil)
text-editor-ref (mf/use-ref nil) text-editor-ref (mf/use-ref nil)
last-file-version-id-ref (mf/use-ref nil)
;; STATE REFS ;; STATE REFS
disable-paste-ref (mf/use-ref false) disable-paste-ref (mf/use-ref false)
@@ -344,11 +345,18 @@
(when (and @canvas-init? preview-blend) (when (and @canvas-init? preview-blend)
(wasm.api/request-render "with-effect"))) (wasm.api/request-render "with-effect")))
(mf/with-effect [@canvas-init? zoom vbox background] (mf/with-effect [@canvas-init? file-version-id zoom vbox background]
(when (and @canvas-init? (not @initialized?)) (when @canvas-init?
(wasm.api/clear-canvas-pixels) (if (not @initialized?)
(wasm.api/initialize-viewport base-objects zoom vbox background) (do
(reset! initialized? true))) (wasm.api/clear-canvas-pixels)
(wasm.api/initialize-viewport base-objects zoom vbox background)
(reset! initialized? true)
(mf/set-ref-val! last-file-version-id-ref file-version-id))
(when (and (some? file-version-id)
(not= file-version-id (mf/ref-val last-file-version-id-ref)))
(wasm.api/initialize-viewport base-objects zoom vbox background)
(mf/set-ref-val! last-file-version-id-ref file-version-id)))))
(mf/with-effect [focus] (mf/with-effect [focus]
(when (and @canvas-init? @initialized?) (when (and @canvas-init? @initialized?)

View File

@@ -261,7 +261,39 @@
:else :else
(let [child-id (obj/get child "$id")] (let [child-id (obj/get child "$id")]
(st/emit! (dwt/move-shapes-to-frame #{child-id} id nil nil) (st/emit! (dwt/move-shapes-to-frame #{child-id} id nil nil)
(ptk/data-event :layout/update {:ids [id]}))))))) (ptk/data-event :layout/update {:ids [id]})))))
:horizontalSizing
{:this true
:get #(-> % u/proxy->shape :layout-item-h-sizing (d/nilv :fix) d/name)
:set
(fn [_ value]
(let [value (keyword value)]
(cond
(not (contains? ctl/item-h-sizing-types value))
(u/display-not-valid :horizontalPadding value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :horizontalPadding "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-h-sizing value})))))}
:verticalSizing
{:this true
:get #(-> % u/proxy->shape :layout-item-v-sizing (d/nilv :fix) d/name)
:set
(fn [_ value]
(let [value (keyword value)]
(cond
(not (contains? ctl/item-v-sizing-types value))
(u/display-not-valid :verticalSizing value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :verticalSizing "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-v-sizing value})))))}))
(defn layout-child-proxy? [p] (defn layout-child-proxy? [p]

View File

@@ -598,3 +598,10 @@
(case axis (case axis
:y "horizontal" :y "horizontal"
:x "vertical")) :x "vertical"))
(defn format-geom-rect
[{:keys [x y width height]}]
#js {:x x
:y y
:width width
:height height})

View File

@@ -17,6 +17,10 @@
[app.util.object :as obj] [app.util.object :as obj]
[beicon.v2.core :as rx])) [beicon.v2.core :as rx]))
;; Needs to be here because moving it to `app.main.data.workspace.mcp` will
;; cause a circular dependency
(def mcp-plugin-id "96dfa740-005d-8020-8007-55ede24a2bae")
;; Stores the installed plugins information ;; Stores the installed plugins information
(defonce ^:private registry (atom {})) (defonce ^:private registry (atom {}))
@@ -127,5 +131,6 @@
(defn check-permission (defn check-permission
[plugin-id permission] [plugin-id permission]
(or (= plugin-id "00000000-0000-0000-0000-000000000000") (or (= plugin-id "00000000-0000-0000-0000-000000000000")
(= plugin-id mcp-plugin-id)
(let [{:keys [permissions]} (dm/get-in @registry [:data plugin-id])] (let [{:keys [permissions]} (dm/get-in @registry [:data plugin-id])]
(contains? permissions permission)))) (contains? permissions permission))))

View File

@@ -1305,7 +1305,8 @@
tokens)))} tokens)))}
:applyToken :applyToken
{:schema [:tuple {:enumerable false
:schema [:tuple
[:fn token-proxy?] [:fn token-proxy?]
[:maybe [:set [:and ::sm/keyword [:fn cto/token-attr?]]]]] [:maybe [:set [:and ::sm/keyword [:fn cto/token-attr?]]]]]
:fn (fn [token attrs] :fn (fn [token attrs]

View File

@@ -8,6 +8,7 @@
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.geom.shapes.text :as gst]
[app.common.record :as crc] [app.common.record :as crc]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.types.shape :as cts] [app.common.types.shape :as cts]
@@ -651,4 +652,7 @@
(u/display-not-valid :verticalAlign "Plugin doesn't have 'content:write' permission") (u/display-not-valid :verticalAlign "Plugin doesn't have 'content:write' permission")
:else :else
(st/emit! (dwt/update-attrs id {:vertical-align value})))))})) (st/emit! (dwt/update-attrs id {:vertical-align value})))))}
{:name "textBounds"
:get #(-> % u/proxy->shape gst/shape->bounds format/format-geom-rect)}))

View File

@@ -144,14 +144,16 @@
(st/emit! (dwtl/delete-token set-id id))) (st/emit! (dwtl/delete-token set-id id)))
:applyToShapes :applyToShapes
{:schema [:tuple {:enumerable false
:schema [:tuple
[:vector [:fn shape-proxy?]] [:vector [:fn shape-proxy?]]
[:maybe [:set [:and ::sm/keyword [:fn cto/token-attr?]]]]] [:maybe [:set [:and ::sm/keyword [:fn cto/token-attr?]]]]]
:fn (fn [shapes attrs] :fn (fn [shapes attrs]
(apply-token-to-shapes file-id set-id id (map #(obj/get % "$id") shapes) attrs))} (apply-token-to-shapes file-id set-id id (map #(obj/get % "$id") shapes) attrs))}
:applyToSelected :applyToSelected
{:schema [:tuple [:maybe [:set [:and ::sm/keyword [:fn cto/token-attr?]]]]] {:enumerable false
:schema [:tuple [:maybe [:set [:and ::sm/keyword [:fn cto/token-attr?]]]]]
:fn (fn [attrs] :fn (fn [attrs]
(let [selected (get-in @st/state [:workspace-local :selected])] (let [selected (get-in @st/state [:workspace-local :selected])]
(apply-token-to-shapes file-id set-id id selected attrs)))})) (apply-token-to-shapes file-id set-id id selected attrs)))}))
@@ -236,14 +238,16 @@
(apply array))))} (apply array))))}
:getTokenById :getTokenById
{:schema [:tuple ::sm/uuid] {:enumerable false
:schema [:tuple ::sm/uuid]
:fn (fn [token-id] :fn (fn [token-id]
(let [token (u/locate-token file-id id token-id)] (let [token (u/locate-token file-id id token-id)]
(when (some? token) (when (some? token)
(token-proxy plugin-id file-id id token-id))))} (token-proxy plugin-id file-id id token-id))))}
:addToken :addToken
{:schema (fn [args] {:enumerable false
:schema (fn [args]
[:tuple (-> (cfo/make-token-schema [:tuple (-> (cfo/make-token-schema
(-> (u/locate-tokens-lib file-id) (ctob/get-tokens id)) (-> (u/locate-tokens-lib file-id) (ctob/get-tokens id))
(cto/dtcg-token-type->token-type (-> args (first) (get "type")))) (cto/dtcg-token-type->token-type (-> args (first) (get "type"))))
@@ -353,13 +357,15 @@
{:this true :get (fn [_])} {:this true :get (fn [_])}
:addSet :addSet
{:schema [:tuple [:fn token-set-proxy?]] {:enumerable false
:schema [:tuple [:fn token-set-proxy?]]
:fn (fn [token-set] :fn (fn [token-set]
(let [theme (u/locate-token-theme file-id id)] (let [theme (u/locate-token-theme file-id id)]
(st/emit! (dwtl/update-token-theme id (ctob/enable-set theme (obj/get token-set :name))))))} (st/emit! (dwtl/update-token-theme id (ctob/enable-set theme (obj/get token-set :name))))))}
:removeSet :removeSet
{:schema [:tuple [:fn token-set-proxy?]] {:enumerable false
:schema [:tuple [:fn token-set-proxy?]]
:fn (fn [token-set] :fn (fn [token-set]
(let [theme (u/locate-token-theme file-id id)] (let [theme (u/locate-token-theme file-id id)]
(st/emit! (dwtl/update-token-theme id (ctob/disable-set theme (obj/get token-set :name))))))} (st/emit! (dwtl/update-token-theme id (ctob/disable-set theme (obj/get token-set :name))))))}
@@ -406,7 +412,8 @@
(apply array (map #(token-set-proxy plugin-id file-id (ctob/get-id %)) sets))))} (apply array (map #(token-set-proxy plugin-id file-id (ctob/get-id %)) sets))))}
:addTheme :addTheme
{:schema (fn [attrs] {:enumerable false
:schema (fn [attrs]
[:tuple (-> (sm/schema (cfo/make-token-theme-schema [:tuple (-> (sm/schema (cfo/make-token-theme-schema
(u/locate-tokens-lib file-id) (u/locate-tokens-lib file-id)
(or (obj/get attrs "group") "") (or (obj/get attrs "group") "")
@@ -419,7 +426,8 @@
(token-theme-proxy plugin-id file-id (:id theme))))} (token-theme-proxy plugin-id file-id (:id theme))))}
:addSet :addSet
{:schema [:tuple (-> (sm/schema (cfo/make-token-set-schema {:enumerable false
:schema [:tuple (-> (sm/schema (cfo/make-token-set-schema
(u/locate-tokens-lib file-id) (u/locate-tokens-lib file-id)
nil)) nil))
(sm/dissoc-key :id))] ;; We don't allow plugins to set the id (sm/dissoc-key :id))] ;; We don't allow plugins to set the id
@@ -431,14 +439,16 @@
(token-set-proxy plugin-id file-id (ctob/get-id set))))} (token-set-proxy plugin-id file-id (ctob/get-id set))))}
:getThemeById :getThemeById
{:schema [:tuple ::sm/uuid] {:enumerable false
:schema [:tuple ::sm/uuid]
:fn (fn [theme-id] :fn (fn [theme-id]
(let [theme (u/locate-token-theme file-id theme-id)] (let [theme (u/locate-token-theme file-id theme-id)]
(when (some? theme) (when (some? theme)
(token-theme-proxy plugin-id file-id theme-id))))} (token-theme-proxy plugin-id file-id theme-id))))}
:getSetById :getSetById
{:schema [:tuple ::sm/uuid] {:enumerable false
:schema [:tuple ::sm/uuid]
:fn (fn [set-id] :fn (fn [set-id]
(let [set (u/locate-token-set file-id set-id)] (let [set (u/locate-token-set file-id set-id)]
(when (some? set) (when (some? set)

View File

@@ -230,12 +230,6 @@
(def get-target-scroll (comp get-scroll-position get-target)) (def get-target-scroll (comp get-scroll-position get-target))
(defn click
"Click a node"
[^js node]
(when (some? node)
(.click node)))
(defn get-files (defn get-files
"Extract the files from dom node." "Extract the files from dom node."
[^js node] [^js node]
@@ -476,7 +470,7 @@
(when (some? node) (when (some? node)
(.focus node))) (.focus node)))
(defn click! (defn click
[^js node] [^js node]
(when (some? node) (when (some? node)
(.click node))) (.click node)))
@@ -748,7 +742,11 @@
(defn trigger-download (defn trigger-download
[filename blob] [filename blob]
(trigger-download-uri filename (.-type ^js blob) (wapi/create-uri blob))) (let [uri (wapi/create-uri blob)]
(try
(trigger-download-uri filename (.-type ^js blob) uri)
(finally
(wapi/revoke-uri uri)))))
(defn event (defn event
"Create an instance of DOM Event" "Create an instance of DOM Event"

View File

@@ -190,6 +190,11 @@
[{:keys [status]}] [{:keys [status]}]
(<= 400 status 499)) (<= 400 status 499))
(defn blob?
[^js v]
(when (some? v)
(instance? js/Blob v)))
(defn as-promise (defn as-promise
[observable] [observable]
(p/create (p/create

View File

@@ -464,6 +464,13 @@
(let [o (get o type-symbol)] (let [o (get o type-symbol)]
(= o t)))) (= o t))))
#?(:cljs
(def Proxy
(app.util.object/class
:name "Proxy"
:extends js/Object
:constructor (constantly nil))))
(defmacro reify (defmacro reify
"A domain specific variation of reify that creates anonymous objects "A domain specific variation of reify that creates anonymous objects
on demand with the ability to assign protocol implementations and on demand with the ability to assign protocol implementations and
@@ -481,7 +488,7 @@
obj-sym obj-sym
(gensym "obj-")] (gensym "obj-")]
`(let [~obj-sym (cljs.core/js-obj) `(let [~obj-sym (new Proxy)
~f-sym (fn [] ~type-name)] ~f-sym (fn [] ~type-name)]
(add-properties! ~obj-sym (add-properties! ~obj-sym
{:name ~'js/Symbol.toStringTag {:name ~'js/Symbol.toStringTag

View File

@@ -642,13 +642,15 @@ export class SelectionController extends EventTarget {
} else { } else {
this.#anchorNode = anchorNode; this.#anchorNode = anchorNode;
this.#anchorOffset = anchorOffset; this.#anchorOffset = anchorOffset;
if (anchorNode === focusNode) { this.#focusNode = focusNode;
this.#focusNode = this.#anchorNode; this.#focusOffset = focusOffset;
this.#focusOffset = this.#anchorOffset; // setPosition() collapses the selection to a single caret. We must only use it
// when anchorOffset === focusOffset. When both points are in the same node but
// offsets differ (e.g. selecting "hola" in "hola adios"), we need setBaseAndExtent()
// to preserve the range so we don't incorrectly collapse ranges and lose the selection.
if (anchorNode === focusNode && anchorOffset === focusOffset) {
this.#selection.setPosition(anchorNode, anchorOffset); this.#selection.setPosition(anchorNode, anchorOffset);
} else { } else {
this.#focusNode = focusNode;
this.#focusOffset = focusOffset;
this.#selection.setBaseAndExtent( this.#selection.setBaseAndExtent(
anchorNode, anchorNode,
anchorOffset, anchorOffset,

View File

@@ -338,77 +338,6 @@ msgstr "You're going to restore %s."
msgid "dashboard-restore-file-confirmation.title" msgid "dashboard-restore-file-confirmation.title"
msgstr "Restore file" msgstr "Restore file"
#: src/app/main/ui/settings/access_tokens.cljs:103
msgid "dashboard.access-tokens.copied-success"
msgstr "Copied token"
#: src/app/main/ui/settings/access_tokens.cljs:189
msgid "dashboard.access-tokens.create"
msgstr "Generate new token"
#: src/app/main/ui/settings/access_tokens.cljs:64
msgid "dashboard.access-tokens.create.success"
msgstr "Access token created successfully."
#: src/app/main/ui/settings/access_tokens.cljs:286
msgid "dashboard.access-tokens.empty.add-one"
msgstr "Press the button \"Generate new token\" to generate one."
#: src/app/main/ui/settings/access_tokens.cljs:285
msgid "dashboard.access-tokens.empty.no-access-tokens"
msgstr "You have no tokens so far."
#: src/app/main/ui/settings/access_tokens.cljs:135
msgid "dashboard.access-tokens.expiration-180-days"
msgstr "180 days"
#: src/app/main/ui/settings/access_tokens.cljs:132
msgid "dashboard.access-tokens.expiration-30-days"
msgstr "30 days"
#: src/app/main/ui/settings/access_tokens.cljs:133
msgid "dashboard.access-tokens.expiration-60-days"
msgstr "60 days"
#: src/app/main/ui/settings/access_tokens.cljs:134
msgid "dashboard.access-tokens.expiration-90-days"
msgstr "90 days"
#: src/app/main/ui/settings/access_tokens.cljs:131
msgid "dashboard.access-tokens.expiration-never"
msgstr "Never"
#: src/app/main/ui/settings/access_tokens.cljs:268
msgid "dashboard.access-tokens.expired-on"
msgstr "Expired on %s"
#: src/app/main/ui/settings/access_tokens.cljs:269
msgid "dashboard.access-tokens.expires-on"
msgstr "Expires on %s"
#: src/app/main/ui/settings/access_tokens.cljs:267
msgid "dashboard.access-tokens.no-expiration"
msgstr "No expiration date"
#: src/app/main/ui/settings/access_tokens.cljs:184
msgid "dashboard.access-tokens.personal"
msgstr "Personal access tokens"
#: src/app/main/ui/settings/access_tokens.cljs:185
msgid "dashboard.access-tokens.personal.description"
msgstr ""
"Personal access tokens function like an alternative to our login/password "
"authentication system and can be used to allow an application to access the "
"internal Penpot API"
#: src/app/main/ui/settings/access_tokens.cljs:142
msgid "dashboard.access-tokens.token-will-expire"
msgstr "The token will expire on %s"
#: src/app/main/ui/settings/access_tokens.cljs:143
msgid "dashboard.access-tokens.token-will-not-expire"
msgstr "The token has no expiration date"
#: src/app/main/ui/dashboard/placeholder.cljs:41 #: src/app/main/ui/dashboard/placeholder.cljs:41
msgid "dashboard.add-file" msgid "dashboard.add-file"
msgstr "Add file" msgstr "Add file"
@@ -431,7 +360,7 @@ msgstr "(copy)"
#: src/app/main/ui/dashboard/sidebar.cljs:347 #: src/app/main/ui/dashboard/sidebar.cljs:347
msgid "dashboard.create-new-org" msgid "dashboard.create-new-org"
msgstr "Create new org" msgstr "+ Create org"
#: src/app/main/ui/dashboard/sidebar.cljs:340 #: src/app/main/ui/dashboard/sidebar.cljs:340
msgid "dashboard.create-new-team" msgid "dashboard.create-new-team"
@@ -2134,6 +2063,209 @@ msgstr "Resolved value:"
msgid "inspect.tabs.styles.variants-panel" msgid "inspect.tabs.styles.variants-panel"
msgstr "Variant Properties" msgstr "Variant Properties"
#: src/app/main/ui/settings/integrations.cljs:189
msgid "integrations.access-tokens.create"
msgstr "Create new access token"
#: src/app/main/ui/settings/integrations.cljs:286
msgid "integrations.access-tokens.empty.add-one"
msgstr "Press the button \"Create new access token\" to generate one."
#: src/app/main/ui/settings/integrations.cljs:285
msgid "integrations.access-tokens.empty.no-access-tokens"
msgstr "You have no tokens so far."
#: src/app/main/ui/settings/integrations.cljs:184
msgid "integrations.access-tokens.personal"
msgstr "Personal access tokens"
#: src/app/main/ui/settings/integrations.cljs:185
msgid "integrations.access-tokens.personal.description"
msgstr ""
"Personal access tokens function like an alternative to our login/password "
"authentication system and can be used to allow an application to access the "
"internal Penpot API"
#: src/app/main/ui/settings/integrations.cljs:152, src/app/main/ui/settings/integrations.cljs:158
msgid "integrations.copy-token"
msgstr "Copy token"
#: src/app/main/ui/settings/integrations.cljs:432
msgid "integrations.create-access-token.title"
msgstr "Create access token"
#: src/app/main/ui/settings/integrations.cljs:433
msgid "integrations.create-access-token.title.created"
msgstr "Access token created"
#: src/app/main/ui/settings/integrations.cljs:290
msgid "integrations.create-mcp-key.title"
msgstr "Create new MCP key"
#: src/app/main/ui/settings/integrations.cljs:291
msgid "integrations.create-mcp-key.title.created"
msgstr "MCP key created"
#: src/app/main/ui/settings/integrations.cljs:257
msgid "integrations.delete-token.accept"
msgstr "Delete token"
#: src/app/main/ui/settings/integrations.cljs:256
msgid "integrations.delete-token.message"
msgstr "Are you sure you want to delete this token?"
#: src/app/main/ui/settings/integrations.cljs:255
msgid "integrations.delete-token.title"
msgstr "Delete token"
#: src/app/main/ui/settings/integrations.cljs:135
msgid "integrations.expiration-180-days"
msgstr "180 days"
#: src/app/main/ui/settings/integrations.cljs:132
msgid "integrations.expiration-30-days"
msgstr "30 days"
#: src/app/main/ui/settings/integrations.cljs:133
msgid "integrations.expiration-60-days"
msgstr "60 days"
#: src/app/main/ui/settings/integrations.cljs:134
msgid "integrations.expiration-90-days"
msgstr "90 days"
#: src/app/main/ui/settings/integrations.cljs:131
msgid "integrations.expiration-never"
msgstr "Never"
#: src/app/main/ui/settings/integrations.cljs:268
msgid "integrations.expired-on"
msgstr "Expired on %s"
#: src/app/main/ui/settings/integrations.cljs:269
msgid "integrations.expires-on"
msgstr "Expires on %s"
#: src/app/main/ui/settings/integrations.cljs:267
msgid "integrations.no-expiration"
msgstr "No expiration date"
#: src/app/main/ui/settings/integrations.cljs:130
msgid "integrations.expiration-date.label"
msgstr "Expiration date"
#: src/app/main/ui/settings/integrations.cljs:131
msgid "integrations.info.non-recuperable"
msgstr "This unique token is non-recuperable. If you lose it, you will need to create a new one."
#: src/app/main/ui/settings/integrations.cljs:336
msgid "integrations.mcp-server.title"
msgstr "MCP Server"
#: src/app/main/ui/settings/integrations.cljs:336
msgid "integrations.mcp-server.title.beta"
msgstr "Beta"
#: src/app/main/ui/settings/integrations.cljs:347
msgid "integrations.mcp-server.description"
msgstr "The Penpot MCP Server enables MCP clients to interact directly with Penpot design files."
#: src/app/main/ui/settings/integrations.cljs:353
msgid "integrations.mcp-server.status"
msgstr "Status"
#: src/app/main/ui/settings/integrations.cljs:370
msgid "integrations.mcp-server.status.disabled"
msgstr "Disabled"
#: src/app/main/ui/settings/integrations.cljs:370
msgid "integrations.mcp-server.status.enabled"
msgstr "Enabled"
#: src/app/main/ui/settings/integrations.cljs:363
msgid "integrations.mcp-server.status.expired.0"
msgstr "The MCP key used to connect to the MCP server has expired. As a result, the connection cannot be established."
#: src/app/main/ui/settings/integrations.cljs:368
msgid "integrations.mcp-server.status.expired.1"
msgstr "Please regenerate the MCP key and update your client configuration with the new key."
#: src/app/main/ui/settings/integrations.cljs:415
msgid "integrations.mcp-server.mcp-keys.copy"
msgstr "Copy link"
#: src/app/main/ui/settings/integrations.cljs:422
msgid "integrations.mcp-server.mcp-keys.help"
msgstr "How to configure MCP clients"
#: src/app/main/ui/settings/integrations.cljs:405
msgid "integrations.mcp-server.mcp-keys.info"
msgstr "This is the server url you'll need to configure your MCP client in order to connect it to the Penpot MCP server."
#: src/app/main/ui/settings/integrations.cljs:387
msgid "integrations.mcp-server.mcp-keys.regenerate"
msgstr "Regenerate MCP key"
#: src/app/main/ui/settings/integrations.cljs:381
msgid "integrations.mcp-server.mcp-keys.title"
msgstr "MCP key"
#: src/app/main/ui/settings/integrations.cljs:388
msgid "integrations.mcp-server.mcp-keys.tootip"
msgstr "The MCP key is needed for the MCP client set up"
#: src/app/main/ui/settings/integrations.cljs:124
msgid "integrations.name.label"
msgstr "Name"
#: src/app/main/ui/settings/integrations.cljs:126
msgid "integrations.name.placeholder"
msgstr "The name can help to know what's the token for"
#: src/app/main/ui/settings/integrations.cljs:103
msgid "integrations.notification.success.copied"
msgstr "Copied token"
#: src/app/main/ui/settings/integrations.cljs:64
msgid "integrations.notification.success.created"
msgstr "Token created successfully"
#: src/app/main/ui/settings/integrations.cljs:327
msgid "integrations.notification.success.copied-link"
msgstr "Link copied to clipboard"
#: src/app/main/ui/settings/integrations.cljs:293
msgid "integrations.notification.success.mcp-server-disabled"
msgstr "MCP server disabled"
#: src/app/main/ui/settings/integrations.cljs:299
msgid "integrations.notification.success.mcp-server-enabled"
msgstr "MCP server enabled"
#: src/app/main/ui/settings/integrations.cljs:317
msgid "integrations.regenerate-mcp-key.info"
msgstr "Regenerating the key will immediately revoke the current one. Any application using it will stop working."
#: src/app/main/ui/settings/integrations.cljs:317
msgid "integrations.regenerate-mcp-key.title"
msgstr "Regenerate MCP key"
#: src/app/main/ui/settings/integrations.cljs:318
msgid "integrations.regenerate-mcp-key.title.created"
msgstr "MCP key regenerated"
#: src/app/main/ui/settings/integrations.cljs:480
msgid "integrations.title"
msgstr "Integrations"
#: src/app/main/ui/settings/integrations.cljs:142
msgid "integrations.token-will-expire"
msgstr "The token will expire on %s"
#: src/app/main/ui/settings/integrations.cljs:143
msgid "integrations.token-will-not-expire"
msgstr "The token has no expiration date"
#: src/app/main/ui/dashboard/comments.cljs:96 #: src/app/main/ui/dashboard/comments.cljs:96
msgid "label.mark-all-as-read" msgid "label.mark-all-as-read"
msgstr "Mark all as read" msgstr "Mark all as read"
@@ -2354,6 +2486,9 @@ msgstr "Discard"
msgid "labels.download" msgid "labels.download"
msgstr "Download %s" msgstr "Download %s"
msgid "labels.download-simple"
msgstr "Download"
#: src/app/main/ui/dashboard/file_menu.cljs:30, src/app/main/ui/dashboard/files.cljs:80, src/app/main/ui/dashboard/files.cljs:179, src/app/main/ui/dashboard/projects.cljs:229, src/app/main/ui/dashboard/projects.cljs:233, src/app/main/ui/dashboard/sidebar.cljs:820 #: src/app/main/ui/dashboard/file_menu.cljs:30, src/app/main/ui/dashboard/files.cljs:80, src/app/main/ui/dashboard/files.cljs:179, src/app/main/ui/dashboard/projects.cljs:229, src/app/main/ui/dashboard/projects.cljs:233, src/app/main/ui/dashboard/sidebar.cljs:820
msgid "labels.drafts" msgid "labels.drafts"
msgstr "Drafts" msgstr "Drafts"
@@ -2485,6 +2620,10 @@ msgstr "Info"
msgid "labels.installed-fonts" msgid "labels.installed-fonts"
msgstr "Installed fonts" msgstr "Installed fonts"
#: src/app/main/ui/settings/sidebar.cljs:123
msgid "labels.integrations"
msgstr "Integrations"
#: src/app/main/ui/static.cljs:405 #: src/app/main/ui/static.cljs:405
msgid "labels.internal-error.desc-message-first" msgid "labels.internal-error.desc-message-first"
msgstr "Something bad happened." msgstr "Something bad happened."
@@ -3144,30 +3283,6 @@ msgstr "Change email"
msgid "modals.change-email.title" msgid "modals.change-email.title"
msgstr "Change your email" msgstr "Change your email"
#: src/app/main/ui/settings/access_tokens.cljs:152, src/app/main/ui/settings/access_tokens.cljs:158
msgid "modals.create-access-token.copy-token"
msgstr "Copy token"
#: src/app/main/ui/settings/access_tokens.cljs:130
msgid "modals.create-access-token.expiration-date.label"
msgstr "Expiration date"
#: src/app/main/ui/settings/access_tokens.cljs:124
msgid "modals.create-access-token.name.label"
msgstr "Name"
#: src/app/main/ui/settings/access_tokens.cljs:126
msgid "modals.create-access-token.name.placeholder"
msgstr "The name can help to know what's the token for"
#: src/app/main/ui/settings/access_tokens.cljs:178
msgid "modals.create-access-token.submit-label"
msgstr "Create token"
#: src/app/main/ui/settings/access_tokens.cljs:111
msgid "modals.create-access-token.title"
msgstr "Generate access token"
#: src/app/main/ui/dashboard/team.cljs:1127 #: src/app/main/ui/dashboard/team.cljs:1127
msgid "modals.create-webhook.submit-label" msgid "modals.create-webhook.submit-label"
msgstr "Create webhook" msgstr "Create webhook"
@@ -3184,18 +3299,6 @@ msgstr "Payload URL"
msgid "modals.create-webhook.url.placeholder" msgid "modals.create-webhook.url.placeholder"
msgstr "https://example.com/postreceive" msgstr "https://example.com/postreceive"
#: src/app/main/ui/settings/access_tokens.cljs:257
msgid "modals.delete-acces-token.accept"
msgstr "Delete token"
#: src/app/main/ui/settings/access_tokens.cljs:256
msgid "modals.delete-acces-token.message"
msgstr "Are you sure you want to delete this token?"
#: src/app/main/ui/settings/access_tokens.cljs:255
msgid "modals.delete-acces-token.title"
msgstr "Delete token"
#: src/app/main/ui/settings/delete_account.cljs:56 #: src/app/main/ui/settings/delete_account.cljs:56
msgid "modals.delete-account.cancel" msgid "modals.delete-account.cancel"
msgstr "Cancel and keep my account" msgstr "Cancel and keep my account"
@@ -5089,14 +5192,14 @@ msgstr "Shared Libraries - %s - Penpot"
msgid "title.default" msgid "title.default"
msgstr "Penpot - Design Freedom for Teams" msgstr "Penpot - Design Freedom for Teams"
#: src/app/main/ui/settings/access_tokens.cljs:278
msgid "title.settings.access-tokens"
msgstr "Profile - Access tokens"
#: src/app/main/ui/settings/feedback.cljs:161 #: src/app/main/ui/settings/feedback.cljs:161
msgid "title.settings.feedback" msgid "title.settings.feedback"
msgstr "Give feedback - Penpot" msgstr "Give feedback - Penpot"
#: src/app/main/ui/settings/integrations.cljs:278
msgid "title.settings.integrations"
msgstr "Integrations - Penpot"
#: src/app/main/ui/settings/notifications.cljs:45 #: src/app/main/ui/settings/notifications.cljs:45
msgid "title.settings.notifications" msgid "title.settings.notifications"
msgstr "Notifications - Penpot" msgstr "Notifications - Penpot"
@@ -7533,6 +7636,18 @@ msgstr "Paste"
msgid "workspace.shape.menu.paste-props" msgid "workspace.shape.menu.paste-props"
msgstr "Paste properties" msgstr "Paste properties"
msgid "workspace.shape.menu.copy-as-image"
msgstr "Copy as image"
msgid "workspace.clipboard.copying"
msgstr "Copying image…"
msgid "workspace.clipboard.image-copied"
msgstr "Image copied to the clipboard"
msgid "workspace.clipboard.image-copy-failed"
msgstr "Error copying image"
#: src/app/main/ui/workspace/context_menu.cljs:443 #: src/app/main/ui/workspace/context_menu.cljs:443
msgid "workspace.shape.menu.path" msgid "workspace.shape.menu.path"
msgstr "Path" msgstr "Path"

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