Compare commits

..

2 Commits

Author SHA1 Message Date
Elena Torro
af6cf17435 wip fix pan on shadows and blurs 2026-02-16 18:03:32 +01:00
Alejandro Alonso
f2d09a6140 🐛 Preserving selection when applying styles to selected text range 2026-02-16 17:39:30 +01:00
62 changed files with 1916 additions and 2010 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}))]

View File

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

View File

@@ -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")}))))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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*)]

View File

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

View File

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

View File

@@ -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}]]]]]))

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}
}

View File

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

View File

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

View File

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

View File

@@ -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}])]))

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}])]]]]))

View 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)}])]])]))

View 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;
}

View File

@@ -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*])])

View File

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

View File

@@ -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)}]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(&current_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");
}

View File

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

View File

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

View File

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

View File

@@ -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();
}
}

View File

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

View File

@@ -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());
}
}

View File

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

View File

@@ -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);
}
}