mirror of
https://github.com/penpot/penpot.git
synced 2026-02-17 01:54:43 -05:00
Compare commits
2 Commits
develop
...
elenatorro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af6cf17435 | ||
|
|
f2d09a6140 |
1
.github/workflows/build-develop.yml
vendored
1
.github/workflows/build-develop.yml
vendored
@@ -1,7 +1,6 @@
|
||||
name: _DEVELOP
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '16 5-20 * * 1-5'
|
||||
|
||||
|
||||
1
.github/workflows/build-staging-render.yml
vendored
1
.github/workflows/build-staging-render.yml
vendored
@@ -1,7 +1,6 @@
|
||||
name: _STAGING RENDER
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '36 5-20 * * 1-5'
|
||||
|
||||
|
||||
1
.github/workflows/build-staging.yml
vendored
1
.github/workflows/build-staging.yml
vendored
@@ -1,7 +1,6 @@
|
||||
name: _STAGING
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '36 5-20 * * 1-5'
|
||||
|
||||
|
||||
1
.github/workflows/build-tag.yml
vendored
1
.github/workflows/build-tag.yml
vendored
@@ -1,7 +1,6 @@
|
||||
name: _TAG
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
@@ -8,9 +8,6 @@
|
||||
|
||||
### :heart: Community contributions (Thank you!)
|
||||
|
||||
- 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)
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
- Access to design tokens in Penpot Plugins [Taiga #8990](https://tree.taiga.io/project/penpot/us/8990)
|
||||
@@ -20,8 +17,6 @@
|
||||
- Optimize sidebar performance for deeply nested shapes [Taiga #13017](https://tree.taiga.io/project/penpot/task/13017)
|
||||
- Remove tokens path node and bulk remove tokens [Taiga #13007](https://tree.taiga.io/project/penpot/us/13007)
|
||||
- Replace themes management modal radio buttons for switches [Taiga #9215](https://tree.taiga.io/project/penpot/us/9215)
|
||||
- [MCP server] Integrations section [Taiga #13112](https://tree.taiga.io/project/penpot/us/13112)
|
||||
- [Access Tokens] Look & feel refinement [Taiga #13114](https://tree.taiga.io/project/penpot/us/13114)
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
@@ -39,7 +34,6 @@
|
||||
- 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 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.2
|
||||
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
::yres/status 200
|
||||
::yres/body (yres/stream-body
|
||||
(fn [_ output]
|
||||
|
||||
(let [channel (sp/chan :buf buf :xf (keep encode))
|
||||
listener (events/spawn-listener
|
||||
channel
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
[:path ::fs/path]
|
||||
[:mtype {:optional true} ::sm/text]])
|
||||
|
||||
(def check-input
|
||||
(def ^:private check-input
|
||||
(sm/check-fn schema:input))
|
||||
|
||||
(defn validate-media-type!
|
||||
|
||||
@@ -463,10 +463,8 @@
|
||||
:fn (mg/resource "app/migrations/sql/0144-mod-server-error-report-table.sql")}
|
||||
|
||||
{: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!
|
||||
[pool name migrations]
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE access_token
|
||||
ADD COLUMN type text NULL;
|
||||
@@ -73,13 +73,9 @@
|
||||
(if (nil? result)
|
||||
204
|
||||
200))
|
||||
|
||||
headers (::http/headers mdata {})
|
||||
headers (cond-> headers
|
||||
(and (yres/stream-body? result)
|
||||
(not (contains? headers "content-type")))
|
||||
headers (cond-> (::http/headers mdata {})
|
||||
(yres/stream-body? result)
|
||||
(assoc "content-type" "application/octet-stream"))]
|
||||
|
||||
{::yres/status status
|
||||
::yres/headers headers
|
||||
::yres/body result}))]
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
(dissoc row :perms))
|
||||
|
||||
(defn create-access-token
|
||||
[{:keys [::db/conn] :as cfg} profile-id name expiration type]
|
||||
[{:keys [::db/conn] :as cfg} profile-id name expiration]
|
||||
(let [token-id (uuid/next)
|
||||
expires-at (some-> expiration (ct/in-future))
|
||||
created-at (ct/now)
|
||||
@@ -36,7 +36,6 @@
|
||||
{:id token-id
|
||||
:name name
|
||||
:token token
|
||||
:type type
|
||||
:profile-id profile-id
|
||||
:created-at created-at
|
||||
:updated-at created-at
|
||||
@@ -51,18 +50,17 @@
|
||||
(def ^:private schema:create-access-token
|
||||
[:map {:title "create-access-token"}
|
||||
[:name [:string {:max 250 :min 1}]]
|
||||
[:expiration {:optional true} ::ct/duration]
|
||||
[:type {:optional true} :string]])
|
||||
[:expiration {:optional true} ::ct/duration]])
|
||||
|
||||
(sv/defmethod ::create-access-token
|
||||
{::doc/added "1.18"
|
||||
::sm/params schema:create-access-token}
|
||||
[cfg {:keys [::rpc/profile-id name expiration type]}]
|
||||
[cfg {:keys [::rpc/profile-id name expiration]}]
|
||||
|
||||
(quotes/check! cfg {::quotes/id ::quotes/access-tokens-per-profile
|
||||
::quotes/profile-id profile-id})
|
||||
|
||||
(db/tx-run! cfg create-access-token profile-id name expiration type))
|
||||
(db/tx-run! cfg create-access-token profile-id name expiration))
|
||||
|
||||
(def ^:private schema:delete-access-token
|
||||
[:map {:title "delete-access-token"}
|
||||
@@ -85,5 +83,5 @@
|
||||
(->> (db/query pool :access-token
|
||||
{:profile-id profile-id}
|
||||
{:order-by [[:expires-at :asc] [:created-at :asc]]
|
||||
:columns [:id :name :perms :type :created-at :updated-at :expires-at]})
|
||||
:columns [:id :name :perms :created-at :updated-at :expires-at]})
|
||||
(mapv decode-row)))
|
||||
|
||||
@@ -9,14 +9,12 @@
|
||||
[app.binfile.common :as bfc]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.media :as cmedia]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.db.sql :as-alias sql]
|
||||
[app.features.logical-deletion :as ldel]
|
||||
[app.http :as-alias http]
|
||||
[app.loggers.audit :as-alias audit]
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
[app.media :as media]
|
||||
@@ -36,9 +34,7 @@
|
||||
java.io.InputStream
|
||||
java.io.OutputStream
|
||||
java.io.SequenceInputStream
|
||||
java.util.Collections
|
||||
java.util.zip.ZipEntry
|
||||
java.util.zip.ZipOutputStream))
|
||||
java.util.Collections))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
@@ -300,98 +296,3 @@
|
||||
(rph/with-meta (rph/wrap)
|
||||
{::audit/props {:font-family (:font-family 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")}))))
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
(def schema:props
|
||||
[:map {:title "ProfileProps"}
|
||||
[:plugins {:optional true} schema:plugin-registry]
|
||||
[:mcp-status {:optional true} ::sm/boolean]
|
||||
[:newsletter-updates {:optional true} ::sm/boolean]
|
||||
[:newsletter-news {:optional true} ::sm/boolean]
|
||||
[:onboarding-team-id {:optional true} ::sm/uuid]
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
|
||||
(t/deftest access-token-authz
|
||||
(let [profile (th/create-profile* 1)
|
||||
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil nil)
|
||||
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil)
|
||||
handler (#'app.http.access-token/wrap-authz identity th/*system*)]
|
||||
|
||||
(let [response (handler nil)]
|
||||
|
||||
@@ -152,9 +152,7 @@
|
||||
:redis-cache
|
||||
|
||||
;; Activates the nitrate module
|
||||
:nitrate
|
||||
|
||||
:mcp})
|
||||
:nitrate})
|
||||
|
||||
(def all-flags
|
||||
(set/union email login varia))
|
||||
|
||||
@@ -147,9 +147,6 @@
|
||||
(let [f (obj/get global "initializeExternalConfigInfo")]
|
||||
(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
|
||||
|
||||
(defn ^boolean check-browser? [candidate]
|
||||
|
||||
@@ -498,3 +498,4 @@
|
||||
(->> (rp/cmd! :delete-access-token params)
|
||||
(rx/tap on-success)
|
||||
(rx/catch on-error))))))
|
||||
|
||||
|
||||
@@ -1434,7 +1434,6 @@
|
||||
(dm/export dwcp/paste-shapes)
|
||||
(dm/export dwcp/paste-data-valid?)
|
||||
(dm/export dwcp/copy-link-to-clipboard)
|
||||
(dm/export dwcp/copy-as-image)
|
||||
|
||||
;; Drawing
|
||||
(dm/export dwd/select-for-drawing)
|
||||
|
||||
@@ -1039,55 +1039,3 @@
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(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)))))))))
|
||||
|
||||
@@ -197,7 +197,7 @@
|
||||
:settings-options
|
||||
:settings-feedback
|
||||
:settings-subscription
|
||||
:settings-integrations
|
||||
:settings-access-tokens
|
||||
:settings-notifications)
|
||||
(let [params (get params :query)
|
||||
error-report-id (some-> params :error-report-id uuid/parse*)]
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
(kbd/enter? event)
|
||||
(let [selected (dom/get-active)]
|
||||
(dom/prevent-default event)
|
||||
(dom/click selected))
|
||||
(dom/click! selected))
|
||||
|
||||
(kbd/tab? event)
|
||||
(on-close)))))]
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
input-name (get props :name)
|
||||
more-classes (get props :class)
|
||||
auto-focus? (get props :auto-focus? false)
|
||||
input-ref (mf/use-ref nil)
|
||||
|
||||
data-testid (d/nilv data-testid input-name)
|
||||
|
||||
@@ -83,6 +82,7 @@
|
||||
(swap! form assoc-in [:touched input-name] true)
|
||||
(fm/on-input-change form input-name value trim)
|
||||
(on-change-value name value)))
|
||||
|
||||
on-blur
|
||||
(fn [_]
|
||||
(reset! focus? false))
|
||||
@@ -92,18 +92,9 @@
|
||||
(when-not (get-in @form [:touched input-name])
|
||||
(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
|
||||
(dissoc :help-icon :form :trim :children :show-success? :auto-focus? :label)
|
||||
(assoc :id (name input-name)
|
||||
:ref input-ref
|
||||
:value value
|
||||
:auto-focus auto-focus?
|
||||
:on-click (when (or is-radio? is-checkbox?) on-click)
|
||||
@@ -140,7 +131,7 @@
|
||||
:for (name input-name)} label
|
||||
|
||||
(when is-checkbox?
|
||||
[:span {:class (stl/css-case :global/checked checked?) :tab-index "0" :on-key-press on-key-press} (when checked? deprecated-icon/status-tick)])
|
||||
[:span {:class (stl/css-case :global/checked checked?)} (when checked? deprecated-icon/status-tick)])
|
||||
|
||||
(if is-checkbox?
|
||||
[:> :input props]
|
||||
|
||||
@@ -9,17 +9,14 @@
|
||||
(:require
|
||||
[app.main.data.modal :as modal]
|
||||
[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.icons :as deprecated-icon]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.i18n :as i18n :refer [tr]]
|
||||
[app.util.keyboard :as k]
|
||||
[goog.events :as events]
|
||||
[rumext.v2 :as mf])
|
||||
(:import
|
||||
goog.events.EventType))
|
||||
(:import goog.events.EventType))
|
||||
|
||||
(mf/defc confirm-dialog
|
||||
{::mf/register modal/components
|
||||
@@ -71,11 +68,8 @@
|
||||
[:div {:class (stl/css :modal-container)}
|
||||
[:div {:class (stl/css :modal-header)}
|
||||
[:h2 {:class (stl/css :modal-title)} title]
|
||||
[:div {:class (stl/css :modal-close-btn)}
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:aria-label (tr "labels.close")
|
||||
:on-click cancel-fn
|
||||
:icon i/close}]]]
|
||||
[:button {:class (stl/css :modal-close-btn)
|
||||
:on-click cancel-fn} deprecated-icon/close]]
|
||||
|
||||
[:div {:class (stl/css :modal-content)}
|
||||
(when (and (string? message) (not= message ""))
|
||||
@@ -93,19 +87,24 @@
|
||||
[:ul {:class (stl/css :component-list)}
|
||||
(for [item items]
|
||||
[:li {:class (stl/css :modal-item-element)}
|
||||
[:> icon* {:icon-id i/component
|
||||
:class (stl/css :modal-component-icon)
|
||||
:size "s"}]
|
||||
[:span {:class (stl/css :modal-component-icon)}
|
||||
deprecated-icon/component]
|
||||
[:span {:class (stl/css :modal-component-name)}
|
||||
(:name item)]])]])]
|
||||
|
||||
[:div {:class (stl/css :modal-footer)}
|
||||
[:div {:class (stl/css :action-buttons)}
|
||||
(when-not (= cancel-label :omit)
|
||||
[:> button* {:variant "secondary"
|
||||
:on-click cancel-fn}
|
||||
cancel-label])
|
||||
[:> button* {:variant (cond (= accept-style :danger) "destructive"
|
||||
(= accept-style :primary) "primary")
|
||||
:on-click accept-fn}
|
||||
accept-label]]]]]))
|
||||
[:input
|
||||
{:class (stl/css :cancel-button)
|
||||
:type "button"
|
||||
:value cancel-label
|
||||
:on-click cancel-fn}])
|
||||
|
||||
[:input
|
||||
{:class (stl/css-case :accept-btn true
|
||||
:danger (= accept-style :danger)
|
||||
:primary (= accept-style :primary))
|
||||
:type "button"
|
||||
:value accept-label
|
||||
:on-click accept-fn}]]]]]))
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
|
||||
.modal-container {
|
||||
@extend .modal-container-base;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xxl);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
margin-bottom: deprecated.$s-24;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@@ -26,13 +27,12 @@
|
||||
}
|
||||
|
||||
.modal-close-btn {
|
||||
position: absolute;
|
||||
top: var(--sp-m);
|
||||
right: var(--sp-m);
|
||||
@extend .modal-close-btn-base;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
@include deprecated.bodyLargeTypography;
|
||||
margin-bottom: deprecated.$s-24;
|
||||
}
|
||||
|
||||
.modal-item-element {
|
||||
@@ -41,18 +41,32 @@
|
||||
|
||||
.modal-component-icon {
|
||||
@include deprecated.flexCenter;
|
||||
color: var(--color-foreground-secondary);
|
||||
height: deprecated.$s-16;
|
||||
width: deprecated.$s-16;
|
||||
svg {
|
||||
@extend .button-icon-small;
|
||||
stroke: var(--color);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-component-name {
|
||||
@include deprecated.bodyLargeTypography;
|
||||
color: var(--color-foreground-secondary);
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
@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-subtitle,
|
||||
.modal-msg {
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
(ns app.main.ui.dashboard.fonts
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.media :as cm]
|
||||
[app.common.uuid :as uuid]
|
||||
@@ -23,7 +22,6 @@
|
||||
[app.main.ui.icons :as deprecated-icon]
|
||||
[app.main.ui.notifications.context-notification :refer [context-notification]]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.http :as http]
|
||||
[app.util.i18n :as i18n :refer [tr]]
|
||||
[app.util.keyboard :as kbd]
|
||||
[beicon.v2.core :as rx]
|
||||
@@ -261,14 +259,11 @@
|
||||
(mf/defc installed-font-context-menu
|
||||
{::mf/props :obj
|
||||
::mf/private true}
|
||||
[{:keys [is-open on-close on-edit on-download on-delete]}]
|
||||
(let [options (mf/with-memo [on-edit on-download on-delete]
|
||||
[{:keys [is-open on-close on-edit on-delete]}]
|
||||
(let [options (mf/with-memo [on-edit on-delete]
|
||||
[{:name (tr "labels.edit")
|
||||
:id "font-edit"
|
||||
:handler on-edit}
|
||||
{:name (tr "labels.download-simple")
|
||||
:id "font-download"
|
||||
:handler on-download}
|
||||
{:name (tr "labels.delete")
|
||||
:id "font-delete"
|
||||
:handler on-delete}])]
|
||||
@@ -350,26 +345,6 @@
|
||||
(st/emit! (df/delete-font font-id)))}]
|
||||
(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
|
||||
(mf/use-fn
|
||||
(fn [event]
|
||||
@@ -432,7 +407,6 @@
|
||||
{:on-close on-menu-close
|
||||
:is-open menu-open?
|
||||
:on-delete on-delete-font
|
||||
:on-download on-download
|
||||
:on-edit on-edit}]]))]))
|
||||
|
||||
(mf/defc installed-fonts*
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
(mf/use-ref nil)
|
||||
|
||||
on-import-files
|
||||
(fn [] (dom/click (mf/ref-val file-input)))
|
||||
(fn [] (dom/click! (mf/ref-val file-input)))
|
||||
|
||||
on-finish-import
|
||||
(mf/use-fn
|
||||
|
||||
@@ -30,13 +30,10 @@
|
||||
[app.main.ui.dashboard.subscription :refer [dashboard-cta*
|
||||
get-subscription-type
|
||||
menu-team-icon*
|
||||
nitrate-sidebar*
|
||||
show-subscription-dashboard-banner?
|
||||
subscription-sidebar*]]
|
||||
[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.raw-svg :refer [raw-svg*]]
|
||||
[app.main.ui.icons :as deprecated-icon]
|
||||
[app.main.ui.nitrate.nitrate-form]
|
||||
[app.util.dom :as dom]
|
||||
@@ -77,8 +74,6 @@
|
||||
(def ^:private 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/private true}
|
||||
[{:keys [item is-selected]}]
|
||||
@@ -502,23 +497,18 @@
|
||||
|
||||
(mf/defc sidebar-org-switch*
|
||||
[{:keys [team profile]}]
|
||||
(let [teams (mf/deref refs/teams)
|
||||
orgs (mf/with-memo [teams]
|
||||
(let [orgs (->> teams
|
||||
vals
|
||||
(group-by :organization-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)))))))
|
||||
(let [teams (->> (mf/deref refs/teams)
|
||||
vals
|
||||
(group-by :organization-id)
|
||||
(map (fn [[_group entries]] (first entries)))
|
||||
vec
|
||||
(d/index-by :id))
|
||||
|
||||
empty? (= (count orgs) 1)
|
||||
teams (update-vals teams
|
||||
(fn [t]
|
||||
(assoc t :name (str "ORG: " (:organization-name t)))))
|
||||
|
||||
|
||||
current-org (mf/with-memo [team]
|
||||
(assoc team :name (str "ORG: " (:organization-name team))))
|
||||
team (assoc team :name (str "ORG: " (:organization-name team)))
|
||||
|
||||
show-teams-menu*
|
||||
(mf/use-state false)
|
||||
@@ -540,53 +530,36 @@
|
||||
(dom/prevent-default event)
|
||||
(dom/stop-propagation event)
|
||||
(some-> (dom/get-current-target event)
|
||||
(dom/click)))))
|
||||
(dom/click!)))))
|
||||
close-teams-menu
|
||||
(mf/use-fn #(reset! show-teams-menu* false))
|
||||
(mf/use-fn #(reset! show-teams-menu* false))]
|
||||
|
||||
on-create-org-click
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
(if (:nitrate-licence profile)
|
||||
;; TODO update when org creation route is ready
|
||||
(dom/open-new-window "/control-center/org/create")
|
||||
(st/emit! (modal/show :nitrate-form {})))))]
|
||||
(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 :sidebar-team-switch)}
|
||||
[:div {:class (stl/css :switch-content)}
|
||||
[:button {:class (stl/css :current-team)
|
||||
:on-click on-show-teams-click
|
||||
:on-key-down on-show-teams-keydown}
|
||||
|
||||
[:div {:class (stl/css :sidebar-team-switch)}
|
||||
[:div {:class (stl/css :switch-content)}
|
||||
[:button {:class (stl/css :current-team)
|
||||
:on-click on-show-teams-click
|
||||
:on-key-down on-show-teams-keydown}
|
||||
[:div {:class (stl/css :team-name)}
|
||||
[:img {:src (cf/resolve-team-photo-url team)
|
||||
:class (stl/css :team-picture)
|
||||
:alt (:name team)}]
|
||||
[:span {:class (stl/css :team-text) :title (:name team)} (:name team)]]
|
||||
|
||||
[: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)]]
|
||||
arrow-icon]]
|
||||
|
||||
arrow-icon]]
|
||||
;; Teams Dropdown
|
||||
|
||||
;; Teams Dropdown
|
||||
|
||||
[:> teams-selector-dropdown* {:show show-teams-menu?
|
||||
:on-close close-teams-menu
|
||||
:id "organizations-list"
|
||||
:class (stl/css :dropdown :teams-dropdown)
|
||||
:team current-org
|
||||
:profile profile
|
||||
:teams orgs
|
||||
:show-default-team false
|
||||
:allow-create-teams false
|
||||
:allow-create-org true}]])))
|
||||
[:> teams-selector-dropdown* {:show show-teams-menu?
|
||||
:on-close close-teams-menu
|
||||
:id "organizations-list"
|
||||
:class (stl/css :dropdown :teams-dropdown)
|
||||
:team team
|
||||
:profile profile
|
||||
:teams teams
|
||||
:show-default-team false
|
||||
:allow-create-teams false
|
||||
:allow-create-org true}]]))
|
||||
|
||||
(mf/defc sidebar-team-switch*
|
||||
[{:keys [team profile]}]
|
||||
@@ -628,7 +601,7 @@
|
||||
(dom/prevent-default event)
|
||||
(dom/stop-propagation event)
|
||||
(some-> (dom/get-current-target event)
|
||||
(dom/click)))))
|
||||
(dom/click!)))))
|
||||
|
||||
close-team-options-menu
|
||||
(mf/use-fn #(reset! show-team-options-menu* false))
|
||||
@@ -648,7 +621,7 @@
|
||||
(dom/stop-propagation event)
|
||||
|
||||
(some-> (dom/get-current-target event)
|
||||
(dom/click)))))
|
||||
(dom/click!)))))
|
||||
|
||||
close-teams-menu
|
||||
(mf/use-fn #(reset! show-teams-menu* false))]
|
||||
@@ -732,8 +705,6 @@
|
||||
overflow* (mf/use-state false)
|
||||
overflow? (deref overflow*)
|
||||
|
||||
nitrate? (contains? cf/flags :nitrate)
|
||||
|
||||
go-projects
|
||||
(mf/use-fn #(st/emit! (dcm/go-to-dashboard-recent)))
|
||||
|
||||
@@ -822,71 +793,70 @@
|
||||
(reset! overflow* (> scroll-height client-height))))
|
||||
|
||||
[:*
|
||||
[:div {:ref container}
|
||||
(when nitrate?
|
||||
[:div {:class (stl/css :nitrate-orgs-container)}
|
||||
[:> sidebar-org-switch* {:team team :profile profile}]])
|
||||
[:div {:class (stl/css-case :sidebar-content true :sidebar-content-nitrate nitrate?)}
|
||||
[:> sidebar-team-switch* {:team team :profile profile}]
|
||||
[:div {:class (stl/css-case :sidebar-content true)
|
||||
:ref container}
|
||||
(when (contains? cf/flags :nitrate)
|
||||
[:> sidebar-org-switch* {:team team :profile profile}])
|
||||
[:> sidebar-team-switch* {:team team :profile profile}]
|
||||
|
||||
[:> sidebar-search* {:search-term search-term
|
||||
:team-id (:id team)}]
|
||||
[:> sidebar-search* {:search-term search-term
|
||||
:team-id (:id team)}]
|
||||
|
||||
[:div {:class (stl/css :sidebar-content-section)}
|
||||
[:ul {:class (stl/css :sidebar-nav)}
|
||||
[:li {:class (stl/css-case :recent-projects true
|
||||
:sidebar-nav-item true
|
||||
:current projects?)}
|
||||
[:& link {:action go-projects
|
||||
:class (stl/css :sidebar-link)
|
||||
:keyboard-action go-projects-with-key}
|
||||
[:span {:class (stl/css :element-title)} (tr "labels.projects")]]]
|
||||
[:div {:class (stl/css :sidebar-content-section)}
|
||||
[:ul {:class (stl/css :sidebar-nav)}
|
||||
[:li {:class (stl/css-case :recent-projects true
|
||||
:sidebar-nav-item true
|
||||
:current projects?)}
|
||||
[:& link {:action go-projects
|
||||
:class (stl/css :sidebar-link)
|
||||
:keyboard-action go-projects-with-key}
|
||||
[:span {:class (stl/css :element-title)} (tr "labels.projects")]]]
|
||||
|
||||
[:li {:class (stl/css-case :current drafts?
|
||||
:sidebar-nav-item true)}
|
||||
[:& link {:action go-drafts
|
||||
:class (stl/css :sidebar-link)
|
||||
:keyboard-action go-drafts-with-key}
|
||||
[:span {:class (stl/css :element-title)} (tr "labels.drafts")]]]]]
|
||||
[:li {:class (stl/css-case :current drafts?
|
||||
:sidebar-nav-item true)}
|
||||
[:& link {:action go-drafts
|
||||
:class (stl/css :sidebar-link)
|
||||
:keyboard-action go-drafts-with-key}
|
||||
[:span {:class (stl/css :element-title)} (tr "labels.drafts")]]]]]
|
||||
|
||||
|
||||
[:div {:class (stl/css :sidebar-content-section)}
|
||||
[:div {:class (stl/css :sidebar-section-title)}
|
||||
(tr "labels.sources")]
|
||||
[:ul {:class (stl/css :sidebar-nav)}
|
||||
[:li {:class (stl/css-case :sidebar-nav-item true
|
||||
:current fonts?)}
|
||||
[:& link {:action go-fonts
|
||||
:class (stl/css :sidebar-link)
|
||||
:keyboard-action go-fonts-with-key
|
||||
:data-testid "fonts"}
|
||||
[:span {:class (stl/css :element-title)} (tr "labels.fonts")]]]
|
||||
[:li {:class (stl/css-case :current libs?
|
||||
:sidebar-nav-item true)}
|
||||
[:& link {:action go-libs
|
||||
:data-testid "libs-link-sidebar"
|
||||
:class (stl/css :sidebar-link)
|
||||
:keyboard-action go-libs-with-key}
|
||||
[:span {:class (stl/css :element-title)} (tr "labels.shared-libraries")]]]]]
|
||||
[:div {:class (stl/css :sidebar-content-section)}
|
||||
[:div {:class (stl/css :sidebar-section-title)}
|
||||
(tr "labels.sources")]
|
||||
[:ul {:class (stl/css :sidebar-nav)}
|
||||
[:li {:class (stl/css-case :sidebar-nav-item true
|
||||
:current fonts?)}
|
||||
[:& link {:action go-fonts
|
||||
:class (stl/css :sidebar-link)
|
||||
:keyboard-action go-fonts-with-key
|
||||
:data-testid "fonts"}
|
||||
[:span {:class (stl/css :element-title)} (tr "labels.fonts")]]]
|
||||
[:li {:class (stl/css-case :current libs?
|
||||
:sidebar-nav-item true)}
|
||||
[:& link {:action go-libs
|
||||
:data-testid "libs-link-sidebar"
|
||||
:class (stl/css :sidebar-link)
|
||||
:keyboard-action go-libs-with-key}
|
||||
[:span {:class (stl/css :element-title)} (tr "labels.shared-libraries")]]]]]
|
||||
|
||||
|
||||
[:div {:class (stl/css :sidebar-content-section)
|
||||
:data-testid "pinned-projects"}
|
||||
[:div {:class (stl/css :sidebar-section-title)}
|
||||
(tr "labels.pinned-projects")]
|
||||
(if (some? pinned-projects)
|
||||
[:ul {:class (stl/css :sidebar-nav :pinned-projects)}
|
||||
(for [item pinned-projects]
|
||||
[:> sidebar-project*
|
||||
{:item item
|
||||
:key (dm/str (:id item))
|
||||
:id (:id item)
|
||||
:team-id (:id team)
|
||||
:is-selected (= (:id item) (:id project))}])]
|
||||
[:div {:class (stl/css :sidebar-empty-placeholder)}
|
||||
pin-icon
|
||||
[: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 :sidebar-content-section)
|
||||
:data-testid "pinned-projects"}
|
||||
[:div {:class (stl/css :sidebar-section-title)}
|
||||
(tr "labels.pinned-projects")]
|
||||
(if (some? pinned-projects)
|
||||
[:ul {:class (stl/css :sidebar-nav :pinned-projects)}
|
||||
(for [item pinned-projects]
|
||||
[:> sidebar-project*
|
||||
{:item item
|
||||
:key (dm/str (:id item))
|
||||
:id (:id item)
|
||||
:team-id (:id team)
|
||||
:is-selected (= (:id item) (:id project))}])]
|
||||
[:div {:class (stl/css :sidebar-empty-placeholder)}
|
||||
pin-icon
|
||||
[:span {:class (stl/css :empty-text)} (tr "dashboard.no-projects-placeholder")]])]]
|
||||
[:div {:class (stl/css-case :separator true :overflow-separator overflow?)}]]))
|
||||
|
||||
(mf/defc help-learning-menu*
|
||||
{::mf/props :obj
|
||||
@@ -1086,13 +1056,10 @@
|
||||
(dom/open-new-window "https://penpot.app/pricing")))]
|
||||
|
||||
[:*
|
||||
(if (contains? cf/flags :nitrate)
|
||||
(when-not (:nitrate-licence profile)
|
||||
[:> nitrate-sidebar* {:profile profile}])
|
||||
(when (contains? cf/flags :subscriptions)
|
||||
(if (show-subscription-dashboard-banner? profile)
|
||||
[:> dashboard-cta* {: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
|
||||
(when (contains? cf/flags :subscriptions-old)
|
||||
|
||||
@@ -40,11 +40,6 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-content-nitrate {
|
||||
padding: var(--sp-m) 0 0 0;
|
||||
border-block-start: $b-1 solid var(--color-background-quaternary);
|
||||
}
|
||||
|
||||
.separator {
|
||||
height: var(--sp-xxs);
|
||||
width: 94%;
|
||||
@@ -519,44 +514,3 @@
|
||||
@include t.use-typography("body-small");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
[app.common.data.macros :as dm]
|
||||
[app.config :as cf]
|
||||
[app.main.data.event :as ev]
|
||||
[app.main.data.modal :as modal]
|
||||
[app.main.router :as rt]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.components.dropdown-menu :refer [dropdown-menu-item*]]
|
||||
@@ -116,26 +115,6 @@
|
||||
:has-dropdown false
|
||||
:is-highlighted false}]))))
|
||||
|
||||
(mf/defc nitrate-sidebar*
|
||||
[]
|
||||
(let [handle-click
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
(st/emit! (modal/show :nitrate-form {}))))]
|
||||
|
||||
;; 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*
|
||||
[{:keys [is-owner team]}]
|
||||
(let [subscription (:subscription team)
|
||||
|
||||
@@ -205,28 +205,3 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ $sz-32: px2rem(32);
|
||||
$sz-36: px2rem(36);
|
||||
$sz-40: px2rem(40);
|
||||
$sz-48: px2rem(48);
|
||||
$sz-64: px2rem(64);
|
||||
$sz-88: px2rem(88);
|
||||
$sz-96: px2rem(96);
|
||||
$sz-120: px2rem(120);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.main.constants :refer [max-input-length]]
|
||||
[app.main.ui.ds.controls.utilities.hint-message :refer [hint-message*]]
|
||||
[app.main.ui.ds.controls.utilities.input-field :refer [input-field*]]
|
||||
@@ -51,11 +52,10 @@
|
||||
:has-hint has-hint
|
||||
:hint-type hint-type
|
||||
:variant variant})]
|
||||
|
||||
[:div {:class [class (stl/css-case :input-wrapper true
|
||||
:variant-dense (= variant "dense")
|
||||
:variant-comfortable (= variant "comfortable")
|
||||
:has-hint has-hint)]}
|
||||
[:div {:class (dm/str class " " (stl/css-case :input-wrapper true
|
||||
:variant-dense (= variant "dense")
|
||||
:variant-comfortable (= variant "comfortable")
|
||||
:has-hint has-hint))}
|
||||
(when has-label
|
||||
[:> label* {:for id :is-optional is-optional} label])
|
||||
[:> input-field* props]
|
||||
@@ -64,3 +64,4 @@
|
||||
:class hint-class
|
||||
:message hint-message
|
||||
:type hint-type}])]))
|
||||
|
||||
|
||||
@@ -84,7 +84,6 @@
|
||||
:on-click on-icon-click}])
|
||||
(if aria-label
|
||||
[:> tooltip* {:content aria-label
|
||||
:class (stl/css :tooltip-wrapper)
|
||||
:id tooltip-id}
|
||||
[:> "input" props]]
|
||||
[:> "input" props])
|
||||
|
||||
@@ -120,7 +120,3 @@
|
||||
color: var(--color-foreground-secondary);
|
||||
min-inline-size: var(--sp-l);
|
||||
}
|
||||
|
||||
.tooltip-wrapper {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
(:require
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.main.ui.ds.controls.input :refer [input*]]
|
||||
[app.main.ui.ds.controls.select :refer [select*]]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.forms :as fm]
|
||||
[app.util.keyboard :as k]
|
||||
@@ -48,23 +47,6 @@
|
||||
|
||||
[:> 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*
|
||||
[{:keys [disabled on-submit] :rest props}]
|
||||
(let [form (mf/use-ctx context)
|
||||
@@ -97,4 +79,4 @@
|
||||
(when (fn? on-submit)
|
||||
(on-submit form event))))]
|
||||
[:> (mf/provider context) {:value form}
|
||||
[:form {:class class :on-submit on-submit'} children]]))
|
||||
[:form {:class class :on-submit on-submit'} children]]))
|
||||
@@ -36,7 +36,7 @@
|
||||
["/feedback" :settings-feedback]
|
||||
["/options" :settings-options]
|
||||
["/subscriptions" :settings-subscription]
|
||||
["/integrations" :settings-integrations]
|
||||
["/access-tokens" :settings-access-tokens]
|
||||
["/notifications" :settings-notifications]]
|
||||
|
||||
["/frame-preview" :frame-preview]
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.hooks :as hooks]
|
||||
[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.delete-account]
|
||||
[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.options :refer [options-page]]
|
||||
[app.main.ui.settings.password :refer [password-page]]
|
||||
@@ -73,8 +73,8 @@
|
||||
:settings-subscription
|
||||
[:> subscription-page* {:profile profile}]
|
||||
|
||||
:settings-integrations
|
||||
[:> integrations-page*]
|
||||
:settings-access-tokens
|
||||
[:& access-tokens-page]
|
||||
|
||||
:settings-notifications
|
||||
[:& notifications-page* {:profile profile}])]]]]))
|
||||
|
||||
291
frontend/src/app/main/ui/settings/access_tokens.cljs
Normal file
291
frontend/src/app/main/ui/settings/access_tokens.cljs
Normal file
@@ -0,0 +1,291 @@
|
||||
;; 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)}])]])]))
|
||||
|
||||
202
frontend/src/app/main/ui/settings/access_tokens.scss
Normal file
202
frontend/src/app/main/ui/settings/access_tokens.scss
Normal file
@@ -0,0 +1,202 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,573 +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.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*])])
|
||||
@@ -1,221 +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;
|
||||
|
||||
@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;
|
||||
}
|
||||
@@ -43,8 +43,8 @@
|
||||
(def ^:private go-settings-subscription
|
||||
#(st/emit! (rt/nav :settings-subscription)))
|
||||
|
||||
(def ^:private go-settings-integrations
|
||||
#(st/emit! (rt/nav :settings-integrations)))
|
||||
(def ^:private go-settings-access-tokens
|
||||
#(st/emit! (rt/nav :settings-access-tokens)))
|
||||
|
||||
(def ^:private go-settings-notifications
|
||||
#(st/emit! (rt/nav :settings-notifications)))
|
||||
@@ -66,7 +66,7 @@
|
||||
options? (= section :settings-options)
|
||||
feedback? (= section :settings-feedback)
|
||||
subscription? (= section :settings-subscription)
|
||||
integrations? (= section :settings-integrations)
|
||||
access-tokens? (= section :settings-access-tokens)
|
||||
notifications? (= section :settings-notifications)
|
||||
team-id (or (dtm/get-last-team-id)
|
||||
(:default-team-id profile))
|
||||
@@ -115,13 +115,12 @@
|
||||
:data-testid "settings-subscription"}
|
||||
[:span {:class (stl/css :element-title)} (tr "subscription.labels")]])
|
||||
|
||||
(when (or (contains? cf/flags :access-tokens)
|
||||
(contains? cf/flags :mcp))
|
||||
[:li {:class (stl/css-case :current integrations?
|
||||
(when (contains? cf/flags :access-tokens)
|
||||
[:li {:class (stl/css-case :current access-tokens?
|
||||
:settings-item true)
|
||||
:on-click go-settings-integrations
|
||||
:data-testid "settings-integrations"}
|
||||
[:span {:class (stl/css :element-title)} (tr "labels.integrations")]])
|
||||
:on-click go-settings-access-tokens
|
||||
:data-testid "settings-access-tokens"}
|
||||
[:span {:class (stl/css :element-title)} (tr "labels.access-tokens")]])
|
||||
|
||||
[:hr {:class (stl/css :sidebar-separator)}]
|
||||
|
||||
|
||||
@@ -150,9 +150,7 @@
|
||||
{::mf/props :obj
|
||||
::mf/private true}
|
||||
[{:keys [shapes]}]
|
||||
(let [multiple? (> (count shapes) 1)
|
||||
|
||||
do-copy #(st/emit! (dw/copy-selected))
|
||||
(let [do-copy #(st/emit! (dw/copy-selected))
|
||||
do-copy-link #(st/emit! (dw/copy-link-to-clipboard))
|
||||
|
||||
do-cut #(st/emit! (dw/copy-selected)
|
||||
@@ -180,9 +178,6 @@
|
||||
handle-copy-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
|
||||
(mf/use-callback
|
||||
(fn []
|
||||
@@ -227,11 +222,6 @@
|
||||
[:> menu-entry* {:title (tr "workspace.shape.menu.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-entry* {:title (tr "workspace.shape.menu.copy-text")
|
||||
@@ -239,7 +229,7 @@
|
||||
|
||||
[:> menu-entry* {:title (tr "workspace.shape.menu.copy-props")
|
||||
:shortcut (sc/get-tooltip :copy-props)
|
||||
:disabled multiple?
|
||||
:disabled (> (count shapes) 1)
|
||||
:on-click handle-copy-props}]
|
||||
[:> menu-entry* {:title (tr "workspace.shape.menu.paste-props")
|
||||
:shortcut (sc/get-tooltip :paste-props)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"A WASM based render API"
|
||||
(:require
|
||||
["react-dom/server" :as rds]
|
||||
[app.common.buffer :as buf]
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
@@ -495,7 +496,23 @@
|
||||
(mapcat get-fill-images)
|
||||
(map #(process-fill-image shape-id % thumbnail?))))))
|
||||
|
||||
(defn- pending-fill-images
|
||||
"Return pending image fetches for fill image-ids without re-sending data to WASM."
|
||||
[shape-id image-ids thumbnail?]
|
||||
(keep (fn [id]
|
||||
(let [buffer (uuid/get-u32 id)
|
||||
cached-image? (h/call wasm/internal-module "_is_image_cached"
|
||||
(aget buffer 0)
|
||||
(aget buffer 1)
|
||||
(aget buffer 2)
|
||||
(aget buffer 3)
|
||||
thumbnail?)]
|
||||
(when (zero? cached-image?)
|
||||
(fetch-image shape-id id thumbnail?))))
|
||||
image-ids))
|
||||
|
||||
(defn set-shape-fills
|
||||
"Write fills to WASM and return pending image fetches."
|
||||
[shape-id fills thumbnail?]
|
||||
(if (empty? fills)
|
||||
(h/call wasm/internal-module "_clear_shape_fills")
|
||||
@@ -510,24 +527,73 @@
|
||||
(h/call wasm/internal-module "_set_shape_fills")
|
||||
|
||||
;; load images for image fills if not cached
|
||||
(keep (fn [id]
|
||||
(let [buffer (uuid/get-u32 id)
|
||||
cached-image? (h/call wasm/internal-module "_is_image_cached"
|
||||
(aget buffer 0)
|
||||
(aget buffer 1)
|
||||
(aget buffer 2)
|
||||
(aget buffer 3)
|
||||
thumbnail?)]
|
||||
(when (zero? cached-image?)
|
||||
(fetch-image shape-id id thumbnail?))))
|
||||
(pending-fill-images shape-id (types.fills/get-image-ids fills) thumbnail?))))
|
||||
|
||||
(types.fills/get-image-ids fills)))))
|
||||
(defn set-shape-fills-data
|
||||
"Write fills data to WASM (no image fetching). Returns the coerced fills for later image checks."
|
||||
[fills]
|
||||
(if (empty? fills)
|
||||
(do (h/call wasm/internal-module "_clear_shape_fills") nil)
|
||||
(let [fills (types.fills/coerce fills)
|
||||
offset (mem/alloc->offset-32 (types.fills/get-byte-size fills))
|
||||
heap (mem/get-heap-u32)]
|
||||
(types.fills/write-to fills heap offset)
|
||||
(h/call wasm/internal-module "_set_shape_fills")
|
||||
fills)))
|
||||
|
||||
(def ^:const STROKE-ENTRY-U8-SIZE
|
||||
"Per-stroke binary entry: 4 bytes header (kind, style, cap-start, cap-end)
|
||||
+ 4 bytes width (f32) + FILL-U8-SIZE bytes fill data."
|
||||
(+ 8 types.fills.impl/FILL-U8-SIZE))
|
||||
|
||||
(defn- translate-stroke-kind
|
||||
"Translate stroke alignment keyword to binary kind value.
|
||||
Must match RawStrokeKind repr(u8) in strokes.rs."
|
||||
[align]
|
||||
(case align
|
||||
:inner 1
|
||||
:outer 2
|
||||
0)) ;; center (default)
|
||||
|
||||
(defn- pending-stroke-images
|
||||
"Return pending image fetches for stroke images without re-sending data to WASM."
|
||||
[shape-id strokes thumbnail?]
|
||||
(into []
|
||||
(keep (fn [stroke]
|
||||
(when-let [image (:stroke-image stroke)]
|
||||
(let [image-id (get image :id)
|
||||
buffer (uuid/get-u32 image-id)
|
||||
cached-image? (h/call wasm/internal-module "_is_image_cached"
|
||||
(aget buffer 0) (aget buffer 1)
|
||||
(aget buffer 2) (aget buffer 3)
|
||||
thumbnail?)]
|
||||
(when (zero? cached-image?)
|
||||
(fetch-image shape-id image-id thumbnail?))))))
|
||||
strokes))
|
||||
|
||||
(defn set-shape-strokes
|
||||
"Write strokes to WASM and return pending image fetches."
|
||||
[shape-id strokes thumbnail?]
|
||||
(h/call wasm/internal-module "_clear_shape_strokes")
|
||||
(keep (fn [stroke]
|
||||
(let [opacity (or (:stroke-opacity stroke) 1.0)
|
||||
(if (empty? strokes)
|
||||
(h/call wasm/internal-module "_clear_shape_strokes")
|
||||
(let [num-strokes (count strokes)
|
||||
buf-size (+ 4 (* num-strokes STROKE-ENTRY-U8-SIZE))
|
||||
base-ptr (mem/alloc buf-size)
|
||||
heap (mem/get-heap-u8)
|
||||
dview (js/DataView. (.-buffer heap))]
|
||||
|
||||
;; Header: stroke count at byte 0
|
||||
(buf/write-byte dview base-ptr num-strokes)
|
||||
|
||||
;; Write each stroke entry into the shared buffer
|
||||
(loop [i 0
|
||||
remaining (seq strokes)]
|
||||
(when remaining
|
||||
(let [stroke (first remaining)
|
||||
entry-offset (+ base-ptr 4 (* i STROKE-ENTRY-U8-SIZE))
|
||||
fill-offset (+ entry-offset 8)
|
||||
|
||||
opacity (or (:stroke-opacity stroke) 1.0)
|
||||
color (:stroke-color stroke)
|
||||
gradient (:stroke-color-gradient stroke)
|
||||
image (:stroke-image stroke)
|
||||
@@ -536,37 +602,89 @@
|
||||
style (-> stroke :stroke-style sr/translate-stroke-style)
|
||||
cap-start (-> stroke :stroke-cap-start sr/translate-stroke-cap)
|
||||
cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap)
|
||||
offset (mem/alloc types.fills.impl/FILL-U8-SIZE)
|
||||
heap (mem/get-heap-u8)
|
||||
dview (js/DataView. (.-buffer heap))]
|
||||
(case align
|
||||
:inner (h/call wasm/internal-module "_add_shape_inner_stroke" width style cap-start cap-end)
|
||||
:outer (h/call wasm/internal-module "_add_shape_outer_stroke" width style cap-start cap-end)
|
||||
(h/call wasm/internal-module "_add_shape_center_stroke" width style cap-start cap-end))
|
||||
kind (translate-stroke-kind align)]
|
||||
|
||||
;; Stroke header: kind(u8), style(u8), cap-start(u8), cap-end(u8), width(f32)
|
||||
(buf/write-byte dview (+ entry-offset 0) kind)
|
||||
(buf/write-byte dview (+ entry-offset 1) style)
|
||||
(buf/write-byte dview (+ entry-offset 2) cap-start)
|
||||
(buf/write-byte dview (+ entry-offset 3) cap-end)
|
||||
(buf/write-float dview (+ entry-offset 4) width)
|
||||
|
||||
;; Fill data (written at fill-offset inside the same buffer)
|
||||
(cond
|
||||
(some? gradient)
|
||||
(do
|
||||
(types.fills.impl/write-gradient-fill offset dview opacity gradient)
|
||||
(h/call wasm/internal-module "_add_shape_stroke_fill"))
|
||||
(types.fills.impl/write-gradient-fill fill-offset dview opacity gradient)
|
||||
|
||||
(some? image)
|
||||
(let [image-id (get image :id)
|
||||
buffer (uuid/get-u32 image-id)
|
||||
cached-image? (h/call wasm/internal-module "_is_image_cached"
|
||||
(aget buffer 0) (aget buffer 1)
|
||||
(aget buffer 2) (aget buffer 3)
|
||||
thumbnail?)]
|
||||
(types.fills.impl/write-image-fill offset dview opacity image)
|
||||
(h/call wasm/internal-module "_add_shape_stroke_fill")
|
||||
(when (== cached-image? 0)
|
||||
(fetch-image shape-id image-id thumbnail?)))
|
||||
(types.fills.impl/write-image-fill fill-offset dview opacity image)
|
||||
|
||||
(some? color)
|
||||
(do
|
||||
(types.fills.impl/write-solid-fill offset dview opacity color)
|
||||
(h/call wasm/internal-module "_add_shape_stroke_fill")))))
|
||||
strokes))
|
||||
(types.fills.impl/write-solid-fill fill-offset dview opacity color))
|
||||
|
||||
(recur (inc i) (next remaining)))))
|
||||
|
||||
;; Single WASM call to set all strokes at once
|
||||
(h/call wasm/internal-module "_set_shape_strokes")
|
||||
|
||||
;; Check image cache and fetch uncached images
|
||||
(pending-stroke-images shape-id strokes thumbnail?))))
|
||||
|
||||
(defn set-shape-strokes-data
|
||||
"Write strokes data to WASM (no image fetching)."
|
||||
[strokes]
|
||||
(if (empty? strokes)
|
||||
(h/call wasm/internal-module "_clear_shape_strokes")
|
||||
(let [num-strokes (count strokes)
|
||||
buf-size (+ 4 (* num-strokes STROKE-ENTRY-U8-SIZE))
|
||||
base-ptr (mem/alloc buf-size)
|
||||
heap (mem/get-heap-u8)
|
||||
dview (js/DataView. (.-buffer heap))]
|
||||
|
||||
;; Header: stroke count at byte 0
|
||||
(buf/write-byte dview base-ptr num-strokes)
|
||||
|
||||
;; Write each stroke entry into the shared buffer
|
||||
(loop [i 0
|
||||
remaining (seq strokes)]
|
||||
(when remaining
|
||||
(let [stroke (first remaining)
|
||||
entry-offset (+ base-ptr 4 (* i STROKE-ENTRY-U8-SIZE))
|
||||
fill-offset (+ entry-offset 8)
|
||||
|
||||
opacity (or (:stroke-opacity stroke) 1.0)
|
||||
color (:stroke-color stroke)
|
||||
gradient (:stroke-color-gradient stroke)
|
||||
image (:stroke-image stroke)
|
||||
width (:stroke-width stroke)
|
||||
align (:stroke-alignment stroke)
|
||||
style (-> stroke :stroke-style sr/translate-stroke-style)
|
||||
cap-start (-> stroke :stroke-cap-start sr/translate-stroke-cap)
|
||||
cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap)
|
||||
kind (translate-stroke-kind align)]
|
||||
|
||||
;; Stroke header
|
||||
(buf/write-byte dview (+ entry-offset 0) kind)
|
||||
(buf/write-byte dview (+ entry-offset 1) style)
|
||||
(buf/write-byte dview (+ entry-offset 2) cap-start)
|
||||
(buf/write-byte dview (+ entry-offset 3) cap-end)
|
||||
(buf/write-float dview (+ entry-offset 4) width)
|
||||
|
||||
;; Fill data
|
||||
(cond
|
||||
(some? gradient)
|
||||
(types.fills.impl/write-gradient-fill fill-offset dview opacity gradient)
|
||||
|
||||
(some? image)
|
||||
(types.fills.impl/write-image-fill fill-offset dview opacity image)
|
||||
|
||||
(some? color)
|
||||
(types.fills.impl/write-solid-fill fill-offset dview opacity color))
|
||||
|
||||
(recur (inc i) (next remaining)))))
|
||||
|
||||
;; Single WASM call to set all strokes at once
|
||||
(h/call wasm/internal-module "_set_shape_strokes"))))
|
||||
|
||||
(defn set-shape-svg-attrs
|
||||
[attrs]
|
||||
@@ -863,29 +981,39 @@
|
||||
(when (ctl/grid-layout? shape)
|
||||
(set-grid-layout shape)))
|
||||
|
||||
;; Shadow binary layout constants (must match Rust RawShadowData):
|
||||
;; 24 bytes per shadow: color(u32) + blur(f32) + spread(f32) + x(f32) + y(f32) + style(u8) + hidden(u8) + padding(2)
|
||||
(def ^:const SHADOW-ENTRY-SIZE 24)
|
||||
(def ^:const SHADOW-HEADER-SIZE 4)
|
||||
|
||||
(defn set-shape-shadows
|
||||
[shadows]
|
||||
(h/call wasm/internal-module "_clear_shape_shadows")
|
||||
|
||||
(run! (fn [shadow]
|
||||
(let [color (get shadow :color)
|
||||
blur (get shadow :blur)
|
||||
(if (or (nil? shadows) (empty? shadows))
|
||||
(h/call wasm/internal-module "_clear_shape_shadows")
|
||||
(let [n (count shadows)
|
||||
size (+ SHADOW-HEADER-SIZE (* n SHADOW-ENTRY-SIZE))
|
||||
offset (mem/alloc size)
|
||||
heap (mem/get-heap-u8)
|
||||
dview (js/DataView. (.-buffer heap))]
|
||||
;; Header: shadow count in first byte
|
||||
(.setUint8 dview offset n)
|
||||
;; Write each shadow entry
|
||||
(loop [i 0, shadows (seq shadows)]
|
||||
(when shadows
|
||||
(let [shadow (first shadows)
|
||||
base (+ offset SHADOW-HEADER-SIZE (* i SHADOW-ENTRY-SIZE))
|
||||
color (get shadow :color)
|
||||
rgba (sr-clr/hex->u32argb (get color :color)
|
||||
(get color :opacity))
|
||||
hidden (get shadow :hidden)
|
||||
x (get shadow :offset-x)
|
||||
y (get shadow :offset-y)
|
||||
spread (get shadow :spread)
|
||||
style (get shadow :style)]
|
||||
(h/call wasm/internal-module "_add_shape_shadow"
|
||||
rgba
|
||||
blur
|
||||
spread
|
||||
x
|
||||
y
|
||||
(sr/translate-shadow-style style)
|
||||
hidden)))
|
||||
shadows))
|
||||
(get color :opacity))]
|
||||
(.setUint32 dview base rgba true)
|
||||
(.setFloat32 dview (+ base 4) (get shadow :blur) true)
|
||||
(.setFloat32 dview (+ base 8) (get shadow :spread) true)
|
||||
(.setFloat32 dview (+ base 12) (get shadow :offset-x) true)
|
||||
(.setFloat32 dview (+ base 16) (get shadow :offset-y) true)
|
||||
(.setUint8 dview (+ base 20) (sr/translate-shadow-style (get shadow :style)))
|
||||
(.setUint8 dview (+ base 21) (if (get shadow :hidden) 1 0))
|
||||
(recur (inc i) (next shadows)))))
|
||||
(h/call wasm/internal-module "_set_shape_shadows"))))
|
||||
|
||||
(defn fonts-from-text-content [content fallback-fonts-only?]
|
||||
(let [paragraph-set (first (get content :children))
|
||||
@@ -962,6 +1090,12 @@
|
||||
;; to prevent errors when navigating quickly
|
||||
(when wasm/context-initialized?
|
||||
(perf/begin-measure "render-finish")
|
||||
;; set_view_end clears fast mode, enters settling mode,
|
||||
;; rebuilds tiles and syncs the viewbox. The settling render
|
||||
;; uses reduced blur quality so visible tiles appear quickly.
|
||||
;; When all settling tiles finish, the Rust side automatically
|
||||
;; transitions to full quality (clears settling, invalidates
|
||||
;; caches, starts a new render loop) — no JS round-trip needed.
|
||||
(h/call wasm/internal-module "_set_view_end")
|
||||
(render ts)
|
||||
(perf/end-measure "render-finish")))]
|
||||
@@ -1043,15 +1177,21 @@
|
||||
(set-shape-layout shape)
|
||||
(set-layout-data shape)
|
||||
|
||||
(let [pending_thumbnails (into [] (concat
|
||||
;; Write fills & strokes data to WASM once (identical for both resolutions)
|
||||
(let [coerced-fills (set-shape-fills-data fills)
|
||||
_ (set-shape-strokes-data strokes)
|
||||
fill-image-ids (when coerced-fills (types.fills/get-image-ids coerced-fills))
|
||||
|
||||
;; Collect pending image fetches per resolution (only the image cache check differs)
|
||||
pending_thumbnails (into [] (concat
|
||||
(set-shape-text-content id content)
|
||||
(set-shape-text-images id content true)
|
||||
(set-shape-fills id fills true)
|
||||
(set-shape-strokes id strokes true)))
|
||||
(pending-fill-images id fill-image-ids true)
|
||||
(pending-stroke-images id strokes true)))
|
||||
pending_full (into [] (concat
|
||||
(set-shape-text-images id content false)
|
||||
(set-shape-fills id fills false)
|
||||
(set-shape-strokes id strokes false)))]
|
||||
(pending-fill-images id fill-image-ids false)
|
||||
(pending-stroke-images id strokes false)))]
|
||||
(perf/end-measure "set-object")
|
||||
{:thumbnails pending_thumbnails
|
||||
:full pending_full})))
|
||||
|
||||
@@ -36,10 +36,14 @@
|
||||
;; | 48 | 24 | transform | 6 × f32 LE (a,b,c,d,e,f) |
|
||||
;; | 72 | 16 | selrect | 4 × f32 LE (x1,y1,x2,y2) |
|
||||
;; | 88 | 16 | corners | 4 × f32 LE (r1,r2,r3,r4) |
|
||||
;; | 104 | 1 | blur_hidden | u8 (0 = visible, !0 = hidden) |
|
||||
;; | 105 | 1 | blur_type | u8 (0 = layer-blur) |
|
||||
;; | 106 | 2 | blur_pad | - |
|
||||
;; | 108 | 4 | blur_value | f32 LE (0.0 = no blur) |
|
||||
;; |--------|------|--------------|-----------------------------------|
|
||||
;; | Total | 104 | | |
|
||||
;; | Total | 112 | | |
|
||||
|
||||
(def ^:const BASE-PROPS-SIZE 104)
|
||||
(def ^:const BASE-PROPS-SIZE 112)
|
||||
(def ^:const FLAG-CLIP-CONTENT 0x01)
|
||||
(def ^:const FLAG-HIDDEN 0x02)
|
||||
(def ^:const CONSTRAINT-NONE 0xFF)
|
||||
@@ -188,6 +192,18 @@
|
||||
(.setFloat32 dview (+ offset 96) r3 true)
|
||||
(.setFloat32 dview (+ offset 100) r4 true)
|
||||
|
||||
;; Write blur fields (offset 104..111)
|
||||
;; Layout in Rust: u8 hidden, u8 blur_type, 2 bytes padding, f32 value
|
||||
(let [blur (get shape :blur)]
|
||||
(when (some? blur)
|
||||
(let [bt (-> blur :type sr/translate-blur-type)
|
||||
hidden (if (:hidden blur) 1 0)
|
||||
value (d/nilv (:value blur) 0.0)]
|
||||
(.setUint8 dview (+ offset 104) hidden)
|
||||
(.setUint8 dview (+ offset 105) bt)
|
||||
;; padding bytes at 106,107 left as zero
|
||||
(.setFloat32 dview (+ offset 108) value true))))
|
||||
|
||||
(h/call wasm/internal-module "_set_shape_base_props")
|
||||
|
||||
nil)))
|
||||
|
||||
@@ -230,6 +230,12 @@
|
||||
|
||||
(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
|
||||
"Extract the files from dom node."
|
||||
[^js node]
|
||||
@@ -470,7 +476,7 @@
|
||||
(when (some? node)
|
||||
(.focus node)))
|
||||
|
||||
(defn click
|
||||
(defn click!
|
||||
[^js node]
|
||||
(when (some? node)
|
||||
(.click node)))
|
||||
@@ -742,11 +748,7 @@
|
||||
|
||||
(defn trigger-download
|
||||
[filename blob]
|
||||
(let [uri (wapi/create-uri blob)]
|
||||
(try
|
||||
(trigger-download-uri filename (.-type ^js blob) uri)
|
||||
(finally
|
||||
(wapi/revoke-uri uri)))))
|
||||
(trigger-download-uri filename (.-type ^js blob) (wapi/create-uri blob)))
|
||||
|
||||
(defn event
|
||||
"Create an instance of DOM Event"
|
||||
|
||||
@@ -190,11 +190,6 @@
|
||||
[{:keys [status]}]
|
||||
(<= 400 status 499))
|
||||
|
||||
(defn blob?
|
||||
[^js v]
|
||||
(when (some? v)
|
||||
(instance? js/Blob v)))
|
||||
|
||||
(defn as-promise
|
||||
[observable]
|
||||
(p/create
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
(:refer-clojure :exclude [set! new get merge clone contains? array? into-array reify class])
|
||||
#?(:cljs (:require-macros [app.util.object]))
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.json :as json]
|
||||
[app.common.schema :as sm]
|
||||
[clojure.core :as c]
|
||||
@@ -157,7 +156,6 @@
|
||||
|
||||
this-sym (with-meta (gensym (str rsym "-this-")) {:tag 'js})
|
||||
target-sym (with-meta (gensym (str rsym "-target-")) {:tag 'js})
|
||||
cause-sym (gensym "cause-")
|
||||
|
||||
make-sym
|
||||
(fn [pname prefix]
|
||||
@@ -178,7 +176,6 @@
|
||||
wrap (c/get params :wrap)
|
||||
schema-1 (c/get params :schema-1)
|
||||
this? (c/get params :this false)
|
||||
on-error (c/get params :on-error)
|
||||
|
||||
decode-expr
|
||||
(c/get params :decode/fn)
|
||||
@@ -217,16 +214,7 @@
|
||||
(with-meta {:tag 'function}))
|
||||
|
||||
val-sym
|
||||
(gensym (str "val-" (str/slug pname) "-"))
|
||||
|
||||
wrap-error-handling
|
||||
(if on-error
|
||||
(fn [expr]
|
||||
`(try
|
||||
~expr
|
||||
(catch :default ~cause-sym
|
||||
(~on-error ~cause-sym))))
|
||||
identity)]
|
||||
(gensym (str "val-" (str/slug pname) "-"))]
|
||||
|
||||
(concat
|
||||
(when wrap
|
||||
@@ -238,13 +226,8 @@
|
||||
`(fn []
|
||||
(let [~this-sym (~'js* "this")
|
||||
~fn-sym ~get-expr]
|
||||
~(wrap-error-handling
|
||||
`(.call ~fn-sym ~this-sym ~this-sym))))
|
||||
`(fn []
|
||||
(let [~this-sym (~'js* "this")
|
||||
~fn-sym ~get-expr]
|
||||
~(wrap-error-handling
|
||||
`(.call ~fn-sym ~this-sym)))))])
|
||||
(.call ~fn-sym ~this-sym ~this-sym)))
|
||||
get-expr)])
|
||||
|
||||
(when set-expr
|
||||
[schema-sym schema-n
|
||||
@@ -258,35 +241,28 @@
|
||||
|
||||
(make-sym pname "set-fn")
|
||||
`(fn [~val-sym]
|
||||
~(wrap-error-handling
|
||||
`(let [~this-sym (~'js* "this")
|
||||
~fn-sym ~set-expr
|
||||
(let [~this-sym (~'js* "this")
|
||||
~fn-sym ~set-expr
|
||||
|
||||
;; We only emit schema and coercer bindings if
|
||||
;; schema-n is provided
|
||||
~@(if (some? schema-n)
|
||||
[schema-sym
|
||||
`(if (fn? ~schema-sym)
|
||||
(~schema-sym ~val-sym)
|
||||
~schema-sym)
|
||||
;; We only emit schema and coercer bindings if
|
||||
;; schema-n is provided
|
||||
~@(if (some? schema-n)
|
||||
[schema-sym `(if (fn? ~schema-sym)
|
||||
(~schema-sym ~val-sym)
|
||||
~schema-sym)
|
||||
|
||||
coercer-sym
|
||||
`(if (nil? ~coercer-sym)
|
||||
(sm/coercer ~schema-sym)
|
||||
~coercer-sym)
|
||||
coercer-sym `(if (nil? ~coercer-sym)
|
||||
(sm/coercer ~schema-sym)
|
||||
~coercer-sym)
|
||||
val-sym (if (not= decode-expr 'app.common.json/->clj)
|
||||
`(~decode-sym ~val-sym)
|
||||
`(~decode-sym ~val-sym ~decode-options))
|
||||
val-sym `(~coercer-sym ~val-sym)]
|
||||
[])]
|
||||
|
||||
val-sym
|
||||
(if (not= decode-expr 'app.common.json/->clj)
|
||||
`(~decode-sym ~val-sym)
|
||||
`(~decode-sym ~val-sym ~decode-options))
|
||||
|
||||
val-sym
|
||||
`(~coercer-sym ~val-sym)]
|
||||
[])]
|
||||
|
||||
~(if this?
|
||||
`(.call ~fn-sym ~this-sym ~this-sym ~val-sym)
|
||||
`(.call ~fn-sym ~this-sym ~val-sym)))))])
|
||||
~(if this?
|
||||
`(.call ~fn-sym ~this-sym ~this-sym ~val-sym)
|
||||
`(.call ~fn-sym ~this-sym ~val-sym))))])
|
||||
|
||||
(when fn-expr
|
||||
[schema-sym (or schema-n schema-1)
|
||||
@@ -299,12 +275,7 @@
|
||||
(make-sym pname "get-fn")
|
||||
`(fn []
|
||||
(let [~this-sym (~'js* "this")
|
||||
~fn-sym ~(if (and (list? fn-expr)
|
||||
(= 'fn (first fn-expr)))
|
||||
(let [[sa sb & sother] fn-expr]
|
||||
`(~sa ~sb ~(wrap-error-handling `(do ~@sother))))
|
||||
fn-expr)
|
||||
|
||||
~fn-sym ~fn-expr
|
||||
~fn-sym ~(if this?
|
||||
`(.bind ~fn-sym ~this-sym ~this-sym)
|
||||
`(.bind ~fn-sym ~this-sym))
|
||||
@@ -313,31 +284,25 @@
|
||||
;; schema-n or schema-1 is provided
|
||||
~@(if (or schema-n schema-1)
|
||||
[fn-sym `(fn* [~@(if schema-1 [val-sym] [])]
|
||||
~(wrap-error-handling
|
||||
`(let [~@(if schema-n
|
||||
[val-sym `(into-array (cljs.core/js-arguments))]
|
||||
[])
|
||||
~val-sym
|
||||
~(if (not= decode-expr 'app.common.json/->clj)
|
||||
`(~decode-sym ~val-sym)
|
||||
`(~decode-sym ~val-sym ~decode-options))
|
||||
(let [~@(if schema-n
|
||||
[val-sym `(into-array (cljs.core/js-arguments))]
|
||||
[])
|
||||
~val-sym ~(if (not= decode-expr 'app.common.json/->clj)
|
||||
`(~decode-sym ~val-sym)
|
||||
`(~decode-sym ~val-sym ~decode-options))
|
||||
|
||||
~schema-sym
|
||||
(if (fn? ~schema-sym)
|
||||
(~schema-sym ~val-sym)
|
||||
~schema-sym)
|
||||
~schema-sym (if (fn? ~schema-sym)
|
||||
(~schema-sym ~val-sym)
|
||||
~schema-sym)
|
||||
|
||||
~coercer-sym
|
||||
(if (nil? ~coercer-sym)
|
||||
(sm/coercer ~schema-sym)
|
||||
~coercer-sym)
|
||||
~coercer-sym (if (nil? ~coercer-sym)
|
||||
(sm/coercer ~schema-sym)
|
||||
~coercer-sym)
|
||||
|
||||
~val-sym
|
||||
(~coercer-sym ~val-sym)]
|
||||
|
||||
~(if schema-1
|
||||
`(~fn-sym ~val-sym)
|
||||
`(apply ~fn-sym ~val-sym)))))]
|
||||
~val-sym (~coercer-sym ~val-sym)]
|
||||
~(if schema-1
|
||||
`(~fn-sym ~val-sym)
|
||||
`(apply ~fn-sym ~val-sym))))]
|
||||
[])]
|
||||
~(if wrap
|
||||
`(~wrap-sym ~fn-sym)
|
||||
@@ -410,16 +375,12 @@
|
||||
(let [definition (first params)]
|
||||
(if (some? definition)
|
||||
(let [definition (if (map? definition)
|
||||
(c/merge {:wrap (:wrap tmeta)
|
||||
:on-error (:on-error tmeta)}
|
||||
definition)
|
||||
(c/merge {:wrap (:wrap tmeta)} definition)
|
||||
(-> {:enumerable false}
|
||||
(c/merge (meta definition))
|
||||
(assoc :wrap (:wrap tmeta))
|
||||
(assoc :on-error (:on-error tmeta))
|
||||
(assoc :fn definition)
|
||||
(dissoc :get :set :line :column)
|
||||
(d/without-nils)))
|
||||
(dissoc :get :set)))
|
||||
definition (assoc definition :name (name ckey))]
|
||||
|
||||
(recur (rest params)
|
||||
@@ -464,13 +425,6 @@
|
||||
(let [o (get o type-symbol)]
|
||||
(= o t))))
|
||||
|
||||
#?(:cljs
|
||||
(def Proxy
|
||||
(app.util.object/class
|
||||
:name "Proxy"
|
||||
:extends js/Object
|
||||
:constructor (constantly nil))))
|
||||
|
||||
(defmacro reify
|
||||
"A domain specific variation of reify that creates anonymous objects
|
||||
on demand with the ability to assign protocol implementations and
|
||||
@@ -488,7 +442,7 @@
|
||||
obj-sym
|
||||
(gensym "obj-")]
|
||||
|
||||
`(let [~obj-sym (new Proxy)
|
||||
`(let [~obj-sym (cljs.core/js-obj)
|
||||
~f-sym (fn [] ~type-name)]
|
||||
(add-properties! ~obj-sym
|
||||
{:name ~'js/Symbol.toStringTag
|
||||
|
||||
@@ -642,13 +642,15 @@ export class SelectionController extends EventTarget {
|
||||
} else {
|
||||
this.#anchorNode = anchorNode;
|
||||
this.#anchorOffset = anchorOffset;
|
||||
if (anchorNode === focusNode) {
|
||||
this.#focusNode = this.#anchorNode;
|
||||
this.#focusOffset = this.#anchorOffset;
|
||||
this.#focusNode = focusNode;
|
||||
this.#focusOffset = focusOffset;
|
||||
// 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);
|
||||
} else {
|
||||
this.#focusNode = focusNode;
|
||||
this.#focusOffset = focusOffset;
|
||||
this.#selection.setBaseAndExtent(
|
||||
anchorNode,
|
||||
anchorOffset,
|
||||
|
||||
@@ -338,6 +338,77 @@ msgstr "You're going to restore %s."
|
||||
msgid "dashboard-restore-file-confirmation.title"
|
||||
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
|
||||
msgid "dashboard.add-file"
|
||||
msgstr "Add file"
|
||||
@@ -360,7 +431,7 @@ msgstr "(copy)"
|
||||
|
||||
#: src/app/main/ui/dashboard/sidebar.cljs:347
|
||||
msgid "dashboard.create-new-org"
|
||||
msgstr "+ Create org"
|
||||
msgstr "Create new org"
|
||||
|
||||
#: src/app/main/ui/dashboard/sidebar.cljs:340
|
||||
msgid "dashboard.create-new-team"
|
||||
@@ -2063,209 +2134,6 @@ msgstr "Resolved value:"
|
||||
msgid "inspect.tabs.styles.variants-panel"
|
||||
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 keys"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:381
|
||||
msgid "integrations.mcp-server.mcp-keys.title"
|
||||
msgstr "MCP keys"
|
||||
|
||||
#: 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
|
||||
msgid "label.mark-all-as-read"
|
||||
msgstr "Mark all as read"
|
||||
@@ -2486,9 +2354,6 @@ msgstr "Discard"
|
||||
msgid "labels.download"
|
||||
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
|
||||
msgid "labels.drafts"
|
||||
msgstr "Drafts"
|
||||
@@ -2620,10 +2485,6 @@ msgstr "Info"
|
||||
msgid "labels.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
|
||||
msgid "labels.internal-error.desc-message-first"
|
||||
msgstr "Something bad happened."
|
||||
@@ -3283,6 +3144,30 @@ msgstr "Change email"
|
||||
msgid "modals.change-email.title"
|
||||
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
|
||||
msgid "modals.create-webhook.submit-label"
|
||||
msgstr "Create webhook"
|
||||
@@ -3299,6 +3184,18 @@ msgstr "Payload URL"
|
||||
msgid "modals.create-webhook.url.placeholder"
|
||||
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
|
||||
msgid "modals.delete-account.cancel"
|
||||
msgstr "Cancel and keep my account"
|
||||
@@ -5192,14 +5089,14 @@ msgstr "Shared Libraries - %s - Penpot"
|
||||
msgid "title.default"
|
||||
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
|
||||
msgid "title.settings.feedback"
|
||||
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
|
||||
msgid "title.settings.notifications"
|
||||
msgstr "Notifications - Penpot"
|
||||
@@ -7700,18 +7597,6 @@ msgstr "Paste"
|
||||
msgid "workspace.shape.menu.paste-props"
|
||||
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
|
||||
msgid "workspace.shape.menu.path"
|
||||
msgstr "Path"
|
||||
|
||||
@@ -347,6 +347,77 @@ msgstr "Vas a restaurar %s."
|
||||
msgid "dashboard-restore-file-confirmation.title"
|
||||
msgstr "Restaurar archivo"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:103
|
||||
msgid "dashboard.access-tokens.copied-success"
|
||||
msgstr "Token copiado"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:189
|
||||
msgid "dashboard.access-tokens.create"
|
||||
msgstr "Generar nuevo token"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:64
|
||||
msgid "dashboard.access-tokens.create.success"
|
||||
msgstr "Access token creado con éxito."
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:286
|
||||
msgid "dashboard.access-tokens.empty.add-one"
|
||||
msgstr "Pulsa el botón \"Generar nuevo token\" para generar uno."
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:285
|
||||
msgid "dashboard.access-tokens.empty.no-access-tokens"
|
||||
msgstr "Todavía no tienes ningún token."
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:135
|
||||
msgid "dashboard.access-tokens.expiration-180-days"
|
||||
msgstr "180 días"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:132
|
||||
msgid "dashboard.access-tokens.expiration-30-days"
|
||||
msgstr "30 días"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:133
|
||||
msgid "dashboard.access-tokens.expiration-60-days"
|
||||
msgstr "60 días"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:134
|
||||
msgid "dashboard.access-tokens.expiration-90-days"
|
||||
msgstr "90 días"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:131
|
||||
msgid "dashboard.access-tokens.expiration-never"
|
||||
msgstr "Nunca"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:268
|
||||
msgid "dashboard.access-tokens.expired-on"
|
||||
msgstr "Expiró el %s"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:269
|
||||
msgid "dashboard.access-tokens.expires-on"
|
||||
msgstr "Expira el %s"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:267
|
||||
msgid "dashboard.access-tokens.no-expiration"
|
||||
msgstr "Sin fecha de expiración"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:184
|
||||
msgid "dashboard.access-tokens.personal"
|
||||
msgstr "Access tokens personales"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:185
|
||||
msgid "dashboard.access-tokens.personal.description"
|
||||
msgstr ""
|
||||
"Los access tokens personales funcionan como una alternativa a nuestro "
|
||||
"sistema de autenticación usuario/password y se pueden usar para permitir a "
|
||||
"otras aplicaciones acceso a la API interna de Penpot"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:142
|
||||
msgid "dashboard.access-tokens.token-will-expire"
|
||||
msgstr "El token expirará el %s"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:143
|
||||
msgid "dashboard.access-tokens.token-will-not-expire"
|
||||
msgstr "El token no tiene fecha de expiración"
|
||||
|
||||
#: src/app/main/ui/dashboard/placeholder.cljs:41
|
||||
msgid "dashboard.add-file"
|
||||
msgstr "Añadir archivo"
|
||||
@@ -369,7 +440,7 @@ msgstr "(copia)"
|
||||
|
||||
#: src/app/main/ui/dashboard/sidebar.cljs:347
|
||||
msgid "dashboard.create-new-org"
|
||||
msgstr "+ Crear org"
|
||||
msgstr "Crear nueva organización"
|
||||
|
||||
#: src/app/main/ui/dashboard/sidebar.cljs:340
|
||||
msgid "dashboard.create-new-team"
|
||||
@@ -2034,209 +2105,6 @@ msgstr "Valor resuelto:"
|
||||
msgid "inspect.tabs.styles.variants-panel"
|
||||
msgstr "Propiedades de las variantes"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:189
|
||||
msgid "integrations.access-tokens.create"
|
||||
msgstr "Crear nuevo token de acceso"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:286
|
||||
msgid "integrations.access-tokens.empty.add-one"
|
||||
msgstr "Pulsa el botón \"Crear nuevo token de accesso\" para generar uno."
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:285
|
||||
msgid "integrations.access-tokens.empty.no-access-tokens"
|
||||
msgstr "Todavía no tienes ningún token."
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:184
|
||||
msgid "integrations.access-tokens.personal"
|
||||
msgstr "Tokens de acceso personales"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:185
|
||||
msgid "integrations.access-tokens.personal.description"
|
||||
msgstr ""
|
||||
"Los tokens de accesso personales funcionan como una alternativa a nuestro "
|
||||
"sistema de autenticación usuario/password y se pueden usar para permitir a "
|
||||
"otras aplicaciones acceso a la API interna de Penpot"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:152, src/app/main/ui/settings/integrations.cljs:158
|
||||
msgid "integrations.copy-token"
|
||||
msgstr "Copiar token"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:432
|
||||
msgid "integrations.create-access-token.title"
|
||||
msgstr "Crear token de accesso"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:433
|
||||
msgid "integrations.create-access-token.title.created"
|
||||
msgstr "Token de acceso creado"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:290
|
||||
msgid "integrations.create-mcp-key.title"
|
||||
msgstr "Crear nueva clave MCP"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:291
|
||||
msgid "integrations.create-mcp-key.title.created"
|
||||
msgstr "Clave MCP creada"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:257
|
||||
msgid "integrations.delete-token.accept"
|
||||
msgstr "Borrar token"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:256
|
||||
msgid "integrations.delete-token.message"
|
||||
msgstr "¿Seguro que deseas borrar este token?"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:255
|
||||
msgid "integrations.delete-token.title"
|
||||
msgstr "Borrar token"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:135
|
||||
msgid "integrations.expiration-180-days"
|
||||
msgstr "180 días"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:132
|
||||
msgid "integrations.expiration-30-days"
|
||||
msgstr "30 días"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:133
|
||||
msgid "integrations.expiration-60-days"
|
||||
msgstr "60 días"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:134
|
||||
msgid "integrations.expiration-90-days"
|
||||
msgstr "90 días"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:131
|
||||
msgid "integrations.expiration-never"
|
||||
msgstr "Nunca"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:268
|
||||
msgid "integrations.expired-on"
|
||||
msgstr "Expiró el %s"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:269
|
||||
msgid "integrations.expires-on"
|
||||
msgstr "Expira el %s"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:267
|
||||
msgid "integrations.no-expiration"
|
||||
msgstr "Sin fecha de expiración"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:130
|
||||
msgid "integrations.expiration-date.label"
|
||||
msgstr "Fecha de expiración"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:131
|
||||
msgid "integrations.info.non-recuperable"
|
||||
msgstr "Esta clave única no es recuperable. Si la pierdes, tendrás que crear una nueva."
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:336
|
||||
msgid "integrations.mcp-server.title"
|
||||
msgstr "Servidor MCP"
|
||||
|
||||
#: 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 "El servidor MCP de Penpot permite que los clientes MCP interactúen directamente con los archivos de diseño de Penpot."
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:353
|
||||
msgid "integrations.mcp-server.status"
|
||||
msgstr "Estado"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:370
|
||||
msgid "integrations.mcp-server.status.enabled"
|
||||
msgstr "Habilitado"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:370
|
||||
msgid "integrations.mcp-server.status.disabled"
|
||||
msgstr "Deshabilitado"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:363
|
||||
msgid "integrations.mcp-server.status.expired.0"
|
||||
msgstr "La clave MCP utilizada para conectarse al servidor MCP ha expirado. Como resultado, no se puede establecer la conexión."
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:368
|
||||
msgid "integrations.mcp-server.status.expired.1"
|
||||
msgstr "Por favor, regenera la clave MCP y actualiza la configuración de tu cliente con la nueva clave."
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:415
|
||||
msgid "integrations.mcp-server.mcp-keys.copy"
|
||||
msgstr "Copiar enlace"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:422
|
||||
msgid "integrations.mcp-server.mcp-keys.help"
|
||||
msgstr "Cómo configurar clientes MCP"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:405
|
||||
msgid "integrations.mcp-server.mcp-keys.info"
|
||||
msgstr "Esta es la URL del servidor que necesitarás configurar en tu cliente MCP para conectarlo al servidor MCP de Penpot."
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:387
|
||||
msgid "integrations.mcp-server.mcp-keys.regenerate"
|
||||
msgstr "Regenerar clave MCP"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:381
|
||||
msgid "integrations.mcp-server.mcp-keys.title"
|
||||
msgstr "Claves MCP"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:388
|
||||
msgid "integrations.mcp-server.mcp-keys.tootip"
|
||||
msgstr "La clave MCP es necesaria para la configuración del cliente MCP"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:124
|
||||
msgid "integrations.name.label"
|
||||
msgstr "Nombre"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:126
|
||||
msgid "integrations.name.placeholder"
|
||||
msgstr "El nombre te pude ayudar a saber para qué se utiliza el token"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:103
|
||||
msgid "integrations.notification.success.copied"
|
||||
msgstr "Token copiado"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:64
|
||||
msgid "integrations.notification.success.created"
|
||||
msgstr "Token creado con éxito"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:327
|
||||
msgid "integrations.notification.success.copied-link"
|
||||
msgstr "Enlace copiado al portapapeles"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:293
|
||||
msgid "integrations.notification.success.mcp-server-disabled"
|
||||
msgstr "Servidor MCP deshabilitado"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:299
|
||||
msgid "integrations.notification.success.mcp-server-enabled"
|
||||
msgstr "Servidor MCP habilitado"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:317
|
||||
msgid "integrations.regenerate-mcp-key.info"
|
||||
msgstr "Regenerar la clave revocará inmediatamente la actual. Cualquier aplicación que la esté utilizando dejará de funcionar."
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:317
|
||||
msgid "integrations.regenerate-mcp-key.title"
|
||||
msgstr "Regenerar clave MCP"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:318
|
||||
msgid "integrations.regenerate-mcp-key.title.created"
|
||||
msgstr "Clave MCP regenerada"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:480
|
||||
msgid "integrations.title"
|
||||
msgstr "Integraciones"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:142
|
||||
msgid "integrations.token-will-expire"
|
||||
msgstr "El token expirará el %s"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:143
|
||||
msgid "integrations.token-will-not-expire"
|
||||
msgstr "El token no tiene fecha de expiración"
|
||||
|
||||
#: src/app/main/ui/dashboard/comments.cljs:96
|
||||
msgid "label.mark-all-as-read"
|
||||
msgstr "Marcar todo como leído"
|
||||
@@ -2457,9 +2325,6 @@ msgstr "Descartar"
|
||||
msgid "labels.download"
|
||||
msgstr "Descargar %s"
|
||||
|
||||
msgid "labels.download-simple"
|
||||
msgstr "Descargar"
|
||||
|
||||
#: 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"
|
||||
msgstr "Borradores"
|
||||
@@ -2591,10 +2456,6 @@ msgstr "Información"
|
||||
msgid "labels.installed-fonts"
|
||||
msgstr "Fuentes instaladas"
|
||||
|
||||
#: src/app/main/ui/settings/sidebar.cljs:123
|
||||
msgid "labels.integrations"
|
||||
msgstr "Integraciones"
|
||||
|
||||
#: src/app/main/ui/static.cljs:405
|
||||
msgid "labels.internal-error.desc-message-first"
|
||||
msgstr "Ha ocurrido algo extraño."
|
||||
@@ -3250,6 +3111,30 @@ msgstr "Cambiar correo"
|
||||
msgid "modals.change-email.title"
|
||||
msgstr "Cambiar tu correo"
|
||||
|
||||
#: 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 "Copiar token"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:130
|
||||
msgid "modals.create-access-token.expiration-date.label"
|
||||
msgstr "Fecha de expiración"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:124
|
||||
msgid "modals.create-access-token.name.label"
|
||||
msgstr "Nombre"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:126
|
||||
msgid "modals.create-access-token.name.placeholder"
|
||||
msgstr "El nombre te pude ayudar a saber para qué se utiliza el token"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:178
|
||||
msgid "modals.create-access-token.submit-label"
|
||||
msgstr "Crear token"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:111
|
||||
msgid "modals.create-access-token.title"
|
||||
msgstr "Generar access token"
|
||||
|
||||
#: src/app/main/ui/dashboard/team.cljs:1127
|
||||
msgid "modals.create-webhook.submit-label"
|
||||
msgstr "Crear webhook"
|
||||
@@ -3266,6 +3151,18 @@ msgstr "Payload URL"
|
||||
msgid "modals.create-webhook.url.placeholder"
|
||||
msgstr "https://example.com/postreceive"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:257
|
||||
msgid "modals.delete-acces-token.accept"
|
||||
msgstr "Borrar token"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:256
|
||||
msgid "modals.delete-acces-token.message"
|
||||
msgstr "¿Seguro que deseas borrar este token?"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:255
|
||||
msgid "modals.delete-acces-token.title"
|
||||
msgstr "Borrar token"
|
||||
|
||||
#: src/app/main/ui/settings/delete_account.cljs:56
|
||||
msgid "modals.delete-account.cancel"
|
||||
msgstr "Cancelar y mantener mi cuenta"
|
||||
@@ -5171,14 +5068,14 @@ msgstr "Bibliotecas Compartidas - %s - Penpot"
|
||||
msgid "title.default"
|
||||
msgstr "Penpot - Diseño Libre para Equipos"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:278
|
||||
msgid "title.settings.access-tokens"
|
||||
msgstr "Perfil - Access tokens"
|
||||
|
||||
#: src/app/main/ui/settings/feedback.cljs:161
|
||||
msgid "title.settings.feedback"
|
||||
msgstr "Danos tu opinión - Penpot"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:278
|
||||
msgid "title.settings.integrations"
|
||||
msgstr "Integraciones - Penpot"
|
||||
|
||||
#: src/app/main/ui/settings/notifications.cljs:45
|
||||
msgid "title.settings.notifications"
|
||||
msgstr "Notificaciones - Penpot"
|
||||
@@ -7644,18 +7541,6 @@ msgstr "Pegar"
|
||||
msgid "workspace.shape.menu.paste-props"
|
||||
msgstr "Pegar propiedades"
|
||||
|
||||
msgid "workspace.shape.menu.copy-as-image"
|
||||
msgstr "Copiar como imagen"
|
||||
|
||||
msgid "workspace.clipboard.copying"
|
||||
msgstr "Copiando imagen…"
|
||||
|
||||
msgid "workspace.clipboard.image-copied"
|
||||
msgstr "Imagen copiada al portapapeles"
|
||||
|
||||
msgid "workspace.clipboard.image-copy-failed"
|
||||
msgstr "Error al copiar la imagen"
|
||||
|
||||
#: src/app/main/ui/workspace/context_menu.cljs:443
|
||||
msgid "workspace.shape.menu.path"
|
||||
msgstr "Ruta"
|
||||
|
||||
@@ -263,6 +263,9 @@ pub extern "C" fn set_view_start() {
|
||||
}
|
||||
performance::begin_measure!("set_view_start");
|
||||
state.render_state.options.set_fast_mode(true);
|
||||
// Clear settling mode if a new pan/zoom starts before the
|
||||
// settling→full quality transition completes.
|
||||
state.render_state.options.set_settling_mode(false);
|
||||
performance::end_measure!("set_view_start");
|
||||
});
|
||||
}
|
||||
@@ -273,6 +276,10 @@ pub extern "C" fn set_view_end() {
|
||||
let _end_start = performance::begin_timed_log!("set_view_end");
|
||||
performance::begin_measure!("set_view_end");
|
||||
state.render_state.options.set_fast_mode(false);
|
||||
// Enter settling mode: the first render pass will use reduced blur
|
||||
// quality so visible tiles appear quickly. The frontend should call
|
||||
// `settle_view_end` after this render to schedule a full-quality pass.
|
||||
state.render_state.options.set_settling_mode(true);
|
||||
state.render_state.cancel_animation_frame();
|
||||
|
||||
// Update tile_viewbox first so that get_tiles_for_shape uses the correct interest area
|
||||
@@ -306,6 +313,24 @@ pub extern "C" fn set_view_end() {
|
||||
});
|
||||
}
|
||||
|
||||
/// Called by the frontend after the settling render pass completes.
|
||||
/// Turns off settling mode, invalidates all tile caches so that the
|
||||
/// next render produces full-quality output.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn settle_view_end() {
|
||||
with_state_mut!(state, {
|
||||
if state.render_state.options.is_settling_mode() {
|
||||
performance::begin_measure!("settle_view_end");
|
||||
state.render_state.options.set_settling_mode(false);
|
||||
// Soft-invalidate cached tiles so the next render pass
|
||||
// re-draws them at full quality while keeping settling
|
||||
// textures as visual fallback to avoid popping.
|
||||
state.render_state.surfaces.soft_clear_tiles();
|
||||
performance::end_measure!("settle_view_end");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn clear_focus_mode() {
|
||||
with_state_mut!(state, {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
pub const DEBUG_VISIBLE: u32 = 0x01;
|
||||
pub const PROFILE_REBUILD_TILES: u32 = 0x02;
|
||||
pub const FAST_MODE: u32 = 0x04;
|
||||
/// Settling mode: transitional state between fast mode and full quality.
|
||||
/// Renders blur at reduced quality for a fast first pass after pan/zoom ends,
|
||||
/// then a full-quality re-render is scheduled automatically.
|
||||
pub const SETTLING_MODE: u32 = 0x08;
|
||||
|
||||
@@ -272,6 +272,10 @@ pub(crate) struct RenderState {
|
||||
pub render_request_id: Option<i32>,
|
||||
// Indicates whether the rendering process has pending frames.
|
||||
pub render_in_progress: bool,
|
||||
/// When true, visible tiles are allowed to yield across frames (progressive
|
||||
/// rendering). This is set when the Target surface has been primed with
|
||||
/// cached content so that un-rendered tiles still show something.
|
||||
progressive_render: bool,
|
||||
// Stack of nodes pending to be rendered.
|
||||
pending_nodes: Vec<NodeRenderState>,
|
||||
pub current_tile: Option<tiles::Tile>,
|
||||
@@ -299,6 +303,12 @@ pub(crate) struct RenderState {
|
||||
pub preview_mode: bool,
|
||||
}
|
||||
|
||||
/// Maximum cache surface dimension in pixels.
|
||||
/// Prevents GPU memory exhaustion (and WebGL context loss) at high zoom
|
||||
/// levels where the tile grid can grow very large.
|
||||
/// 8192 × 8192 × 4 bytes ≈ 256 MB, which is safe for most GPUs.
|
||||
const MAX_CACHE_DIMENSION: i32 = 8192;
|
||||
|
||||
pub fn get_cache_size(viewbox: Viewbox, scale: f32) -> skia::ISize {
|
||||
// First we retrieve the extended area of the viewport that we could render.
|
||||
let TileRect(isx, isy, iex, iey) = tiles::get_tiles_for_viewbox_with_interest(
|
||||
@@ -311,11 +321,9 @@ pub fn get_cache_size(viewbox: Viewbox, scale: f32) -> skia::ISize {
|
||||
let dy = if isy.signum() != iey.signum() { 1 } else { 0 };
|
||||
|
||||
let tile_size = tiles::TILE_SIZE;
|
||||
(
|
||||
((iex - isx).abs() + dx) * tile_size as i32,
|
||||
((iey - isy).abs() + dy) * tile_size as i32,
|
||||
)
|
||||
.into()
|
||||
let w = (((iex - isx).abs() + dx) * tile_size as i32).min(MAX_CACHE_DIMENSION);
|
||||
let h = (((iey - isy).abs() + dy) * tile_size as i32).min(MAX_CACHE_DIMENSION);
|
||||
(w, h).into()
|
||||
}
|
||||
|
||||
impl RenderState {
|
||||
@@ -350,6 +358,7 @@ impl RenderState {
|
||||
background_color: skia::Color::TRANSPARENT,
|
||||
render_request_id: None,
|
||||
render_in_progress: false,
|
||||
progressive_render: false,
|
||||
pending_nodes: vec![],
|
||||
current_tile: None,
|
||||
sampling_options,
|
||||
@@ -664,16 +673,20 @@ impl RenderState {
|
||||
.nested_fills
|
||||
.last()
|
||||
.is_some_and(|fills| !fills.is_empty());
|
||||
let has_inherited_blur = !self.ignore_nested_blurs
|
||||
let has_inherited_blur = !fast_mode
|
||||
&& !self.ignore_nested_blurs
|
||||
&& self.nested_blurs.iter().flatten().any(|blur| {
|
||||
!blur.hidden && blur.blur_type == BlurType::LayerBlur && blur.value > 0.0
|
||||
});
|
||||
// In fast mode blur is stripped from shapes, so treat blur as absent
|
||||
// for the direct-render eligibility check.
|
||||
let effective_blur_none = shape.blur.is_none() || fast_mode;
|
||||
let can_render_directly = apply_to_current_surface
|
||||
&& clip_bounds.is_none()
|
||||
&& offset.is_none()
|
||||
&& parent_shadows.is_none()
|
||||
&& !shape.needs_layer()
|
||||
&& shape.blur.is_none()
|
||||
&& effective_blur_none
|
||||
&& !has_inherited_blur
|
||||
&& shape.shadows.is_empty()
|
||||
&& shape.transform.is_identity()
|
||||
@@ -784,7 +797,29 @@ impl RenderState {
|
||||
shape.to_mut().set_blur(None);
|
||||
}
|
||||
if fast_mode {
|
||||
shape.to_mut().set_blur(None);
|
||||
// In fast mode (pan/zoom), strip blur entirely for individual shapes.
|
||||
// Each blur requires an expensive offscreen filter-surface pass
|
||||
// (clear → render → composite), so with many blurred shapes (e.g. 100)
|
||||
// even clamped blur values cause significant thread blocking.
|
||||
// The tile cache already shows the previously-blurred content, so
|
||||
// removing blur during interaction is visually acceptable. The
|
||||
// settling pass will restore blur at reduced quality, followed by
|
||||
// a full-quality re-render.
|
||||
if shape.blur.is_some() {
|
||||
shape.to_mut().set_blur(None);
|
||||
}
|
||||
} else if self.options.is_settling_mode() {
|
||||
// In settling mode (first render after pan/zoom ends) we allow
|
||||
// blur but clamp it to a small value so the visible tiles
|
||||
// render quickly. A full-quality re-render is scheduled after.
|
||||
const SETTLING_MAX_BLUR: f32 = 4.0;
|
||||
if let Some(ref blur) = shape.blur {
|
||||
if !blur.hidden && blur.value > SETTLING_MAX_BLUR {
|
||||
let mut clamped = *blur;
|
||||
clamped.value = SETTLING_MAX_BLUR;
|
||||
shape.to_mut().set_blur(Some(clamped));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let center = shape.center();
|
||||
@@ -869,13 +904,18 @@ impl RenderState {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let mut drop_shadows = shape.drop_shadow_paints();
|
||||
let max_shadow_blur = if self.options.is_settling_mode() {
|
||||
Some(4.0_f32)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut drop_shadows = shape.drop_shadow_paints(max_shadow_blur);
|
||||
|
||||
if let Some(inherited_shadows) = self.get_inherited_drop_shadows() {
|
||||
drop_shadows.extend(inherited_shadows);
|
||||
}
|
||||
|
||||
let inner_shadows = shape.inner_shadow_paints();
|
||||
let inner_shadows = shape.inner_shadow_paints(max_shadow_blur);
|
||||
let blur_filter = shape.image_filter(1.);
|
||||
let mut paragraphs_with_shadows =
|
||||
text_content.paragraph_builder_group_from_text(Some(true));
|
||||
@@ -1032,7 +1072,12 @@ impl RenderState {
|
||||
Some(strokes_surface_id),
|
||||
antialias,
|
||||
);
|
||||
if !fast_mode {
|
||||
if !self.options.is_fast_mode() {
|
||||
let max_shadow_blur = if self.options.is_settling_mode() {
|
||||
Some(4.0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
for stroke in &visible_strokes {
|
||||
shadows::render_stroke_inner_shadows(
|
||||
self,
|
||||
@@ -1040,17 +1085,24 @@ impl RenderState {
|
||||
stroke,
|
||||
antialias,
|
||||
innershadows_surface_id,
|
||||
max_shadow_blur,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !fast_mode {
|
||||
if !self.options.is_fast_mode() {
|
||||
let max_shadow_blur = if self.options.is_settling_mode() {
|
||||
Some(4.0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
shadows::render_fill_inner_shadows(
|
||||
self,
|
||||
shape,
|
||||
antialias,
|
||||
innershadows_surface_id,
|
||||
max_shadow_blur,
|
||||
);
|
||||
}
|
||||
// bools::debug_render_bool_paths(self, shape, shapes, modifiers, structure);
|
||||
@@ -1175,6 +1227,37 @@ impl RenderState {
|
||||
performance::begin_measure!("start_render_loop");
|
||||
|
||||
self.reset_canvas();
|
||||
|
||||
// When we have valid cached content (from a previous render or
|
||||
// settling pass), prime the Target surface with it. This lets
|
||||
// visible tiles yield across frames without showing empty
|
||||
// background — un-rendered tiles display the cached content until
|
||||
// they are individually re-rendered at the current quality level.
|
||||
let has_valid_cache = self.cached_viewbox.area.width() > 0.0;
|
||||
self.progressive_render = has_valid_cache;
|
||||
if has_valid_cache {
|
||||
let cached_scale = self.get_cached_scale();
|
||||
let navigate_zoom = self.viewbox.zoom / self.cached_viewbox.zoom;
|
||||
let TileRect(start_tile_x, start_tile_y, _, _) =
|
||||
tiles::get_tiles_for_viewbox_with_interest(
|
||||
self.cached_viewbox,
|
||||
VIEWPORT_INTEREST_AREA_THRESHOLD,
|
||||
cached_scale,
|
||||
);
|
||||
let offset_x = self.viewbox.area.left * self.cached_viewbox.zoom * self.options.dpr();
|
||||
let offset_y = self.viewbox.area.top * self.cached_viewbox.zoom * self.options.dpr();
|
||||
let translate_x = (start_tile_x as f32 * tiles::TILE_SIZE) - offset_x;
|
||||
let translate_y = (start_tile_y as f32 * tiles::TILE_SIZE) - offset_y;
|
||||
{
|
||||
let canvas = self.surfaces.canvas(SurfaceId::Target);
|
||||
canvas.save();
|
||||
canvas.scale((navigate_zoom, navigate_zoom));
|
||||
canvas.translate((translate_x, translate_y));
|
||||
}
|
||||
self.surfaces.draw_cache_to_target();
|
||||
self.surfaces.canvas(SurfaceId::Target).restore();
|
||||
}
|
||||
|
||||
let surface_ids = SurfaceId::Strokes as u32
|
||||
| SurfaceId::Fills as u32
|
||||
| SurfaceId::InnerShadows as u32
|
||||
@@ -1185,11 +1268,15 @@ impl RenderState {
|
||||
|
||||
let viewbox_cache_size = get_cache_size(self.viewbox, scale);
|
||||
let cached_viewbox_cache_size = get_cache_size(self.cached_viewbox, scale);
|
||||
// Only resize cache if the new size is larger than the cached size
|
||||
// This avoids unnecessary surface recreations when the cache size decreases
|
||||
if viewbox_cache_size.width > cached_viewbox_cache_size.width
|
||||
|| viewbox_cache_size.height > cached_viewbox_cache_size.height
|
||||
{
|
||||
// Resize cache when the new size is larger, or when the current cache
|
||||
// is significantly oversized (more than 2× the needed area). This
|
||||
// prevents the cache from staying at its peak size after zooming out,
|
||||
// which would waste GPU memory.
|
||||
let needs_grow = viewbox_cache_size.width > cached_viewbox_cache_size.width
|
||||
|| viewbox_cache_size.height > cached_viewbox_cache_size.height;
|
||||
let needs_shrink = cached_viewbox_cache_size.width > viewbox_cache_size.width * 2
|
||||
|| cached_viewbox_cache_size.height > viewbox_cache_size.height * 2;
|
||||
if needs_grow || needs_shrink {
|
||||
self.surfaces
|
||||
.resize_cache(viewbox_cache_size, VIEWPORT_INTEREST_AREA_THRESHOLD);
|
||||
}
|
||||
@@ -1247,6 +1334,26 @@ impl RenderState {
|
||||
if self.render_in_progress {
|
||||
self.cancel_animation_frame();
|
||||
self.render_request_id = Some(wapi::request_animation_frame!());
|
||||
} else if self.options.is_settling_mode() {
|
||||
// The settling render (reduced-quality) has finished all tiles.
|
||||
// Automatically transition to full quality: clear settling mode,
|
||||
// invalidate tile caches, and start a fresh render loop so that
|
||||
// tiles are re-drawn at full blur quality. This keeps the rAF
|
||||
// chain going without needing a round-trip through JS.
|
||||
//
|
||||
// Use soft_clear so settling-quality tile textures remain
|
||||
// available as visual fallback. This prevents the "bumpy" pop
|
||||
// where plain (unblurred) shapes flash before full-quality
|
||||
// tiles replace them.
|
||||
performance::begin_measure!("settling_to_full_quality");
|
||||
self.options.set_settling_mode(false);
|
||||
self.surfaces.soft_clear_tiles();
|
||||
performance::end_measure!("settling_to_full_quality");
|
||||
// Use a fresh timestamp so the full-quality pass gets its own
|
||||
// time budget instead of inheriting the exhausted budget from
|
||||
// the settling pass.
|
||||
let fresh_ts = performance::get_time();
|
||||
self.start_render_loop(base_object, tree, fresh_ts, false)?;
|
||||
} else {
|
||||
performance::end_measure!("render");
|
||||
}
|
||||
@@ -1324,7 +1431,15 @@ impl RenderState {
|
||||
|
||||
if let Some(frame_blur) = Self::frame_clip_layer_blur(element) {
|
||||
let scale = self.get_scale();
|
||||
let sigma = frame_blur.value * scale;
|
||||
let mut sigma = frame_blur.value * scale;
|
||||
// In fast mode skip frame-level blur entirely for better
|
||||
// responsiveness; in settling mode clamp to a small value.
|
||||
if self.options.is_fast_mode() {
|
||||
sigma = 0.0;
|
||||
} else if self.options.is_settling_mode() {
|
||||
const SETTLING_MAX_SIGMA: f32 = 4.0;
|
||||
sigma = sigma.min(SETTLING_MAX_SIGMA * scale);
|
||||
}
|
||||
if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) {
|
||||
paint.set_image_filter(filter);
|
||||
}
|
||||
@@ -1508,6 +1623,15 @@ impl RenderState {
|
||||
transformed_shadow.to_mut().offset = (0.0, 0.0);
|
||||
transformed_shadow.to_mut().color = skia::Color::BLACK;
|
||||
|
||||
// Clamp shadow blur during settling mode for faster rendering.
|
||||
// The filter surface is also at 0.5x resolution (see FAST_MODE_FILTER_DOWNSCALE).
|
||||
if self.options.is_settling_mode() {
|
||||
const SETTLING_MAX_SHADOW_BLUR: f32 = 4.0;
|
||||
if transformed_shadow.blur > SETTLING_MAX_SHADOW_BLUR {
|
||||
transformed_shadow.to_mut().blur = SETTLING_MAX_SHADOW_BLUR;
|
||||
}
|
||||
}
|
||||
|
||||
let mut plain_shape = Cow::Borrowed(shape);
|
||||
let combined_blur =
|
||||
Self::combine_blur_values(self.combined_layer_blur(shape.blur), extra_layer_blur);
|
||||
@@ -1738,7 +1862,11 @@ impl RenderState {
|
||||
|
||||
let mut transformed_shadow: Cow<Shadow> = Cow::Borrowed(shadow);
|
||||
transformed_shadow.to_mut().color = skia::Color::BLACK;
|
||||
transformed_shadow.to_mut().blur = transformed_shadow.blur * scale;
|
||||
let mut blur = transformed_shadow.blur;
|
||||
if self.options.is_settling_mode() {
|
||||
blur = blur.min(4.0);
|
||||
}
|
||||
transformed_shadow.to_mut().blur = blur * scale;
|
||||
transformed_shadow.to_mut().spread = transformed_shadow.spread * scale;
|
||||
|
||||
let mut new_shadow_paint = skia::Paint::default();
|
||||
@@ -1879,11 +2007,14 @@ impl RenderState {
|
||||
let element_extrect =
|
||||
extrect.get_or_insert_with(|| transformed_element.extrect(tree, scale));
|
||||
element_extrect.intersects(self.render_area)
|
||||
&& !transformed_element.visually_insignificant(scale, tree)
|
||||
&& !transformed_element
|
||||
.visually_insignificant_with_extrect(scale, element_extrect)
|
||||
} else {
|
||||
// For simple shapes without effects, use selrect for both intersection
|
||||
// and size check — avoids expensive extrect computation entirely.
|
||||
let selrect = transformed_element.selrect();
|
||||
selrect.intersects(self.render_area)
|
||||
&& !transformed_element.visually_insignificant(scale, tree)
|
||||
&& !transformed_element.visually_insignificant_with_extrect(scale, &selrect)
|
||||
};
|
||||
|
||||
if self.options.is_debug_visible() {
|
||||
@@ -1936,6 +2067,9 @@ impl RenderState {
|
||||
.get_render_context_translation(self.render_area, scale);
|
||||
|
||||
// Skip expensive drop shadow rendering in fast mode (during pan/zoom)
|
||||
// and settling mode (first post-interaction pass). Shadow rendering
|
||||
// is the most expensive operation per shape; deferring it to the
|
||||
// progressive full-quality pass prevents the UI from freezing.
|
||||
let skip_shadows = self.options.is_fast_mode();
|
||||
|
||||
// Skip shadow block when already rendered before the layer (frame_clip_layer_blur)
|
||||
@@ -2097,11 +2231,18 @@ impl RenderState {
|
||||
}
|
||||
} else {
|
||||
performance::begin_measure!("render_shape_tree::uncached");
|
||||
// Only allow stopping (yielding) if the current tile is NOT visible.
|
||||
// This ensures all visible tiles render synchronously before showing,
|
||||
// eliminating empty squares during zoom. Interest-area tiles can still yield.
|
||||
// Decide whether this tile can yield (stop mid-render to
|
||||
// avoid blocking the main thread for too long).
|
||||
//
|
||||
// Normally, visible tiles render synchronously to prevent
|
||||
// empty squares on screen. However, when progressive_render
|
||||
// is active (the Target was primed with cached content),
|
||||
// visible tiles can also yield because the cached content
|
||||
// fills in for tiles that haven't been re-rendered yet.
|
||||
// This keeps each frame short and responsive even when
|
||||
// tiles contain heavy blurs.
|
||||
let tile_is_visible = self.tile_viewbox.is_visible(¤t_tile);
|
||||
let can_stop = allow_stop && !tile_is_visible;
|
||||
let can_stop = allow_stop && (!tile_is_visible || self.progressive_render);
|
||||
let (is_empty, early_return) =
|
||||
self.render_shape_tree_partial_uncached(tree, timestamp, can_stop)?;
|
||||
|
||||
@@ -2122,11 +2263,20 @@ impl RenderState {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.surfaces.apply_mut(SurfaceId::Target as u32, |s| {
|
||||
let mut paint = skia::Paint::default();
|
||||
paint.set_color(self.background_color);
|
||||
s.canvas().draw_rect(tile_rect, &paint);
|
||||
});
|
||||
// Tile is empty — try to show a soft-cleared fallback tile
|
||||
// (e.g. from a settling pass) instead of plain background.
|
||||
let tile = self.current_tile.unwrap();
|
||||
if !self.surfaces.draw_cached_tile_fallback(
|
||||
tile,
|
||||
tile_rect,
|
||||
self.background_color,
|
||||
) {
|
||||
self.surfaces.apply_mut(SurfaceId::Target as u32, |s| {
|
||||
let mut paint = skia::Paint::default();
|
||||
paint.set_color(self.background_color);
|
||||
s.canvas().draw_rect(tile_rect, &paint);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2167,6 +2317,7 @@ impl RenderState {
|
||||
}
|
||||
|
||||
self.render_in_progress = false;
|
||||
self.progressive_render = false;
|
||||
|
||||
self.surfaces.gc();
|
||||
|
||||
@@ -2360,8 +2511,10 @@ impl RenderState {
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate changed tiles - old content stays visible until new tiles render
|
||||
self.surfaces.remove_cached_tiles(self.background_color);
|
||||
if zoom_changed {
|
||||
// Invalidate changed tiles - old content stays visible until new tiles render
|
||||
self.surfaces.remove_cached_tiles(self.background_color);
|
||||
}
|
||||
|
||||
performance::end_measure!("rebuild_tiles_shallow");
|
||||
}
|
||||
@@ -2393,11 +2546,10 @@ impl RenderState {
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate changed tiles - old content stays visible until new tiles render
|
||||
// Invalidate all cached tiles - content will be re-rendered at the new state.
|
||||
// remove_cached_tiles already clears the entire tile cache, so no need
|
||||
// to also call remove_cached_tile per tile individually.
|
||||
self.surfaces.remove_cached_tiles(self.background_color);
|
||||
for tile in all_tiles {
|
||||
self.remove_cached_tile(tile);
|
||||
}
|
||||
performance::end_measure!("rebuild_tiles");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@ use skia_safe::{self as skia, ImageFilter, Rect};
|
||||
|
||||
use super::{RenderState, SurfaceId};
|
||||
|
||||
/// Extra downscale factor applied to the filter surface during fast mode
|
||||
/// (pan/zoom interactions). Rendering blurred content at half resolution
|
||||
/// is virtually indistinguishable while the viewport is moving and cuts
|
||||
/// GPU fill-rate / shader work significantly.
|
||||
const FAST_MODE_FILTER_DOWNSCALE: f32 = 0.5;
|
||||
|
||||
/// Composes two image filters, returning a combined filter if both are present,
|
||||
/// or the individual filter if only one is present, or None if neither is present.
|
||||
///
|
||||
@@ -87,7 +93,7 @@ where
|
||||
let bounds_height = bounds.height().ceil().max(1.0) as i32;
|
||||
|
||||
// Calculate scale factor if bounds exceed filter surface size
|
||||
let scale = if bounds_width > filter_width || bounds_height > filter_height {
|
||||
let mut scale = if bounds_width > filter_width || bounds_height > filter_height {
|
||||
let scale_x = filter_width as f32 / bounds_width as f32;
|
||||
let scale_y = filter_height as f32 / bounds_height as f32;
|
||||
// Use the smaller scale to ensure everything fits
|
||||
@@ -96,6 +102,14 @@ where
|
||||
1.0
|
||||
};
|
||||
|
||||
// In fast mode or settling mode (pan/zoom or post-interaction first pass)
|
||||
// apply an additional downscale so that blur filter surfaces are rendered at
|
||||
// lower resolution. The compositing step scales the result back up, trading
|
||||
// a small amount of fidelity for a large reduction in GPU work.
|
||||
if render_state.options.is_reduced_quality() {
|
||||
scale = (scale * FAST_MODE_FILTER_DOWNSCALE).max(0.1);
|
||||
}
|
||||
|
||||
{
|
||||
let canvas = render_state.surfaces.canvas(filter_id);
|
||||
canvas.clear(skia::Color::TRANSPARENT);
|
||||
|
||||
@@ -28,6 +28,27 @@ impl RenderOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// Settling mode: transitional reduced-quality render after pan/zoom ends.
|
||||
/// Blur is kept but rendered at reduced resolution/sigma so the first
|
||||
/// post-interaction frame appears quickly. A follow-up full-quality render
|
||||
/// is then scheduled.
|
||||
pub fn is_settling_mode(&self) -> bool {
|
||||
self.flags & options::SETTLING_MODE == options::SETTLING_MODE
|
||||
}
|
||||
|
||||
pub fn set_settling_mode(&mut self, enabled: bool) {
|
||||
if enabled {
|
||||
self.flags |= options::SETTLING_MODE;
|
||||
} else {
|
||||
self.flags &= !options::SETTLING_MODE;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if blur quality should be reduced (either fast mode or settling mode).
|
||||
pub fn is_reduced_quality(&self) -> bool {
|
||||
self.is_fast_mode() || self.is_settling_mode()
|
||||
}
|
||||
|
||||
pub fn dpr(&self) -> f32 {
|
||||
self.dpr.unwrap_or(1.0)
|
||||
}
|
||||
|
||||
@@ -11,10 +11,12 @@ pub fn render_fill_inner_shadows(
|
||||
shape: &Shape,
|
||||
antialias: bool,
|
||||
surface_id: SurfaceId,
|
||||
max_blur: Option<f32>,
|
||||
) {
|
||||
if shape.has_fills() {
|
||||
for shadow in shape.inner_shadows_visible() {
|
||||
render_fill_inner_shadow(render_state, shape, shadow, antialias, surface_id);
|
||||
let shadow = clamp_shadow_blur(shadow, max_blur);
|
||||
render_fill_inner_shadow(render_state, shape, &shadow, antialias, surface_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,9 +38,11 @@ pub fn render_stroke_inner_shadows(
|
||||
stroke: &Stroke,
|
||||
antialias: bool,
|
||||
surface_id: SurfaceId,
|
||||
max_blur: Option<f32>,
|
||||
) {
|
||||
if !shape.has_fills() {
|
||||
for shadow in shape.inner_shadows_visible() {
|
||||
let shadow = clamp_shadow_blur(shadow, max_blur);
|
||||
let filter = shadow.get_inner_shadow_filter();
|
||||
strokes::render_single(
|
||||
render_state,
|
||||
@@ -166,3 +170,14 @@ pub fn render_text_shadows(
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
|
||||
fn clamp_shadow_blur(shadow: &Shadow, max_blur: Option<f32>) -> Shadow {
|
||||
match max_blur {
|
||||
Some(max) if shadow.blur > max => {
|
||||
let mut clamped = *shadow;
|
||||
clamped.blur = max;
|
||||
clamped
|
||||
}
|
||||
_ => *shadow,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,13 @@ impl Surfaces {
|
||||
self.tiles.clear();
|
||||
}
|
||||
|
||||
/// Soft-clear tiles: marks all tiles as needing re-render but keeps their
|
||||
/// textures available for visual fallback via `draw_cached_tile_surface`.
|
||||
/// This avoids the visual "pop" when transitioning between quality levels.
|
||||
pub fn soft_clear_tiles(&mut self) {
|
||||
self.tiles.soft_clear();
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, gpu_state: &mut GpuState, new_width: i32, new_height: i32) {
|
||||
self.reset_from_target(gpu_state.create_target_surface(new_width, new_height));
|
||||
}
|
||||
@@ -461,6 +468,29 @@ impl Surfaces {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws a cached tile even if it was soft-removed, for visual fallback
|
||||
/// during quality transitions. Returns true if a fallback tile was drawn.
|
||||
pub fn draw_cached_tile_fallback(
|
||||
&mut self,
|
||||
tile: Tile,
|
||||
rect: skia::Rect,
|
||||
color: skia::Color,
|
||||
) -> bool {
|
||||
if let Some(image) = self.tiles.get_even_if_removed(tile) {
|
||||
let mut paint = skia::Paint::default();
|
||||
paint.set_color(color);
|
||||
|
||||
self.target.canvas().draw_rect(rect, &paint);
|
||||
|
||||
self.target
|
||||
.canvas()
|
||||
.draw_image_rect(&image, None, rect, &skia::Paint::default());
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the current tile directly to the target and cache surfaces without
|
||||
/// creating a snapshot. This avoids GPU stalls from ReadPixels but doesn't
|
||||
/// populate the tile texture cache (suitable for one-shot renders like tests).
|
||||
@@ -534,6 +564,7 @@ impl TileTextureCache {
|
||||
for tile in self.removed.iter() {
|
||||
self.grid.remove(tile);
|
||||
}
|
||||
self.removed.clear();
|
||||
}
|
||||
|
||||
fn free_tiles(&mut self, tile_viewbox: &TileViewbox) {
|
||||
@@ -579,13 +610,29 @@ impl TileTextureCache {
|
||||
self.grid.get_mut(&tile)
|
||||
}
|
||||
|
||||
/// Returns the tile texture even if it was soft-removed.
|
||||
/// Used for visual fallback during quality transitions.
|
||||
pub fn get_even_if_removed(&mut self, tile: Tile) -> Option<&mut skia::Image> {
|
||||
self.grid.get_mut(&tile)
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, tile: Tile) {
|
||||
self.removed.insert(tile);
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
/// Soft-clear marks every tile as removed (so `has()` returns false and
|
||||
/// tiles will be re-rendered) but keeps the textures in `grid` so that
|
||||
/// `get()` can still return them when used explicitly for visual fallback.
|
||||
/// Use this when transitioning between quality levels so that old tiles
|
||||
/// remain visible until replaced by higher-quality versions.
|
||||
pub fn soft_clear(&mut self) {
|
||||
for k in self.grid.keys() {
|
||||
self.removed.insert(*k);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.grid.clear();
|
||||
self.removed.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +196,10 @@ pub struct Shape {
|
||||
pub extrect_cache: RefCell<Option<(math::Rect, u32)>>,
|
||||
pub svg_transform: Option<Matrix>,
|
||||
pub ignore_constraints: bool,
|
||||
/// Cached `ImageFilter` for the unit-scale blur (`scale = 1.0`).
|
||||
/// Invalidated whenever `set_blur` is called. Keyed by the blur
|
||||
/// parameters so a stale entry is never returned.
|
||||
cached_image_filter: RefCell<Option<(Blur, skia::ImageFilter)>>,
|
||||
}
|
||||
|
||||
// Returns all ancestor shapes of this shape, traversing up the parent hierarchy
|
||||
@@ -284,6 +288,7 @@ impl Shape {
|
||||
extrect_cache: RefCell::new(None),
|
||||
svg_transform: None,
|
||||
ignore_constraints: false,
|
||||
cached_image_filter: RefCell::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,6 +301,8 @@ impl Shape {
|
||||
|
||||
if let Some(blur) = self.blur.as_mut() {
|
||||
blur.scale_content(value);
|
||||
// Invalidate cached filter since blur value changed
|
||||
*self.cached_image_filter.borrow_mut() = None;
|
||||
}
|
||||
|
||||
self.layout_item
|
||||
@@ -604,6 +611,8 @@ impl Shape {
|
||||
|
||||
pub fn set_blur(&mut self, blur: Option<Blur>) {
|
||||
self.invalidate_extrect();
|
||||
// Invalidate the cached ImageFilter when blur parameters change
|
||||
*self.cached_image_filter.borrow_mut() = None;
|
||||
self.blur = blur;
|
||||
}
|
||||
|
||||
@@ -654,6 +663,11 @@ impl Shape {
|
||||
self.strokes.push(s)
|
||||
}
|
||||
|
||||
pub fn set_strokes(&mut self, strokes: Vec<Stroke>) {
|
||||
self.invalidate_extrect();
|
||||
self.strokes = strokes;
|
||||
}
|
||||
|
||||
pub fn set_stroke_fill(&mut self, f: Fill) -> Result<(), String> {
|
||||
let stroke = self.strokes.last_mut().ok_or("Shape has no strokes")?;
|
||||
stroke.fill = f;
|
||||
@@ -740,8 +754,17 @@ impl Shape {
|
||||
self.calculate_extrect(shapes_pool, scale)
|
||||
}
|
||||
|
||||
/// Check if shape is too small to be visually relevant at the given scale.
|
||||
/// Prefer `visually_insignificant_with_extrect` if you already have a computed extrect.
|
||||
#[allow(dead_code)]
|
||||
pub fn visually_insignificant(&self, scale: f32, shapes_pool: ShapesPoolRef) -> bool {
|
||||
let extrect = self.extrect(shapes_pool, scale);
|
||||
self.visually_insignificant_with_extrect(scale, &extrect)
|
||||
}
|
||||
|
||||
/// Check if shape is too small to be visually relevant, using a precomputed extrect.
|
||||
/// This avoids redundant extrect computation when the caller already has one.
|
||||
pub fn visually_insignificant_with_extrect(&self, scale: f32, extrect: &math::Rect) -> bool {
|
||||
extrect.width() * scale < MIN_VISIBLE_SIZE && extrect.height() * scale < MIN_VISIBLE_SIZE
|
||||
}
|
||||
|
||||
@@ -872,6 +895,11 @@ impl Shape {
|
||||
}
|
||||
|
||||
fn apply_shadow_bounds(&self, bounds: Bounds) -> Bounds {
|
||||
// Fast path: skip when no shadows exist
|
||||
if self.shadows.is_empty() {
|
||||
return bounds;
|
||||
}
|
||||
|
||||
let mut rect = bounds.to_rect();
|
||||
for shadow in self.shadows_visible() {
|
||||
if !shadow.hidden() {
|
||||
@@ -885,6 +913,12 @@ impl Shape {
|
||||
}
|
||||
|
||||
fn apply_blur_bounds(&self, bounds: Bounds) -> Bounds {
|
||||
// Fast path: skip when no active blur (most common case)
|
||||
match self.blur {
|
||||
Some(b) if !b.hidden => {}
|
||||
_ => return bounds,
|
||||
}
|
||||
|
||||
let mut rect = bounds.to_rect();
|
||||
let image_filter = self.image_filter(1.);
|
||||
if let Some(image_filter) = image_filter {
|
||||
@@ -956,7 +990,6 @@ impl Shape {
|
||||
}
|
||||
|
||||
pub fn apply_children_blur(&self, bounds: Bounds, tree: ShapesPoolRef) -> Bounds {
|
||||
let mut rect = bounds.to_rect();
|
||||
let mut children_blur = 0.0;
|
||||
let mut current_parent_id = self.parent_id;
|
||||
|
||||
@@ -983,6 +1016,12 @@ impl Shape {
|
||||
}
|
||||
}
|
||||
|
||||
// Short-circuit: no parent has a layer blur, skip Skia filter creation entirely
|
||||
if children_blur == 0.0 {
|
||||
return bounds;
|
||||
}
|
||||
|
||||
let mut rect = bounds.to_rect();
|
||||
let blur = skia::image_filters::blur((children_blur, children_blur), None, None, None);
|
||||
if let Some(image_filter) = blur {
|
||||
let blur_bounds = image_filter.compute_fast_bounds(rect);
|
||||
@@ -1212,16 +1251,39 @@ impl Shape {
|
||||
}
|
||||
|
||||
pub fn image_filter(&self, scale: f32) -> Option<skia::ImageFilter> {
|
||||
self.blur
|
||||
.filter(|blur| !blur.hidden)
|
||||
.and_then(|blur| match blur.blur_type {
|
||||
BlurType::LayerBlur => skia::image_filters::blur(
|
||||
(blur.value * scale, blur.value * scale),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
})
|
||||
let blur = self.blur.filter(|b| !b.hidden)?;
|
||||
|
||||
// Fast path: for the most common case (scale == 1.0) use the cached filter.
|
||||
if scale == 1.0 {
|
||||
let cache = self.cached_image_filter.borrow();
|
||||
if let Some((cached_blur, ref filter)) = *cache {
|
||||
if cached_blur == blur {
|
||||
return Some(filter.clone());
|
||||
}
|
||||
}
|
||||
drop(cache);
|
||||
|
||||
// Compute and cache
|
||||
let filter = match blur.blur_type {
|
||||
BlurType::LayerBlur => {
|
||||
skia::image_filters::blur((blur.value, blur.value), None, None, None)
|
||||
}
|
||||
};
|
||||
if let Some(ref f) = filter {
|
||||
*self.cached_image_filter.borrow_mut() = Some((blur, f.clone()));
|
||||
}
|
||||
return filter;
|
||||
}
|
||||
|
||||
// Non-unit scale: compute directly (rare path)
|
||||
match blur.blur_type {
|
||||
BlurType::LayerBlur => skia::image_filters::blur(
|
||||
(blur.value * scale, blur.value * scale),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -1251,6 +1313,11 @@ impl Shape {
|
||||
self.shadows.push(shadow);
|
||||
}
|
||||
|
||||
pub fn set_shadows(&mut self, shadows: Vec<Shadow>) {
|
||||
self.invalidate_extrect();
|
||||
self.shadows = shadows;
|
||||
}
|
||||
|
||||
pub fn clear_shadows(&mut self) {
|
||||
self.invalidate_extrect();
|
||||
self.shadows.clear();
|
||||
@@ -1578,12 +1645,20 @@ impl Shape {
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn drop_shadow_paints(&self) -> Vec<skia_safe::Paint> {
|
||||
pub fn drop_shadow_paints(&self, max_blur: Option<f32>) -> Vec<skia_safe::Paint> {
|
||||
let drop_shadows: Vec<&Shadow> = self.drop_shadows_visible().collect();
|
||||
|
||||
drop_shadows
|
||||
.into_iter()
|
||||
.map(|shadow| {
|
||||
let shadow = match max_blur {
|
||||
Some(max) if shadow.blur > max => {
|
||||
let mut c = *shadow;
|
||||
c.blur = max;
|
||||
c
|
||||
}
|
||||
_ => *shadow,
|
||||
};
|
||||
let mut paint = skia_safe::Paint::default();
|
||||
let filter = shadow.get_drop_shadow_filter();
|
||||
paint.set_image_filter(filter);
|
||||
@@ -1592,12 +1667,20 @@ impl Shape {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn inner_shadow_paints(&self) -> Vec<skia_safe::Paint> {
|
||||
pub fn inner_shadow_paints(&self, max_blur: Option<f32>) -> Vec<skia_safe::Paint> {
|
||||
let inner_shadows: Vec<&Shadow> = self.inner_shadows_visible().collect();
|
||||
|
||||
inner_shadows
|
||||
.into_iter()
|
||||
.map(|shadow| {
|
||||
let shadow = match max_blur {
|
||||
Some(max) if shadow.blur > max => {
|
||||
let mut c = *shadow;
|
||||
c.blur = max;
|
||||
c
|
||||
}
|
||||
_ => *shadow,
|
||||
};
|
||||
let mut paint = skia_safe::Paint::default();
|
||||
let filter = shadow.get_inner_shadow_filter();
|
||||
paint.set_image_filter(filter);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
use macros::ToJs;
|
||||
use skia_safe as skia;
|
||||
|
||||
use crate::mem;
|
||||
use crate::shapes::{Shadow, ShadowStyle};
|
||||
use crate::{with_current_shape_mut, STATE};
|
||||
|
||||
const RAW_SHADOW_DATA_SIZE: usize = std::mem::size_of::<RawShadowData>();
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, ToJs)]
|
||||
#[repr(u8)]
|
||||
#[allow(dead_code)]
|
||||
@@ -28,6 +31,93 @@ impl From<RawShadowStyle> for ShadowStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// Binary layout for a single shadow entry in the batched buffer.
|
||||
///
|
||||
/// | Offset | Size | Field | Type |
|
||||
/// |--------|------|---------|------|
|
||||
/// | 0 | 4 | color | u32 |
|
||||
/// | 4 | 4 | blur | f32 |
|
||||
/// | 8 | 4 | spread | f32 |
|
||||
/// | 12 | 4 | x | f32 |
|
||||
/// | 16 | 4 | y | f32 |
|
||||
/// | 20 | 1 | style | u8 |
|
||||
/// | 21 | 1 | hidden | u8 |
|
||||
/// | 22 | 2 | padding | - |
|
||||
/// | Total | 24 | | |
|
||||
#[repr(C, align(4))]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct RawShadowData {
|
||||
color: u32,
|
||||
blur: f32,
|
||||
spread: f32,
|
||||
x: f32,
|
||||
y: f32,
|
||||
style: u8,
|
||||
hidden: u8,
|
||||
_padding: [u8; 2],
|
||||
}
|
||||
|
||||
impl From<RawShadowData> for Shadow {
|
||||
fn from(raw: RawShadowData) -> Self {
|
||||
let color = skia::Color::new(raw.color);
|
||||
let style = RawShadowStyle::from(raw.style).into();
|
||||
Shadow::new(
|
||||
color,
|
||||
raw.blur,
|
||||
raw.spread,
|
||||
(raw.x, raw.y),
|
||||
style,
|
||||
raw.hidden != 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<[u8; RAW_SHADOW_DATA_SIZE]> for RawShadowData {
|
||||
fn from(bytes: [u8; RAW_SHADOW_DATA_SIZE]) -> Self {
|
||||
unsafe { std::mem::transmute(bytes) }
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&[u8]> for RawShadowData {
|
||||
type Error = String;
|
||||
fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
|
||||
let data: [u8; RAW_SHADOW_DATA_SIZE] = bytes
|
||||
.get(0..RAW_SHADOW_DATA_SIZE)
|
||||
.and_then(|slice| slice.try_into().ok())
|
||||
.ok_or("Invalid shadow data".to_string())?;
|
||||
Ok(RawShadowData::from(data))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_shadows_from_bytes(buffer: &[u8], num_shadows: usize) -> Vec<Shadow> {
|
||||
buffer
|
||||
.chunks_exact(RAW_SHADOW_DATA_SIZE)
|
||||
.take(num_shadows)
|
||||
.map(|bytes| {
|
||||
RawShadowData::try_from(bytes)
|
||||
.expect("Invalid shadow data")
|
||||
.into()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Batched shadow setter: reads all shadows from the shared memory buffer.
|
||||
/// Buffer layout: [u8 count][3 bytes padding][N × 24-byte RawShadowData]
|
||||
#[no_mangle]
|
||||
pub extern "C" fn set_shape_shadows() {
|
||||
with_current_shape_mut!(state, |shape: &mut Shape| {
|
||||
let bytes = mem::bytes();
|
||||
let num_shadows = bytes.first().copied().unwrap_or(0) as usize;
|
||||
let shadows = if num_shadows == 0 {
|
||||
vec![]
|
||||
} else {
|
||||
parse_shadows_from_bytes(&bytes[4..], num_shadows)
|
||||
};
|
||||
shape.set_shadows(shadows);
|
||||
mem::free_bytes();
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn add_shape_shadow(
|
||||
raw_color: u32,
|
||||
@@ -52,3 +142,41 @@ pub extern "C" fn clear_shape_shadows() {
|
||||
shape.clear_shadows();
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_raw_shadow_data_layout() {
|
||||
assert_eq!(RAW_SHADOW_DATA_SIZE, 24);
|
||||
assert_eq!(std::mem::align_of::<RawShadowData>(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_raw_shadow_data_from_bytes() {
|
||||
let mut bytes = [0u8; RAW_SHADOW_DATA_SIZE];
|
||||
// color = 0xFF112233
|
||||
bytes[0..4].copy_from_slice(&0xFF112233_u32.to_le_bytes());
|
||||
// blur = 5.0
|
||||
bytes[4..8].copy_from_slice(&5.0_f32.to_le_bytes());
|
||||
// spread = 2.0
|
||||
bytes[8..12].copy_from_slice(&2.0_f32.to_le_bytes());
|
||||
// x = 10.0
|
||||
bytes[12..16].copy_from_slice(&10.0_f32.to_le_bytes());
|
||||
// y = 15.0
|
||||
bytes[16..20].copy_from_slice(&15.0_f32.to_le_bytes());
|
||||
// style = 0 (DropShadow)
|
||||
bytes[20] = 0;
|
||||
// hidden = 0 (false)
|
||||
bytes[21] = 0;
|
||||
|
||||
let raw = RawShadowData::from(bytes);
|
||||
let shadow: Shadow = raw.into();
|
||||
|
||||
assert_eq!(shadow.blur, 5.0);
|
||||
assert_eq!(shadow.spread, 2.0);
|
||||
assert_eq!(shadow.offset, (10.0, 15.0));
|
||||
assert!(!shadow.hidden());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ use crate::wasm::layouts::constraints::{RawConstraintH, RawConstraintV};
|
||||
use crate::{with_state_mut, STATE};
|
||||
|
||||
use super::RawShapeType;
|
||||
use crate::shapes::Blur;
|
||||
use crate::wasm::blurs::RawBlurType;
|
||||
|
||||
const FLAG_CLIP_CONTENT: u8 = 0b0000_0001;
|
||||
const FLAG_HIDDEN: u8 = 0b0000_0010;
|
||||
@@ -59,6 +61,12 @@ pub struct RawBasePropsData {
|
||||
corner_r2: f32,
|
||||
corner_r3: f32,
|
||||
corner_r4: f32,
|
||||
// Blur fields (8 bytes)
|
||||
// hidden (u8), blur_type (u8), padding (2 bytes), value (f32)
|
||||
blur_hidden: u8,
|
||||
blur_type: u8,
|
||||
blur_padding: [u8; 2],
|
||||
blur_value: f32,
|
||||
}
|
||||
|
||||
impl RawBasePropsData {
|
||||
@@ -97,6 +105,17 @@ impl RawBasePropsData {
|
||||
Some(RawConstraintV::from(self.constraint_v).into())
|
||||
}
|
||||
}
|
||||
|
||||
fn blur(&self) -> Option<Blur> {
|
||||
// Only LayerBlur is currently supported; hidden indicated by blur_hidden != 0
|
||||
if self.blur_value > 0.0 {
|
||||
let hidden = self.blur_hidden != 0;
|
||||
let blur_type = RawBlurType::from(self.blur_type).into();
|
||||
Some(Blur::new(blur_type, hidden, self.blur_value))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<[u8; RAW_BASE_PROPS_SIZE]> for RawBasePropsData {
|
||||
@@ -149,6 +168,8 @@ pub extern "C" fn set_shape_base_props() {
|
||||
raw.selrect_y2,
|
||||
);
|
||||
shape.set_corners((raw.corner_r1, raw.corner_r2, raw.corner_r3, raw.corner_r4));
|
||||
// Apply blur if present in the batched data
|
||||
shape.set_blur(raw.blur());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -169,7 +190,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_raw_base_props_layout() {
|
||||
assert_eq!(RAW_BASE_PROPS_SIZE, 104);
|
||||
assert_eq!(RAW_BASE_PROPS_SIZE, 112);
|
||||
assert_eq!(std::mem::align_of::<RawBasePropsData>(), 4);
|
||||
}
|
||||
|
||||
@@ -189,6 +210,8 @@ mod tests {
|
||||
assert_eq!(std::mem::offset_of!(RawBasePropsData, transform_a), 48);
|
||||
assert_eq!(std::mem::offset_of!(RawBasePropsData, selrect_x1), 72);
|
||||
assert_eq!(std::mem::offset_of!(RawBasePropsData, corner_r1), 88);
|
||||
assert_eq!(std::mem::offset_of!(RawBasePropsData, blur_hidden), 104);
|
||||
assert_eq!(std::mem::offset_of!(RawBasePropsData, blur_value), 108);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
use macros::ToJs;
|
||||
|
||||
use crate::mem;
|
||||
use crate::shapes::{self, StrokeCap, StrokeStyle};
|
||||
use crate::shapes::{self, Fill, StrokeCap, StrokeKind, StrokeStyle};
|
||||
use crate::with_current_shape_mut;
|
||||
use crate::STATE;
|
||||
|
||||
use super::fills::RawFillData;
|
||||
|
||||
const RAW_FILL_DATA_SIZE: usize = std::mem::size_of::<RawFillData>();
|
||||
|
||||
/// Binary layout per stroke entry (matches STROKE-ENTRY-U8-SIZE on CLJS side):
|
||||
/// byte 0: kind (0=center, 1=inner, 2=outer)
|
||||
/// byte 1: style (0=solid, 1=dotted, 2=dashed, 3=mixed)
|
||||
/// byte 2: cap_start
|
||||
/// byte 3: cap_end
|
||||
/// bytes 4-7: width (f32, little-endian)
|
||||
/// bytes 8..: fill data (RAW_FILL_DATA_SIZE bytes)
|
||||
const STROKE_ENTRY_SIZE: usize = 8 + RAW_FILL_DATA_SIZE;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Copy, ToJs)]
|
||||
#[repr(u8)]
|
||||
#[allow(dead_code)]
|
||||
@@ -69,6 +82,76 @@ impl TryFrom<RawStrokeCap> for StrokeCap {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
#[repr(u8)]
|
||||
#[allow(dead_code)]
|
||||
pub enum RawStrokeKind {
|
||||
Center = 0,
|
||||
Inner = 1,
|
||||
Outer = 2,
|
||||
}
|
||||
|
||||
impl From<u8> for RawStrokeKind {
|
||||
fn from(value: u8) -> Self {
|
||||
unsafe { std::mem::transmute(value) }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RawStrokeKind> for StrokeKind {
|
||||
fn from(value: RawStrokeKind) -> Self {
|
||||
match value {
|
||||
RawStrokeKind::Center => StrokeKind::Center,
|
||||
RawStrokeKind::Inner => StrokeKind::Inner,
|
||||
RawStrokeKind::Outer => StrokeKind::Outer,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_stroke_from_bytes(bytes: &[u8]) -> shapes::Stroke {
|
||||
let kind = RawStrokeKind::from(bytes[0]);
|
||||
let style = RawStrokeStyle::from(bytes[1]);
|
||||
let cap_start = RawStrokeCap::from(bytes[2]);
|
||||
let cap_end = RawStrokeCap::from(bytes[3]);
|
||||
let width = f32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
|
||||
let fill_bytes = &bytes[8..8 + RAW_FILL_DATA_SIZE];
|
||||
let raw_fill = RawFillData::try_from(fill_bytes).expect("Invalid stroke fill data");
|
||||
let fill: Fill = raw_fill.into();
|
||||
|
||||
shapes::Stroke {
|
||||
fill,
|
||||
width,
|
||||
style: style.into(),
|
||||
cap_start: cap_start.try_into().ok(),
|
||||
cap_end: cap_end.try_into().ok(),
|
||||
kind: kind.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_strokes_from_bytes(buffer: &[u8], num_strokes: usize) -> Vec<shapes::Stroke> {
|
||||
buffer
|
||||
.chunks_exact(STROKE_ENTRY_SIZE)
|
||||
.take(num_strokes)
|
||||
.map(parse_stroke_from_bytes)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Batched stroke setter: reads all strokes from a single shared-memory buffer.
|
||||
///
|
||||
/// Buffer layout:
|
||||
/// byte 0: number of strokes (u8)
|
||||
/// bytes 1-3: padding (reserved)
|
||||
/// bytes 4..: N × STROKE_ENTRY_SIZE stroke entries
|
||||
#[no_mangle]
|
||||
pub extern "C" fn set_shape_strokes() {
|
||||
with_current_shape_mut!(state, |shape: &mut Shape| {
|
||||
let bytes = mem::bytes();
|
||||
let num_strokes = bytes.first().copied().unwrap_or(0) as usize;
|
||||
let strokes = parse_strokes_from_bytes(&bytes[4..], num_strokes);
|
||||
shape.set_strokes(strokes);
|
||||
mem::free_bytes();
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn add_shape_center_stroke(width: f32, style: u8, cap_start: u8, cap_end: u8) {
|
||||
let stroke_style = RawStrokeStyle::from(style);
|
||||
@@ -134,3 +217,95 @@ pub extern "C" fn clear_shape_strokes() {
|
||||
shape.clear_strokes();
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_stroke_entry_size() {
|
||||
// STROKE_ENTRY_SIZE = 8 bytes header + RAW_FILL_DATA_SIZE bytes fill
|
||||
assert_eq!(STROKE_ENTRY_SIZE, 8 + RAW_FILL_DATA_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_stroke_center_solid() {
|
||||
let mut entry = vec![0u8; STROKE_ENTRY_SIZE];
|
||||
// kind = Center (0)
|
||||
entry[0] = 0;
|
||||
// style = Solid (0)
|
||||
entry[1] = 0;
|
||||
// cap_start = None (0)
|
||||
entry[2] = 0;
|
||||
// cap_end = None (0)
|
||||
entry[3] = 0;
|
||||
// width = 5.0 (f32 LE)
|
||||
entry[4..8].copy_from_slice(&5.0f32.to_le_bytes());
|
||||
// fill: solid (tag byte 0x00 at offset 8, color at offset 12)
|
||||
entry[8] = 0x00;
|
||||
entry[12..16].copy_from_slice(&0xff00ff00_u32.to_le_bytes());
|
||||
|
||||
let stroke = parse_stroke_from_bytes(&entry);
|
||||
|
||||
assert_eq!(stroke.kind, StrokeKind::Center);
|
||||
assert_eq!(stroke.style, StrokeStyle::Solid);
|
||||
assert_eq!(stroke.cap_start, None);
|
||||
assert_eq!(stroke.cap_end, None);
|
||||
assert_eq!(stroke.width, 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_stroke_inner_dashed_with_caps() {
|
||||
let mut entry = vec![0u8; STROKE_ENTRY_SIZE];
|
||||
// kind = Inner (1)
|
||||
entry[0] = 1;
|
||||
// style = Dashed (2)
|
||||
entry[1] = 2;
|
||||
// cap_start = LineArrow (1)
|
||||
entry[2] = 1;
|
||||
// cap_end = Round (6)
|
||||
entry[3] = 6;
|
||||
// width = 3.5 (f32 LE)
|
||||
entry[4..8].copy_from_slice(&3.5f32.to_le_bytes());
|
||||
// fill: solid
|
||||
entry[8] = 0x00;
|
||||
entry[12..16].copy_from_slice(&0xffaabbcc_u32.to_le_bytes());
|
||||
|
||||
let stroke = parse_stroke_from_bytes(&entry);
|
||||
|
||||
assert_eq!(stroke.kind, StrokeKind::Inner);
|
||||
assert_eq!(stroke.style, StrokeStyle::Dashed);
|
||||
assert_eq!(stroke.cap_start, Some(StrokeCap::LineArrow));
|
||||
assert_eq!(stroke.cap_end, Some(StrokeCap::Round));
|
||||
assert_eq!(stroke.width, 3.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_multiple_strokes() {
|
||||
let mut buffer = vec![0u8; 2 * STROKE_ENTRY_SIZE];
|
||||
|
||||
// First stroke: center solid
|
||||
buffer[0] = 0; // center
|
||||
buffer[1] = 0; // solid
|
||||
buffer[4..8].copy_from_slice(&2.0f32.to_le_bytes());
|
||||
buffer[8] = 0x00; // solid fill
|
||||
buffer[12..16].copy_from_slice(&0xff000000_u32.to_le_bytes());
|
||||
|
||||
// Second stroke: outer dotted
|
||||
let off = STROKE_ENTRY_SIZE;
|
||||
buffer[off] = 2; // outer
|
||||
buffer[off + 1] = 1; // dotted
|
||||
buffer[off + 4..off + 8].copy_from_slice(&4.0f32.to_le_bytes());
|
||||
buffer[off + 8] = 0x00; // solid fill
|
||||
buffer[off + 12..off + 16].copy_from_slice(&0xff0000ff_u32.to_le_bytes());
|
||||
|
||||
let strokes = parse_strokes_from_bytes(&buffer, 2);
|
||||
|
||||
assert_eq!(strokes.len(), 2);
|
||||
assert_eq!(strokes[0].kind, StrokeKind::Center);
|
||||
assert_eq!(strokes[0].width, 2.0);
|
||||
assert_eq!(strokes[1].kind, StrokeKind::Outer);
|
||||
assert_eq!(strokes[1].style, StrokeStyle::Dotted);
|
||||
assert_eq!(strokes[1].width, 4.0);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user