Compare commits
69 Commits
niwinz-dev
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12e5d8d8c4 | ||
|
|
04a3126856 | ||
|
|
2f71663470 | ||
|
|
43cb313cd7 | ||
|
|
0b199c606a | ||
|
|
54f63c5dc5 | ||
|
|
a14c36e996 | ||
|
|
2b525f0f48 | ||
|
|
fd6ff04e90 | ||
|
|
dbb0aa8ce2 | ||
|
|
12822833f6 | ||
|
|
307ae374fe | ||
|
|
7d7dbd4662 | ||
|
|
139d4ba13c | ||
|
|
0cb5c16823 | ||
|
|
4ed1a544f8 | ||
|
|
566ac67fc9 | ||
|
|
b2231e520c | ||
|
|
e722e17b10 | ||
|
|
755d720b34 | ||
|
|
d991d59852 | ||
|
|
7eb9a207f5 | ||
|
|
8ac17604fd | ||
|
|
eede023d6b | ||
|
|
ccd42852b7 | ||
|
|
a2f7ae549e | ||
|
|
6f74d458a8 | ||
|
|
8d033de145 | ||
|
|
f5afcde0de | ||
|
|
b6dfdc23cd | ||
|
|
a5a084cf0f | ||
|
|
1546025814 | ||
|
|
8de510d1c6 | ||
|
|
2e77c09ca5 | ||
|
|
47346e478e | ||
|
|
f32c377f17 | ||
|
|
97f01c646d | ||
|
|
eea1d3c0a5 | ||
|
|
9eef4de87d | ||
|
|
76289df32c | ||
|
|
187d1118c0 | ||
|
|
a674b5f914 | ||
|
|
71507fb9b7 | ||
|
|
024aedc3ca | ||
|
|
44657c95df | ||
|
|
d4d5009a3d | ||
|
|
bb4d0322d8 | ||
|
|
56e369a1c0 | ||
|
|
6b277956b9 | ||
|
|
e9a56c9d9f | ||
|
|
8d90edcc2f | ||
|
|
8186f3c87c | ||
|
|
d7282518c4 | ||
|
|
467eb3c333 | ||
|
|
d2299f83ec | ||
|
|
11a283916d | ||
|
|
e9b2e9e818 | ||
|
|
c4aa51bc01 | ||
|
|
1c270ac9c6 | ||
|
|
969666b39b | ||
|
|
a8322215dd | ||
|
|
e1ce97a2b4 | ||
|
|
2ccd2a6679 | ||
|
|
2d9a2e0d50 | ||
|
|
216d400262 | ||
|
|
c87ffdcd30 | ||
|
|
8ef6600cdc | ||
|
|
a3764b9713 | ||
|
|
79e5d2f4cd |
@@ -12,7 +12,6 @@ penpot - error list
|
||||
|
||||
<a class="{% if version = 3 %}strong{% endif %}" href="?version=3">[BACKEND ERRORS]</a>
|
||||
<a class="{% if version = 4 %}strong{% endif %}" href="?version=4">[FRONTEND ERRORS]</a>
|
||||
<a class="{% if version = 5 %}strong{% endif %}" href="?version=5">[RLIMIT REPORTS]</a>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="horizontal-list">
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
{% extends "app/templates/base.tmpl" %}
|
||||
|
||||
{% block title %}
|
||||
Report: {{hint|abbreviate:150}} - {{id}} - Penpot Rate Limit Report
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav>
|
||||
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
|
||||
<div>[<a href="#head">head</a>]</div>
|
||||
<div>[<a href="#context">context</a>]</div>
|
||||
<div>[<a href="#result">result</a>]</div>
|
||||
</nav>
|
||||
<main>
|
||||
<div class="table">
|
||||
<div class="table-row multiline">
|
||||
<div id="head" class="table-key">HEAD:</div>
|
||||
<div class="table-val">
|
||||
<h1><span class="not-important">Hint:</span> <br/> {{hint}}</h1>
|
||||
<h2><span class="not-important">Reported at:</span> <br/> {{created-at}}</h2>
|
||||
<h2><span class="not-important">Report ID:</span> <br/> {{id}}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-row multiline">
|
||||
<div id="context" class="table-key">CONTEXT: </div>
|
||||
<div class="table-val">
|
||||
<pre>{{context}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-row multiline">
|
||||
<div id="context" class="table-key">RESULT: </div>
|
||||
<div class="table-val">
|
||||
<pre>{{result}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -3,9 +3,9 @@
|
||||
{:default
|
||||
[[:default :window "200000/h"]]
|
||||
|
||||
;; #{:main/get-teams}
|
||||
;; #{:command/get-teams}
|
||||
;; [[:burst :bucket "5/5/5s"]]
|
||||
|
||||
;; #{:main/get-profile}
|
||||
;; #{:command/get-profile}
|
||||
;; [[:burst :bucket "60/60/1m"]]
|
||||
}
|
||||
|
||||
@@ -240,13 +240,6 @@
|
||||
(tmpl/render (-> content
|
||||
(assoc :id id)
|
||||
(assoc :version 4)
|
||||
(assoc :created-at (ct/format-inst created-at :rfc1123))))))
|
||||
|
||||
(render-template-v5 [{:keys [content id created-at]}]
|
||||
(-> (io/resource "app/templates/error-report.v5.tmpl")
|
||||
(tmpl/render (-> content
|
||||
(assoc :id id)
|
||||
(assoc :version 5)
|
||||
(assoc :created-at (ct/format-inst created-at :rfc1123))))))]
|
||||
|
||||
(if-let [report (get-report request)]
|
||||
@@ -254,8 +247,7 @@
|
||||
1 (render-template-v1 report)
|
||||
2 (render-template-v2 report)
|
||||
3 (render-template-v3 report)
|
||||
4 (render-template-v4 report)
|
||||
5 (render-template-v5 report))]
|
||||
4 (render-template-v4 report))]
|
||||
{::yres/status 200
|
||||
::yres/body result
|
||||
::yres/headers {"content-type" "text/html; charset=utf-8"
|
||||
|
||||
@@ -151,22 +151,20 @@
|
||||
uuid/zero)
|
||||
|
||||
props (-> (or (::replace-props resultm)
|
||||
(merge params (::props resultm)))
|
||||
(-> params
|
||||
(merge (::props resultm))
|
||||
(dissoc :profile-id)
|
||||
(dissoc :type)))
|
||||
(clean-props))
|
||||
|
||||
context (merge (::context resultm)
|
||||
(prepare-context-from-request request))
|
||||
ip-addr (inet/parse-request request)
|
||||
module (get cfg ::rpc/module)]
|
||||
ip-addr (inet/parse-request request)]
|
||||
|
||||
{::type (or (::type resultm)
|
||||
(::rpc/type cfg))
|
||||
::name (or (::name resultm)
|
||||
(let [sname (::sv/name mdata)]
|
||||
(if (not= module "main")
|
||||
(str module "-" sname)
|
||||
sname)))
|
||||
|
||||
(::sv/name mdata))
|
||||
::profile-id profile-id
|
||||
::ip-addr ip-addr
|
||||
::props props
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.rpc.rlimit :as-alias rlimit]
|
||||
[clojure.spec.alpha :as s]
|
||||
[integrant.core :as ig]
|
||||
[promesa.exec :as px]
|
||||
@@ -42,7 +41,7 @@
|
||||
(or (instance? java.util.concurrent.CompletionException cause)
|
||||
(instance? java.util.concurrent.ExecutionException cause)))
|
||||
|
||||
(defn- log-record->report
|
||||
(defn record->report
|
||||
[{:keys [::l/context ::l/message ::l/props ::l/logger ::l/level ::l/cause] :as record}]
|
||||
(assert (l/valid-record? record) "expectd valid log record")
|
||||
(let [data (if (concurrent-exception? cause)
|
||||
@@ -87,16 +86,16 @@
|
||||
[{:keys [::db/pool]} {:keys [::l/id] :as record}]
|
||||
(try
|
||||
(let [uri (cf/get :public-uri)
|
||||
report (-> record log-record->report d/without-nils)]
|
||||
report (-> record record->report d/without-nils)]
|
||||
(l/dbg :hint "registering error on database"
|
||||
:id (str id)
|
||||
:id id
|
||||
:src "logging"
|
||||
:uri (str uri "/dbg/error/" id))
|
||||
(persist-on-database! pool id 3 report))
|
||||
(catch Throwable cause
|
||||
(l/warn :hint "unexpected exception on database error logger" :cause cause))))
|
||||
|
||||
(defn- audit-event->report
|
||||
(defn- event->report
|
||||
[{:keys [::audit/context ::audit/props ::audit/ip-addr] :as record}]
|
||||
(let [context
|
||||
(reduce-kv (fn [context k v]
|
||||
@@ -126,51 +125,15 @@
|
||||
[{:keys [::db/pool]} {:keys [::audit/id] :as event}]
|
||||
(try
|
||||
(let [uri (cf/get :public-uri)
|
||||
report (-> event audit-event->report d/without-nils)]
|
||||
report (-> event event->report d/without-nils)]
|
||||
(l/dbg :hint "registering error on database"
|
||||
:id (str id)
|
||||
:id id
|
||||
:src "audit-log"
|
||||
:uri (str uri "/dbg/error/" id))
|
||||
(persist-on-database! pool id 4 report))
|
||||
(catch Throwable cause
|
||||
(l/warn :hint "unexpected exception on database error logger" :cause cause))))
|
||||
|
||||
(defn- rlimit-event->report
|
||||
[event]
|
||||
(let [context
|
||||
(-> {}
|
||||
(assoc :rlimit/uid (::rlimit/uid event))
|
||||
(assoc :rlimit/method (::rlimit/method event))
|
||||
(assoc :backend/tenant (cf/get :tenant))
|
||||
(assoc :backend/host (cf/get :host))
|
||||
(assoc :backend/public-uri (str (cf/get :public-uri)))
|
||||
(assoc :backend/version (:full cf/version)))
|
||||
|
||||
result
|
||||
(->> (::rlimit/results event)
|
||||
(mapv (fn [result]
|
||||
(-> (into (sorted-map) result)
|
||||
(dissoc ::rlimit/method)))))]
|
||||
|
||||
{:hint (str "Rate Limit Rejection: " (::rlimit/method event) " for " (::rlimit/uid event))
|
||||
:context (-> (into (sorted-map) context)
|
||||
(pp/pprint-str :length 50))
|
||||
:result (pp/pprint-str result :length 50)}))
|
||||
|
||||
(defn- handle-rlimit-event
|
||||
"Convert the log record into a report object and persist it on the database"
|
||||
[{:keys [::db/pool]} {:keys [::rlimit/id] :as event}]
|
||||
(try
|
||||
(let [uri (cf/get :public-uri)
|
||||
report (-> event rlimit-event->report d/without-nils)]
|
||||
(l/dbg :hint "registering rate limit rejection"
|
||||
:id (str id)
|
||||
:src "rlimit"
|
||||
:uri (str uri "/dbg/error/" id))
|
||||
(persist-on-database! pool id 5 report))
|
||||
(catch Throwable cause
|
||||
(l/warn :hint "unexpected exception on database error logger" :cause cause))))
|
||||
|
||||
(defmethod ig/assert-key ::reporter
|
||||
[_ params]
|
||||
(assert (db/pool? (::db/pool params)) "expect valid database pool"))
|
||||
@@ -191,9 +154,6 @@
|
||||
(::audit/id item)
|
||||
(handle-audit-event cfg item)
|
||||
|
||||
(::rlimit/id item)
|
||||
(handle-rlimit-event cfg item)
|
||||
|
||||
:else
|
||||
(l/warn :hint "received unexpected item" :item item))
|
||||
|
||||
|
||||
@@ -9,12 +9,10 @@
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.pprint :as pp]
|
||||
[app.common.uri :as u]
|
||||
[app.config :as cf]
|
||||
[app.http.client :as http]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.rpc.rlimit :as-alias rlimit]
|
||||
[app.util.json :as json]
|
||||
[integrant.core :as ig]
|
||||
[promesa.exec :as px]
|
||||
@@ -24,28 +22,21 @@
|
||||
|
||||
(defn- send-mattermost-notification!
|
||||
[cfg {:keys [id] :as report}]
|
||||
(let [type (get report :type)
|
||||
text (str "#" type " | " (get report :hint) "\n"
|
||||
(when id
|
||||
(str (u/join (cf/get :public-uri) "/dbg/error/" id) " "))
|
||||
|
||||
|
||||
(let [url (u/join (cf/get :public-uri) "/dbg/error/" id)
|
||||
|
||||
text (str "Exception: " url " "
|
||||
(when-let [pid (:profile-id report)]
|
||||
(if (uuid? pid)
|
||||
(str "(pid: #uuid-" pid ")")
|
||||
(str "(pid: #ip-" pid ")")))
|
||||
(str "(pid: #uuid-" pid ")"))
|
||||
"\n"
|
||||
"- host: #" (:host report) "\n"
|
||||
"- tenant: #" (:tenant report) "\n"
|
||||
"- origin: #" (:origin report) "\n"
|
||||
(when-let [href (get report :href)]
|
||||
(str "- href: `" href "`\n"))
|
||||
(when-let [version (get report :frontend-version)]
|
||||
(str "- frontend-version: `" version "`\n"))
|
||||
(when-let [version (get report :backend-version)]
|
||||
(str "- backend-version: `" version "`\n"))
|
||||
"- href: `" (:href report) "`\n"
|
||||
"- frontend-version: `" (:frontend-version report) "`\n"
|
||||
"- backend-version: `" (:backend-version report) "`\n"
|
||||
"\n"
|
||||
(when-let [info (:info report)]
|
||||
(str "```\n" info "```"))
|
||||
(when-let [trace (:trace report)]
|
||||
(str "```\n"
|
||||
"Trace:\n"
|
||||
@@ -63,15 +54,13 @@
|
||||
(l/warn :hint "error on sending data"
|
||||
:response (pr-str resp)))))
|
||||
|
||||
(defn- log-record->report
|
||||
[{:keys [::l/context ::l/id ::l/cause ::l/message] :as record}]
|
||||
(defn- record->report
|
||||
[{:keys [::l/context ::l/id ::l/cause] :as record}]
|
||||
(assert (l/valid-record? record) "expectd valid log record")
|
||||
|
||||
(let [public-uri (cf/get :public-uri)]
|
||||
{:id id
|
||||
:type "exception"
|
||||
:origin "logging"
|
||||
:hint (or (some-> cause ex-message) @message)
|
||||
:tenant (cf/get :tenant)
|
||||
:host (cf/get :host)
|
||||
:backend-version (:full cf/version)
|
||||
@@ -85,9 +74,7 @@
|
||||
(defn- audit-event->report
|
||||
[{:keys [::audit/context ::audit/props ::audit/id] :as event}]
|
||||
{:id id
|
||||
:type "exception"
|
||||
:origin "audit-log"
|
||||
:hint (get props :hint)
|
||||
:tenant (cf/get :tenant)
|
||||
:host (cf/get :host)
|
||||
:backend-version (:full cf/version)
|
||||
@@ -95,35 +82,18 @@
|
||||
:profile-id (:audit/profile-id event)
|
||||
:href (get props :href)})
|
||||
|
||||
(defn- rlimit-event->report
|
||||
[event]
|
||||
{:id (::rlimit/id event)
|
||||
:type "notification"
|
||||
:origin "rlimit"
|
||||
:hint (str "rlimit reject of "
|
||||
(::rlimit/method event)
|
||||
" for "
|
||||
(::rlimit/uid event))
|
||||
:tenant (cf/get :tenant)
|
||||
:host (cf/get :host)
|
||||
:backend-version (:full cf/version)
|
||||
:profile-id (::rlimit/profile-id event)
|
||||
:info (with-out-str
|
||||
(println "Rejected by:")
|
||||
(println "------------")
|
||||
(println "Method: " (::rlimit/method event))
|
||||
(println "Limit Name: " (::rlimit/name event))
|
||||
(println "Limit Strategy:" (::rlimit/strategy event))
|
||||
(println)
|
||||
(println "Results & Config:")
|
||||
(println "-----------------")
|
||||
(doseq [result (::rlimit/results event)]
|
||||
(pp/pprint (into (sorted-map) result))))})
|
||||
|
||||
(defn- handle-event
|
||||
[cfg event event->report]
|
||||
(defn- handle-log-record
|
||||
[cfg record]
|
||||
(try
|
||||
(let [report (event->report event)]
|
||||
(let [report (record->report record)]
|
||||
(send-mattermost-notification! cfg report))
|
||||
(catch Throwable cause
|
||||
(l/warn :hint "unhandled error" :cause cause))))
|
||||
|
||||
(defn- handle-audit-event
|
||||
[cfg record]
|
||||
(try
|
||||
(let [report (audit-event->report record)]
|
||||
(send-mattermost-notification! cfg report))
|
||||
(catch Throwable cause
|
||||
(l/warn :hint "unhandled error" :cause cause))))
|
||||
@@ -146,13 +116,10 @@
|
||||
(when @enabled
|
||||
(cond
|
||||
(::l/id item)
|
||||
(handle-event cfg item log-record->report)
|
||||
(handle-log-record cfg item)
|
||||
|
||||
(::audit/id item)
|
||||
(handle-event cfg item audit-event->report)
|
||||
|
||||
(::rlimit/id item)
|
||||
(handle-event cfg item rlimit-event->report)
|
||||
(handle-audit-event cfg item)
|
||||
|
||||
:else
|
||||
(l/warn :hint "received unexpected item" :item item)))
|
||||
|
||||
@@ -317,13 +317,7 @@
|
||||
::climit/enabled (contains? cf/flags :rpc-climit)}
|
||||
|
||||
:app.rpc/rlimit
|
||||
{::wrk/executor (ig/ref ::wrk/netty-executor)
|
||||
|
||||
:app.loggers.mattermost/reporter
|
||||
(ig/ref :app.loggers.mattermost/reporter)
|
||||
|
||||
:app.loggers.database/reporter
|
||||
(ig/ref :app.loggers.database/reporter)}
|
||||
{::wrk/executor (ig/ref ::wrk/netty-executor)}
|
||||
|
||||
:app.rpc/methods
|
||||
{::http.client/client (ig/ref ::http.client/client)
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
[methods]
|
||||
(let [methods (update-vals methods peek)]
|
||||
(fn [{:keys [params path-params method] :as request}]
|
||||
(let [handler-name (:method-name path-params)
|
||||
(let [handler-name (:type path-params)
|
||||
etag (yreq/get-header request "if-none-match")
|
||||
|
||||
key-id (get request ::http/auth-key-id)
|
||||
@@ -227,8 +227,8 @@
|
||||
(wrap-authentication cfg $ mdata)))
|
||||
|
||||
(defn- process-method
|
||||
[cfg wrap-fn [f mdata]]
|
||||
(l/wrn :hint "add method" :module (::module cfg) :type (::type cfg) :name (::sv/name mdata))
|
||||
[cfg module wrap-fn [f mdata]]
|
||||
(l/trc :hint "add method" :module module :name (::sv/name mdata))
|
||||
(let [f (wrap-fn cfg f mdata)
|
||||
k (keyword (::sv/name mdata))]
|
||||
[k [mdata (partial f cfg)]]))
|
||||
@@ -239,7 +239,7 @@
|
||||
|
||||
(defn- resolve-methods
|
||||
[cfg]
|
||||
(let [cfg (assoc cfg ::module "main" ::type "command" ::metrics-id :rpc-main-timing)]
|
||||
(let [cfg (assoc cfg ::type "command" ::metrics-id :rpc-command-timing)]
|
||||
(->> (sv/scan-ns
|
||||
'app.rpc.commands.access-token
|
||||
'app.rpc.commands.audit
|
||||
@@ -266,7 +266,7 @@
|
||||
'app.rpc.commands.verify-token
|
||||
'app.rpc.commands.viewer
|
||||
'app.rpc.commands.webhooks)
|
||||
(map (partial process-method cfg wrap))
|
||||
(map (partial process-method cfg "rpc" wrap))
|
||||
(into {}))))
|
||||
|
||||
(def ^:private schema:methods-params
|
||||
@@ -298,13 +298,13 @@
|
||||
|
||||
(defn- resolve-management-methods
|
||||
[cfg]
|
||||
(let [cfg (assoc cfg ::module "management" ::type "command" ::metrics-id :rpc-management-timing)
|
||||
(let [cfg (assoc cfg ::type "management" ::metrics-id :rpc-management-timing)
|
||||
mods (cond->> (list 'app.rpc.management.exporter)
|
||||
(contains? cf/flags :nitrate)
|
||||
(cons 'app.rpc.management.nitrate))]
|
||||
|
||||
(->> (apply sv/scan-ns mods)
|
||||
(map (partial process-method cfg wrap-management))
|
||||
(map (partial process-method cfg "management" wrap-management))
|
||||
(into {}))))
|
||||
|
||||
(def ^:private schema:management-methods-params
|
||||
@@ -359,7 +359,7 @@
|
||||
(let [public-uri (cf/get :public-uri)]
|
||||
["/api"
|
||||
["/management"
|
||||
["/methods/:method-name"
|
||||
["/methods/:type"
|
||||
{:middleware [[mw/shared-key-auth shared-keys]
|
||||
[session/authz cfg]]
|
||||
:handler (make-rpc-handler management-methods)}]
|
||||
@@ -370,7 +370,7 @@
|
||||
:description "MANAGEMENT API")]
|
||||
|
||||
["/main"
|
||||
["/methods/:method-name"
|
||||
["/methods/:type"
|
||||
{:middleware [[mw/cors]
|
||||
[sec/client-header-check]
|
||||
[session/authz cfg]
|
||||
@@ -388,7 +388,7 @@
|
||||
["/openapi" {:handler (redirect (u/join public-uri "/api/main/doc/openapi"))}]
|
||||
["/openapi.join" {:handler (redirect (u/join public-uri "/api/main/doc/openapi.json"))}]
|
||||
|
||||
["/rpc/command/:method-name"
|
||||
["/rpc/command/:type"
|
||||
{:middleware [[mw/cors]
|
||||
[sec/client-header-check]
|
||||
[session/authz cfg]
|
||||
|
||||
@@ -52,8 +52,6 @@
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.http :as-alias http]
|
||||
[app.loggers.database :as loggers.db]
|
||||
[app.loggers.mattermost :as loggers.mm]
|
||||
[app.redis :as rds]
|
||||
[app.redis.script :as-alias rscript]
|
||||
[app.rpc :as-alias rpc]
|
||||
@@ -173,9 +171,9 @@
|
||||
:hint (str/ffmt "looks like '%' does not have a valid format" opts))))
|
||||
|
||||
(defmethod process-limit :bucket
|
||||
[rconn profile-id now {:keys [::key ::params ::method ::capacity ::interval ::rate] :as limit}]
|
||||
[rconn profile-id now {:keys [::key ::params ::service ::capacity ::interval ::rate] :as limit}]
|
||||
(let [script (-> bucket-rate-limit-script
|
||||
(assoc ::rscript/keys [(str key "." method "." profile-id)])
|
||||
(assoc ::rscript/keys [(str key "." service "." profile-id)])
|
||||
(assoc ::rscript/vals (conj params (->seconds now))))
|
||||
result (rds/eval rconn script)
|
||||
allowed? (boolean (nth result 0))
|
||||
@@ -183,7 +181,7 @@
|
||||
reset (* (/ (inst-ms interval) rate)
|
||||
(- capacity remaining))]
|
||||
(l/trace :hint "limit processed"
|
||||
:method method
|
||||
:service service
|
||||
:limit (name (::name limit))
|
||||
:strategy (name (::strategy limit))
|
||||
:opts (::opts limit)
|
||||
@@ -195,17 +193,17 @@
|
||||
(assoc ::lresult/remaining remaining))))
|
||||
|
||||
(defmethod process-limit :window
|
||||
[rconn uid now {:keys [::permits ::unit ::key ::method] :as limit}]
|
||||
[rconn profile-id now {:keys [::permits ::unit ::key ::service] :as limit}]
|
||||
(let [ts (ct/truncate now unit)
|
||||
ttl (ct/diff now (ct/plus ts {unit 1}))
|
||||
script (-> window-rate-limit-script
|
||||
(assoc ::rscript/keys [(str key "." method "." uid "." (ct/format-inst ts))])
|
||||
(assoc ::rscript/keys [(str key "." service "." profile-id "." (ct/format-inst ts))])
|
||||
(assoc ::rscript/vals [permits (->seconds ttl)]))
|
||||
result (rds/eval rconn script)
|
||||
allowed? (boolean (nth result 0))
|
||||
remaining (nth result 1)]
|
||||
(l/trace :hint "limit processed"
|
||||
:method method
|
||||
:service service
|
||||
:name (name (::name limit))
|
||||
:strategy (name (::strategy limit))
|
||||
:opts (::opts limit)
|
||||
@@ -213,13 +211,12 @@
|
||||
:remaining remaining)
|
||||
(-> limit
|
||||
(assoc ::lresult/allowed allowed?)
|
||||
(assoc ::lresult/timestamp ts)
|
||||
(assoc ::lresult/remaining remaining)
|
||||
(assoc ::lresult/reset (ct/plus ts {unit 1})))))
|
||||
|
||||
(defn- process-limits
|
||||
[{:keys [::rds/conn] :as cfg} uid limits now]
|
||||
(let [results (into [] (map (partial process-limit conn uid now)) limits)
|
||||
[rconn profile-id limits now]
|
||||
(let [results (into [] (map (partial process-limit rconn profile-id now)) limits)
|
||||
remaining (->> results
|
||||
(d/index-by ::name ::lresult/remaining)
|
||||
(uri/map->query-string))
|
||||
@@ -230,22 +227,11 @@
|
||||
rejected (d/seek (complement ::lresult/allowed) results)]
|
||||
|
||||
(when rejected
|
||||
(let [event {::id (uuid/next)
|
||||
::uid uid
|
||||
::method (-> rejected ::method name)
|
||||
::name (-> rejected ::name name)
|
||||
::strategy (-> rejected ::strategy name)
|
||||
::results results}]
|
||||
|
||||
(l/warn :hint "rejected rate limit"
|
||||
:method (-> rejected ::method name)
|
||||
:name (-> rejected ::name name)
|
||||
:strategy (-> rejected ::strategy name)
|
||||
:uid (str uid)
|
||||
:report-id (:id event))
|
||||
|
||||
(loggers.mm/emit cfg event)
|
||||
(loggers.db/emit cfg event)))
|
||||
(l/warn :hint "rejected rate limit"
|
||||
:profile-id (str profile-id)
|
||||
:limit-service (-> rejected ::service name)
|
||||
:limit-name (-> rejected ::name name)
|
||||
:limit-strategy (-> rejected ::strategy name)))
|
||||
|
||||
{::enabled true
|
||||
::allowed (not (some? rejected))
|
||||
@@ -258,7 +244,7 @@
|
||||
[state skey sname]
|
||||
(when-let [limits (or (get-in @state [::limits skey])
|
||||
(get-in @state [::limits :default]))]
|
||||
(into [] (map #(assoc % ::method sname)) limits)))
|
||||
(into [] (map #(assoc % ::service sname)) limits)))
|
||||
|
||||
(defn- get-uid
|
||||
[{:keys [::rpc/profile-id] :as params}]
|
||||
@@ -268,10 +254,10 @@
|
||||
uuid/zero)))
|
||||
|
||||
(defn- process-request'
|
||||
[cfg limits params]
|
||||
[{:keys [::rds/conn] :as cfg} limits params]
|
||||
(try
|
||||
(let [uid (get-uid params)
|
||||
result (process-limits cfg uid limits (ct/now))]
|
||||
result (process-limits conn uid limits (ct/now))]
|
||||
(if (contains? cf/flags :soft-rpc-rlimit)
|
||||
{::enabled false}
|
||||
result))
|
||||
@@ -289,8 +275,8 @@
|
||||
(assert (or (nil? rlimit) (valid-rlimit-instance? rlimit)) "expected a valid rlimit instance")
|
||||
|
||||
(if rlimit
|
||||
(let [skey (keyword (::rpc/module cfg) (->> mdata ::sv/spec name))
|
||||
sname (str (::rpc/module cfg) "." (->> mdata ::sv/spec name))
|
||||
(let [skey (keyword (::rpc/type cfg) (->> mdata ::sv/spec name))
|
||||
sname (str (::rpc/type cfg) "." (->> mdata ::sv/spec name))
|
||||
cfg (-> cfg
|
||||
(assoc ::skey skey)
|
||||
(assoc ::sname sname))]
|
||||
|
||||
@@ -104,13 +104,13 @@
|
||||
(assoc-in [::db/pool ::db/password] (:database-password config))
|
||||
(assoc-in [:app.rpc/methods :app.setup/templates] templates)
|
||||
(assoc-in [:app.rpc/methods :app.setup/templates] templates)
|
||||
(update :app.rpc/rlimit assoc
|
||||
:app.loggers.mattermost/reporter nil
|
||||
:app.loggers.database/reporter nil)
|
||||
(update :app.rpc/methods assoc
|
||||
:app.setup/templates templates
|
||||
:app.loggers.mattermost/reporter nil
|
||||
:app.loggers.database/reporter nil)
|
||||
(update :app.rpc/methods
|
||||
(fn [state]
|
||||
(-> state
|
||||
(assoc :app.setup/templates templates)
|
||||
(assoc :app.loggers.mattermost/reporter nil)
|
||||
(assoc :app.loggers.database/reporter nil))))
|
||||
|
||||
(dissoc :app.srepl/server
|
||||
:app.http/server
|
||||
:app.http/route
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"design-tokens/v1"
|
||||
"text-editor/v2-html-paste"
|
||||
"text-editor/v2"
|
||||
"text-editor-wasm/v1"
|
||||
"render-wasm/v1"
|
||||
"variants/v1"})
|
||||
|
||||
@@ -78,6 +79,7 @@
|
||||
"plugins/runtime"
|
||||
"text-editor/v2-html-paste"
|
||||
"text-editor/v2"
|
||||
"text-editor-wasm/v1"
|
||||
"tokens/numeric-input"
|
||||
"render-wasm/v1"})
|
||||
|
||||
@@ -127,6 +129,7 @@
|
||||
:feature-design-tokens "design-tokens/v1"
|
||||
:feature-text-editor-v2 "text-editor/v2"
|
||||
:feature-text-editor-v2-html-paste "text-editor/v2-html-paste"
|
||||
:feature-text-editor-wasm "text-editor-wasm/v1"
|
||||
:feature-render-wasm "render-wasm/v1"
|
||||
:feature-variants "variants/v1"
|
||||
:feature-token-input "tokens/numeric-input"
|
||||
|
||||
@@ -8,9 +8,7 @@ desc: Customize your Penpot instance today. Learn how to install with Elestio, D
|
||||
This guide explains how to get your own Penpot instance, running on a machine you control,
|
||||
to test it, use it by you or your team, or even customize and extend it any way you like.
|
||||
|
||||
If you need more context you can look at the <a
|
||||
href="https://community.penpot.app/t/self-hosting-penpot-i/2336" target="_blank">post
|
||||
about self-hosting</a> in Penpot community.
|
||||
For additional context, see the post <a href="https://penpot.app/blog/how-to-self-host-penpot/" target="_blank">How to self-host Penpot: A technical implementation guide</a> on the Penpot blog.
|
||||
|
||||
<strong>The experience stays the same, whether you use
|
||||
Penpot <a href="https://design.penpot.app" target="_blank">in the cloud</a>
|
||||
|
||||
@@ -14,7 +14,7 @@ Keep in mind that database size doesn't grow strictly proportionally with user c
|
||||
|
||||
# About Valkey / Redis requirements
|
||||
|
||||
"Valkey is mainly used for coordinating websocket notifications and, since Penpot 2.11, as a cache. Therefore, disk storage will not be necessary as it will use the instance's RAM.
|
||||
Valkey is mainly used for coordinating websocket notifications and, since Penpot 2.11, as a cache. Therefore, disk storage will not be necessary as it will use the instance's RAM.
|
||||
|
||||
To prevent the cache from hogging all the system's RAM usage, it is recommended to use two configuration parameters which, both in the docker-compose.yaml provided by Penpot and in the official Helm Chart, come with default parameters that should be sufficient for most deployments:
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"~:features": {
|
||||
"~#set": [
|
||||
"fdata/path-data",
|
||||
"plugins/runtime",
|
||||
"design-tokens/v1",
|
||||
"variants/v1",
|
||||
"layout/grid",
|
||||
"styles/v2",
|
||||
"fdata/objects-map",
|
||||
"text-editor/v2",
|
||||
"render-wasm/v1",
|
||||
"components/v2",
|
||||
"fdata/shape-data-type"
|
||||
]
|
||||
},
|
||||
"~:team-id": "~ud7430f09-4f59-8049-8007-6277bb7586f6",
|
||||
"~:permissions": {
|
||||
"~:type": "~:membership",
|
||||
"~:is-owner": true,
|
||||
"~:is-admin": true,
|
||||
"~:can-edit": true,
|
||||
"~:can-read": true,
|
||||
"~:is-logged": true
|
||||
},
|
||||
"~:has-media-trimmed": false,
|
||||
"~:comment-thread-seqn": 0,
|
||||
"~:name": "test_color_blending",
|
||||
"~:revn": 78,
|
||||
"~:modified-at": "~m1770820738388",
|
||||
"~:vern": 0,
|
||||
"~:id": "~ub15901d7-d46d-8056-8007-8d5e34fc1f0c",
|
||||
"~:is-shared": false,
|
||||
"~:migrations": {
|
||||
"~#ordered-set": [
|
||||
"legacy-2",
|
||||
"legacy-3",
|
||||
"legacy-5",
|
||||
"legacy-6",
|
||||
"legacy-7",
|
||||
"legacy-8",
|
||||
"legacy-9",
|
||||
"legacy-10",
|
||||
"legacy-11",
|
||||
"legacy-12",
|
||||
"legacy-13",
|
||||
"legacy-14",
|
||||
"legacy-16",
|
||||
"legacy-17",
|
||||
"legacy-18",
|
||||
"legacy-19",
|
||||
"legacy-25",
|
||||
"legacy-26",
|
||||
"legacy-27",
|
||||
"legacy-28",
|
||||
"legacy-29",
|
||||
"legacy-31",
|
||||
"legacy-32",
|
||||
"legacy-33",
|
||||
"legacy-34",
|
||||
"legacy-36",
|
||||
"legacy-37",
|
||||
"legacy-38",
|
||||
"legacy-39",
|
||||
"legacy-40",
|
||||
"legacy-41",
|
||||
"legacy-42",
|
||||
"legacy-43",
|
||||
"legacy-44",
|
||||
"legacy-45",
|
||||
"legacy-46",
|
||||
"legacy-47",
|
||||
"legacy-48",
|
||||
"legacy-49",
|
||||
"legacy-50",
|
||||
"legacy-51",
|
||||
"legacy-52",
|
||||
"legacy-53",
|
||||
"legacy-54",
|
||||
"legacy-55",
|
||||
"legacy-56",
|
||||
"legacy-57",
|
||||
"legacy-59",
|
||||
"legacy-62",
|
||||
"legacy-65",
|
||||
"legacy-66",
|
||||
"legacy-67",
|
||||
"0001-remove-tokens-from-groups",
|
||||
"0002-normalize-bool-content-v2",
|
||||
"0002-clean-shape-interactions",
|
||||
"0003-fix-root-shape",
|
||||
"0003-convert-path-content-v2",
|
||||
"0005-deprecate-image-type",
|
||||
"0006-fix-old-texts-fills",
|
||||
"0008-fix-library-colors-v4",
|
||||
"0009-clean-library-colors",
|
||||
"0009-add-partial-text-touched-flags",
|
||||
"0010-fix-swap-slots-pointing-non-existent-shapes",
|
||||
"0011-fix-invalid-text-touched-flags",
|
||||
"0012-fix-position-data",
|
||||
"0013-fix-component-path",
|
||||
"0013-clear-invalid-strokes-and-fills",
|
||||
"0014-fix-tokens-lib-duplicate-ids",
|
||||
"0014-clear-components-nil-objects",
|
||||
"0015-fix-text-attrs-blank-strings",
|
||||
"0015-clean-shadow-color",
|
||||
"0016-copy-fills-from-position-data-to-text-node"
|
||||
]
|
||||
},
|
||||
"~:version": 67,
|
||||
"~:project-id": "~ud7430f09-4f59-8049-8007-6277bb765abd",
|
||||
"~:created-at": "~m1770741329904",
|
||||
"~:backend": "legacy-db",
|
||||
"~:data": {
|
||||
"~:pages": [
|
||||
"~ub15901d7-d46d-8056-8007-8d5e34fc1f0d"
|
||||
],
|
||||
"~:pages-index": {
|
||||
"~ub15901d7-d46d-8056-8007-8d5e34fc1f0d": {
|
||||
"~:objects": {
|
||||
"~#penpot/objects-map/v2": {
|
||||
"~u00000000-0000-0000-0000-000000000000": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"Root Frame\",\"~:width\",0.01,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0.0,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.01]],[\"^:\",[\"^ \",\"~:x\",0.0,\"~:y\",0.01]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1.0,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",0.01,\"~:height\",0.01,\"~:x1\",0,\"~:y1\",0,\"~:x2\",0.01,\"~:y2\",0.01]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#FFFFFF\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^H\",0.01,\"~:flip-y\",null,\"~:shapes\",[\"~u3b7d4c1f-3b79-80e5-8007-8d5e38c5a297\",\"~udb80df91-a3a3-803b-8007-8e379b5fd50f\",\"~udb80df91-a3a3-803b-8007-8e38034ff7c8\",\"~udb80df91-a3a3-803b-8007-8e37a71c9d28\",\"~udb80df91-a3a3-803b-8007-8e384d8c53b9\",\"~udb80df91-a3a3-803b-8007-8e37c09b4084\",\"~u18522c44-655d-8050-8007-8e89f4bdc0c4\",\"~u097859f1-ca3b-80ba-8007-8e8beb99a3f5\",\"~u18522c44-655d-8050-8007-8e89f4bdc0c5\",\"~u097859f1-ca3b-80ba-8007-8e8bfca43303\",\"~ufb1f50bf-1bff-8030-8007-8e8c3bd8fcd7\",\"~u18522c44-655d-8050-8007-8e89f4bdc0c6\"]]]",
|
||||
"~u097859f1-ca3b-80ba-8007-8e8bfca43303": "[\"~#shape\",[\"^ \",\"~:y\",-637.0000057220459,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",336.9999895095825,\"~:y\",-637.000005722046]],[\"^<\",[\"^ \",\"~:x\",636.9999995231628,\"~:y\",-637.000005722046]],[\"^<\",[\"^ \",\"~:x\",636.9999995231628,\"~:y\",-337.00000858306885]],[\"^<\",[\"^ \",\"~:x\",336.9999895095825,\"~:y\",-337.00000858306885]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e380b12ac2a\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~u097859f1-ca3b-80ba-8007-8e8bfca43303\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",10,\"~:stroke-color\",\"#4bff00\",\"~:stroke-opacity\",1],[\"^ \",\"^J\",\"^K\",\"^L\",\"~:outer\",\"^N\",10,\"^O\",\"#333fbd\",\"^P\",1],[\"^ \",\"^J\",\"^K\",\"^L\",\"~:inner\",\"^N\",10,\"^O\",\"#ff0000\",\"^P\",1]],\"~:x\",336.9999895095825,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",336.9999895095825,\"~:y\",-637.0000057220459,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",336.9999895095825,\"~:y1\",-637.0000057220459,\"~:x2\",636.9999995231628,\"~:y2\",-337.00000858306885]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^11\",\"#ff0000\",\"^12\",1]],\"~:flip-x\",null,\"^W\",299.99999713897705,\"~:flip-y\",null]]",
|
||||
"~udb80df91-a3a3-803b-8007-8e384d8c53b9": "[\"~#shape\",[\"^ \",\"~:y\",450.99999806284904,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Ellipse\",\"~:width\",300.0000065565109,\"~:type\",\"~:circle\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1021.0000203847885,\"~:y\",450.99999806284904]],[\"^<\",[\"^ \",\"~:x\",1321.0000269412994,\"~:y\",450.99999806284904]],[\"^<\",[\"^ \",\"~:x\",1321.0000269412994,\"~:y\",751.0000142753124]],[\"^<\",[\"^ \",\"~:x\",1021.0000203847885,\"~:y\",751.0000142753124]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e37b7ddd15c\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"^@\",\"~udb80df91-a3a3-803b-8007-8e384d8c53b9\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:inner\",\"~:stroke-width\",20,\"~:stroke-color\",\"#333fbd\",\"~:stroke-opacity\",1]],\"~:x\",1021.0000203847885,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1021.0000203847885,\"~:y\",450.99999806284904,\"^8\",300.0000065565109,\"~:height\",300.0000162124634,\"~:x1\",1021.0000203847885,\"~:y1\",450.99999806284904,\"~:x2\",1321.0000269412994,\"~:y2\",751.0000142753124]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^W\",\"#ff0000\",\"^X\",1]],\"~:flip-x\",null,\"^Q\",300.0000162124634,\"~:flip-y\",null]]",
|
||||
"~udb80df91-a3a3-803b-8007-8e379b5fd50f": "[\"~#shape\",[\"^ \",\"~:y\",82.00000368146124,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",686.7500124588994,\"~:y\",82.00000368146122]],[\"^<\",[\"^ \",\"~:x\",986.7500224724797,\"~:y\",82.00000368146122]],[\"^<\",[\"^ \",\"~:x\",986.7500224724797,\"~:y\",382.0000008204383]],[\"^<\",[\"^ \",\"~:x\",686.7500124588994,\"~:y\",382.0000008204383]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~udb80df91-a3a3-803b-8007-8e379b5fd50f\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",686.7500124588994,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",686.7500124588994,\"~:y\",82.00000368146124,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",686.7500124588994,\"~:y1\",82.00000368146124,\"~:x2\",986.7500224724797,\"~:y2\",382.0000008204383]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^P\",\"#ff0000\",\"^Q\",1]],\"~:flip-x\",null,\"^J\",299.99999713897705,\"~:flip-y\",null]]",
|
||||
"~u3b7d4c1f-3b79-80e5-8007-8d5e38c5a297": "[\"~#shape\",[\"^ \",\"~:y\",81.9999960520667,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",337.0000200882939,\"~:y\",81.99999605206669]],[\"^<\",[\"^ \",\"~:x\",637.0000301018742,\"~:y\",81.99999605206669]],[\"^<\",[\"^ \",\"~:x\",637.0000301018742,\"~:y\",381.99999319104376]],[\"^<\",[\"^ \",\"~:x\",337.0000200882939,\"~:y\",381.99999319104376]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:blur\",[\"^ \",\"~:id\",\"~u432cbb09-2ee7-80bf-8007-8d660b2f52ad\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~u3b7d4c1f-3b79-80e5-8007-8d5e38c5a297\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",337.0000200882939,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",337.0000200882939,\"~:y\",81.9999960520667,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",337.0000200882939,\"~:y1\",81.9999960520667,\"~:x2\",637.0000301018742,\"~:y2\",381.99999319104376]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^T\",\"#ff0000\",\"^U\",1]],\"~:flip-x\",null,\"^N\",299.99999713897705,\"~:flip-y\",null]]",
|
||||
"~ufb1f50bf-1bff-8030-8007-8e8c3bd8fcd7": "[\"~#shape\",[\"^ \",\"~:y\",-629.9999999999998,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1037,\"~:y\",-630]],[\"^<\",[\"^ \",\"~:x\",1337.0000100135803,\"~:y\",-630]],[\"^<\",[\"^ \",\"~:x\",1337.0000100135803,\"~:y\",-330.0000028610228]],[\"^<\",[\"^ \",\"~:x\",1037,\"~:y\",-330.0000028610228]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e380b12ac2a\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~ufb1f50bf-1bff-8030-8007-8e8c3bd8fcd7\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:outer\",\"~:stroke-width\",10,\"~:stroke-color\",\"#333fbd\",\"~:stroke-opacity\",1],[\"^ \",\"^J\",\"^K\",\"^L\",\"~:inner\",\"^N\",10,\"^O\",\"#ff0000\",\"^P\",1],[\"^ \",\"^J\",\"^K\",\"^L\",\"~:center\",\"^N\",10,\"^O\",\"#4bff00\",\"^P\",1]],\"~:x\",1037,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1037,\"~:y\",-629.9999999999998,\"^8\",300.0000100135803,\"~:height\",299.999997138977,\"~:x1\",1037,\"~:y1\",-629.9999999999998,\"~:x2\",1337.0000100135803,\"~:y2\",-330.0000028610228]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^11\",\"#ff0000\",\"^12\",1]],\"~:flip-x\",null,\"^W\",299.999997138977,\"~:flip-y\",null]]",
|
||||
"~u097859f1-ca3b-80ba-8007-8e8beb99a3f5": "[\"~#shape\",[\"^ \",\"~:y\",-626.0000057220459,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",687.0000123977661,\"~:y\",-626.000005722046]],[\"^<\",[\"^ \",\"~:x\",987.0000224113464,\"~:y\",-626.000005722046]],[\"^<\",[\"^ \",\"~:x\",987.0000224113464,\"~:y\",-326.00000858306885]],[\"^<\",[\"^ \",\"~:x\",687.0000123977661,\"~:y\",-326.00000858306885]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e380b12ac2a\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~u097859f1-ca3b-80ba-8007-8e8beb99a3f5\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:inner\",\"~:stroke-width\",10,\"~:stroke-color\",\"#333fbd\",\"~:stroke-opacity\",1],[\"^ \",\"^J\",\"^K\",\"^L\",\"^M\",\"^N\",10,\"^O\",\"#ff0000\",\"^P\",1]],\"~:x\",687.0000123977661,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",687.0000123977661,\"~:y\",-626.0000057220459,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",687.0000123977661,\"~:y1\",-626.0000057220459,\"~:x2\",987.0000224113464,\"~:y2\",-326.00000858306885]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^[\",\"#ff0000\",\"^10\",1]],\"~:flip-x\",null,\"^U\",299.99999713897705,\"~:flip-y\",null]]",
|
||||
"~udb80df91-a3a3-803b-8007-8e37a71c9d28": "[\"~#shape\",[\"^ \",\"~:y\",450.99999806284904,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Ellipse\",\"~:width\",300.0000065565109,\"~:type\",\"~:circle\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",337.0000203847885,\"~:y\",450.99999806284904]],[\"^<\",[\"^ \",\"~:x\",637.0000269412994,\"~:y\",450.99999806284904]],[\"^<\",[\"^ \",\"~:x\",637.0000269412994,\"~:y\",751.0000142753124]],[\"^<\",[\"^ \",\"~:x\",337.0000203847885,\"~:y\",751.0000142753124]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e37b7ddd15c\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"^@\",\"~udb80df91-a3a3-803b-8007-8e37a71c9d28\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",337.0000203847885,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",337.0000203847885,\"~:y\",450.99999806284904,\"^8\",300.0000065565109,\"~:height\",300.0000162124634,\"~:x1\",337.0000203847885,\"~:y1\",450.99999806284904,\"~:x2\",637.0000269412994,\"~:y2\",751.0000142753124]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^P\",\"#ff0000\",\"^Q\",1]],\"~:flip-x\",null,\"^J\",300.0000162124634,\"~:flip-y\",null]]",
|
||||
"~u18522c44-655d-8050-8007-8e89f4bdc0c5": "[\"~#shape\",[\"^ \",\"~:y\",-287.0000057220459,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",337.00002002716064,\"~:y\",-287.000005722046]],[\"^<\",[\"^ \",\"~:x\",637.000030040741,\"~:y\",-287.000005722046]],[\"^<\",[\"^ \",\"~:x\",637.000030040741,\"~:y\",12.999991416931152]],[\"^<\",[\"^ \",\"~:x\",337.00002002716064,\"~:y\",12.999991416931152]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e380b12ac2a\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~u18522c44-655d-8050-8007-8e89f4bdc0c5\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:outer\",\"~:stroke-width\",10,\"~:stroke-color\",\"#333fbd\",\"~:stroke-opacity\",1],[\"^ \",\"^J\",\"^K\",\"^L\",\"~:inner\",\"^N\",10,\"^O\",\"#ff0000\",\"^P\",1]],\"~:x\",337.00002002716064,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",337.00002002716064,\"~:y\",-287.0000057220459,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",337.00002002716064,\"~:y1\",-287.0000057220459,\"~:x2\",637.000030040741,\"~:y2\",12.999991416931152]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^10\",\"#ff0000\",\"^11\",1]],\"~:flip-x\",null,\"^V\",299.99999713897705,\"~:flip-y\",null]]",
|
||||
"~udb80df91-a3a3-803b-8007-8e37c09b4084": "[\"~#shape\",[\"^ \",\"~:y\",450.99999806284904,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Ellipse\",\"~:width\",300.0000065565109,\"~:type\",\"~:circle\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",679.0000203847885,\"~:y\",450.99999806284904]],[\"^<\",[\"^ \",\"~:x\",979.0000269412994,\"~:y\",450.99999806284904]],[\"^<\",[\"^ \",\"~:x\",979.0000269412994,\"~:y\",751.0000142753124]],[\"^<\",[\"^ \",\"~:x\",679.0000203847885,\"~:y\",751.0000142753124]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~udb80df91-a3a3-803b-8007-8e37c09b4084\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",679.0000203847885,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",679.0000203847885,\"~:y\",450.99999806284904,\"^8\",300.0000065565109,\"~:height\",300.0000162124634,\"~:x1\",679.0000203847885,\"~:y1\",450.99999806284904,\"~:x2\",979.0000269412994,\"~:y2\",751.0000142753124]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^L\",\"#ff0000\",\"^M\",1]],\"~:flip-x\",null,\"^F\",300.0000162124634,\"~:flip-y\",null]]",
|
||||
"~u18522c44-655d-8050-8007-8e89f4bdc0c4": "[\"~#shape\",[\"^ \",\"~:y\",-287.0000057220459,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",686.7500123977661,\"~:y\",-287.000005722046]],[\"^<\",[\"^ \",\"~:x\",986.7500224113464,\"~:y\",-287.000005722046]],[\"^<\",[\"^ \",\"~:x\",986.7500224113464,\"~:y\",12.999991416931152]],[\"^<\",[\"^ \",\"~:x\",686.7500123977661,\"~:y\",12.999991416931152]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e380b12ac2a\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~u18522c44-655d-8050-8007-8e89f4bdc0c4\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:inner\",\"~:stroke-width\",10,\"~:stroke-color\",\"#333fbd\",\"~:stroke-opacity\",0.5],[\"^ \",\"^J\",\"^K\",\"^L\",\"^M\",\"^N\",10,\"^O\",\"#ff0000\",\"^P\",1]],\"~:x\",686.7500123977661,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",686.7500123977661,\"~:y\",-287.0000057220459,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",686.7500123977661,\"~:y1\",-287.0000057220459,\"~:x2\",986.7500224113464,\"~:y2\",12.999991416931152]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^[\",\"#ff0000\",\"^10\",1]],\"~:flip-x\",null,\"^U\",299.99999713897705,\"~:flip-y\",null]]",
|
||||
"~udb80df91-a3a3-803b-8007-8e38034ff7c8": "[\"~#shape\",[\"^ \",\"~:y\",82.00000368146124,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1036.5000048295049,\"~:y\",82.00000368146122]],[\"^<\",[\"^ \",\"~:x\",1336.5000148430852,\"~:y\",82.00000368146122]],[\"^<\",[\"^ \",\"~:x\",1336.5000148430852,\"~:y\",382.0000008204383]],[\"^<\",[\"^ \",\"~:x\",1036.5000048295049,\"~:y\",382.0000008204383]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e380b12ac2a\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~udb80df91-a3a3-803b-8007-8e38034ff7c8\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:inner\",\"~:stroke-width\",10,\"~:stroke-color\",\"#333fbd\",\"~:stroke-opacity\",1]],\"~:x\",1036.5000048295049,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1036.5000048295049,\"~:y\",82.00000368146124,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",1036.5000048295049,\"~:y1\",82.00000368146124,\"~:x2\",1336.5000148430852,\"~:y2\",382.0000008204383]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^[\",\"#ff0000\",\"^10\",1]],\"~:flip-x\",null,\"^U\",299.99999713897705,\"~:flip-y\",null]]",
|
||||
"~u18522c44-655d-8050-8007-8e89f4bdc0c6": "[\"~#shape\",[\"^ \",\"~:y\",-287.0000057220459,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1036.5000047683716,\"~:y\",-287.000005722046]],[\"^<\",[\"^ \",\"~:x\",1336.500014781952,\"~:y\",-287.000005722046]],[\"^<\",[\"^ \",\"~:x\",1336.500014781952,\"~:y\",12.999991416931152]],[\"^<\",[\"^ \",\"~:x\",1036.5000047683716,\"~:y\",12.999991416931152]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e380b12ac2a\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~u18522c44-655d-8050-8007-8e89f4bdc0c6\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:inner\",\"~:stroke-width\",10,\"~:stroke-color\",\"#333fbd\",\"~:stroke-opacity\",1],[\"^ \",\"^J\",\"^K\",\"^L\",\"~:outer\",\"^N\",10,\"^O\",\"#ff0000\",\"^P\",1]],\"~:x\",1036.5000047683716,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1036.5000047683716,\"~:y\",-287.0000057220459,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",1036.5000047683716,\"~:y1\",-287.0000057220459,\"~:x2\",1336.500014781952,\"~:y2\",12.999991416931152]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^10\",\"#ff0000\",\"^11\",1]],\"~:flip-x\",null,\"^V\",299.99999713897705,\"~:flip-y\",null]]"
|
||||
}
|
||||
},
|
||||
"~:id": "~ub15901d7-d46d-8056-8007-8d5e34fc1f0d",
|
||||
"~:name": "Page 1"
|
||||
}
|
||||
},
|
||||
"~:id": "~ub15901d7-d46d-8056-8007-8d5e34fc1f0c",
|
||||
"~:options": {
|
||||
"~:components-v2": true,
|
||||
"~:base-font-size": "16px"
|
||||
}
|
||||
}
|
||||
}
|
||||
2868
frontend/playwright/data/render-wasm/get-file-stroke-styles.json
Normal file
@@ -10,7 +10,7 @@ export const WASM_FLAGS = [
|
||||
export class WasmWorkspacePage extends WorkspacePage {
|
||||
static async init(page) {
|
||||
await super.init(page);
|
||||
await WorkspacePage.mockConfigFlags(page, WASM_FLAGS);
|
||||
await WasmWorkspacePage.mockConfigFlags(page, WASM_FLAGS);
|
||||
|
||||
await page.addInitScript(() => {
|
||||
document.addEventListener("penpot:wasm:loaded", () => {
|
||||
@@ -27,6 +27,14 @@ export class WasmWorkspacePage extends WorkspacePage {
|
||||
});
|
||||
}
|
||||
|
||||
static async mockConfigFlags(page, flags) {
|
||||
await super.mockConfigFlags(page, [...WASM_FLAGS, ...flags]);
|
||||
}
|
||||
|
||||
async mockConfigFlags(flags) {
|
||||
return WasmWorkspacePage.mockConfigFlags(this.page, flags);
|
||||
}
|
||||
|
||||
constructor(page) {
|
||||
super(page);
|
||||
this.canvas = page.getByTestId("canvas-wasm-shapes");
|
||||
|
||||
@@ -165,6 +165,7 @@ test("Updates canvas background", async ({ page }) => {
|
||||
});
|
||||
await canvasBackgroundInput.fill("FABADA");
|
||||
await workspace.page.keyboard.press("Enter");
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
@@ -196,7 +197,7 @@ test("Renders a file with blurs applied to any kind of shape", async ({
|
||||
|
||||
test("Renders a file with shadows applied to any kind of shape", async ({
|
||||
page,
|
||||
}) => {
|
||||
}) => {
|
||||
const workspace = new WasmWorkspacePage(page);
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("render-wasm/get-file-shadows.json");
|
||||
@@ -290,6 +291,24 @@ test("Renders a file with nested clipping frames", async ({ page }) => {
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
|
||||
test("Renders clipped frames with strokes correctly (no double painting)", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = new WasmWorkspacePage(page);
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile(
|
||||
"render-wasm/get-file-frame-strokes-opacity.json",
|
||||
);
|
||||
|
||||
await workspace.goToWorkspace({
|
||||
id: "3144ac7c-a5cc-80e8-8007-8bbb29a4e56e",
|
||||
pageId: "3144ac7c-a5cc-80e8-8007-8bbb29a510ac",
|
||||
});
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
|
||||
test("Renders a clipped frame with a large blur drop shadow", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -305,3 +324,35 @@ test("Renders a clipped frame with a large blur drop shadow", async ({
|
||||
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
|
||||
test("Renders a file with solid, dotted, dashed and mixed stroke styles", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = new WasmWorkspacePage(page);
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("render-wasm/get-file-stroke-styles.json");
|
||||
|
||||
await workspace.goToWorkspace({
|
||||
id: "b888b894-3697-80d3-8006-51cc8a55c200",
|
||||
pageId: "b888b894-3697-80d3-8006-51cc8a55c210",
|
||||
});
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
|
||||
test("Renders shapes with multiple fills and blur", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = new WasmWorkspacePage(page);
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("render-wasm/get-file-fill-blend-blurs.json");
|
||||
|
||||
await workspace.goToWorkspace({
|
||||
id: "b15901d7-d46d-8056-8007-8d5e34fc1f0c",
|
||||
pageId: "b15901d7-d46d-8056-8007-8d5e34fc1f0d",
|
||||
});
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 360 KiB After Width: | Height: | Size: 348 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 216 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 126 KiB |
@@ -32,6 +32,7 @@
|
||||
[app.main.features :as features]
|
||||
[app.main.fonts :as fonts]
|
||||
[app.main.router :as rt]
|
||||
[app.render-wasm.api :as wasm.api]
|
||||
[app.util.text-editor :as ted]
|
||||
[app.util.text.content.styles :as styles]
|
||||
[app.util.timers :as ts]
|
||||
@@ -508,12 +509,12 @@
|
||||
ptk/EffectEvent
|
||||
(effect [_ state _]
|
||||
(when (features/active-feature? state "text-editor/v2")
|
||||
(let [instance (:workspace-editor state)
|
||||
styles (some-> (editor.v2/getCurrentStyle instance)
|
||||
(styles/get-styles-from-style-declaration :removed-mixed true)
|
||||
((comp update-node-fn migrate-node))
|
||||
(styles/attrs->styles))]
|
||||
(editor.v2/applyStylesToSelection instance styles)))))))
|
||||
(when-let [instance (:workspace-editor state)]
|
||||
(let [styles (some-> (editor.v2/getCurrentStyle instance)
|
||||
(styles/get-styles-from-style-declaration :removed-mixed true)
|
||||
((comp update-node-fn migrate-node))
|
||||
(styles/attrs->styles))]
|
||||
(editor.v2/applyStylesToSelection instance styles))))))))
|
||||
|
||||
;; --- RESIZE UTILS
|
||||
|
||||
@@ -777,17 +778,30 @@
|
||||
(rx/of (v2-update-text-editor-styles id attrs)))
|
||||
|
||||
(when (features/active-feature? state "render-wasm/v1")
|
||||
(rx/of (dwwt/resize-wasm-text-debounce id)))))))
|
||||
(rx/concat
|
||||
;; Apply style to selected spans and sync content
|
||||
(when (wasm.api/text-editor-is-active?)
|
||||
(let [span-attrs (select-keys attrs txt/text-node-attrs)]
|
||||
(when (not (empty? span-attrs))
|
||||
(let [result (wasm.api/apply-style-to-selection span-attrs)]
|
||||
(when result
|
||||
(rx/of (v2-update-text-shape-content
|
||||
(:shape-id result) (:content result)
|
||||
:update-name? true)))))))
|
||||
;; Resize (with delay for font-id changes)
|
||||
(cond->> (rx/of (dwwt/resize-wasm-text id))
|
||||
(contains? attrs :font-id)
|
||||
(rx/delay 200))))))))
|
||||
|
||||
ptk/EffectEvent
|
||||
(effect [_ state _]
|
||||
(when (features/active-feature? state "text-editor/v2")
|
||||
(let [instance (:workspace-editor state)
|
||||
attrs-to-override (some-> (editor.v2/getCurrentStyle instance)
|
||||
(styles/get-styles-from-style-declaration))
|
||||
overriden-attrs (merge attrs-to-override attrs)
|
||||
styles (styles/attrs->styles overriden-attrs)]
|
||||
(editor.v2/applyStylesToSelection instance styles))))))
|
||||
(when-let [instance (:workspace-editor state)]
|
||||
(let [attrs-to-override (some-> (editor.v2/getCurrentStyle instance)
|
||||
(styles/get-styles-from-style-declaration))
|
||||
overriden-attrs (merge attrs-to-override attrs)
|
||||
styles (styles/attrs->styles overriden-attrs)]
|
||||
(editor.v2/applyStylesToSelection instance styles)))))))
|
||||
|
||||
(defn update-all-attrs
|
||||
[ids attrs]
|
||||
|
||||
@@ -409,7 +409,7 @@
|
||||
modif-tree (dwm/build-modif-tree ids objects get-modifier)]
|
||||
|
||||
(if (features/active-feature? state "render-wasm/v1")
|
||||
(rx/of (dwm/apply-wasm-modifiers modif-tree {:ignore-snap-pixel true}))
|
||||
(rx/of (dwm/apply-wasm-modifiers modif-tree (assoc options :ignore-snap-pixel true)))
|
||||
|
||||
(let [modif-tree (gm/set-objects-modifiers modif-tree objects)]
|
||||
(rx/of (dwm/apply-modifiers* objects modif-tree nil options)))))))))
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
[app.common.math :as mth]
|
||||
[app.common.types.color :as cc]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.keyboard :as kbd]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(defn parse-hex
|
||||
@@ -67,34 +68,60 @@
|
||||
(when (some? val)
|
||||
(setup-hex-color val))))
|
||||
|
||||
apply-property-change
|
||||
(fn [property val]
|
||||
(let [val (case property
|
||||
:s (/ val 100)
|
||||
:v (value->hsv-value val)
|
||||
:alpha (/ val 100)
|
||||
val)]
|
||||
(cond
|
||||
(= property :alpha)
|
||||
(on-change {:alpha val})
|
||||
|
||||
(#{:r :g :b} property)
|
||||
(let [{:keys [r g b]} (merge color (hash-map property val))
|
||||
hex (cc/rgb->hex [r g b])
|
||||
[h s v] (cc/hex->hsv hex)]
|
||||
(on-change {:hex hex
|
||||
:h h :s s :v v
|
||||
:r r :g g :b b}))
|
||||
|
||||
:else
|
||||
(let [{:keys [h s v]} (merge color (hash-map property val))
|
||||
hex (cc/hsv->hex [h s v])
|
||||
[r g b] (cc/hex->rgb hex)]
|
||||
(on-change {:hex hex
|
||||
:h h :s s :v v
|
||||
:r r :g g :b b})))))
|
||||
|
||||
on-change-property
|
||||
(fn [property max-value]
|
||||
(fn [e]
|
||||
(let [val (-> e dom/get-target-val d/parse-double (mth/clamp 0 max-value))
|
||||
val (case property
|
||||
:s (/ val 100)
|
||||
:v (value->hsv-value val)
|
||||
val)]
|
||||
(when (not (nil? val))
|
||||
(if (#{:r :g :b} property)
|
||||
(let [{:keys [r g b]} (merge color (hash-map property val))
|
||||
hex (cc/rgb->hex [r g b])
|
||||
[h s v] (cc/hex->hsv hex)]
|
||||
(on-change {:hex hex
|
||||
:h h :s s :v v
|
||||
:r r :g g :b b}))
|
||||
(let [val (-> e dom/get-target-val d/parse-double (mth/clamp 0 max-value))]
|
||||
(when (some? val)
|
||||
(apply-property-change property val)))))
|
||||
|
||||
(let [{:keys [h s v]} (merge color (hash-map property val))
|
||||
hex (cc/hsv->hex [h s v])
|
||||
[r g b] (cc/hex->rgb hex)]
|
||||
(on-change {:hex hex
|
||||
:h h :s s :v v
|
||||
:r r :g g :b b})))))))
|
||||
on-key-down-step
|
||||
(fn [max-value on-step]
|
||||
(fn [e]
|
||||
(let [up? (kbd/up-arrow? e)
|
||||
down? (kbd/down-arrow? e)]
|
||||
(when (and (or up? down?)
|
||||
(or (kbd/shift? e) (kbd/alt? e)))
|
||||
(dom/prevent-default e)
|
||||
(when-let [current-value (-> e dom/get-target-val d/parse-double)]
|
||||
(let [step (cond
|
||||
(kbd/shift? e) (if up? 10 -10)
|
||||
(kbd/alt? e) (if up? 0.1 -0.1))
|
||||
new-value (mth/clamp (+ current-value step) 0 max-value)
|
||||
node (dom/get-target e)]
|
||||
(dom/set-value! node new-value)
|
||||
(on-step new-value)))))))
|
||||
|
||||
on-change-opacity
|
||||
(fn [e]
|
||||
(when-let [new-alpha (-> e dom/get-target-val (mth/clamp 0 100) (/ 100))]
|
||||
(on-change {:alpha new-alpha})))]
|
||||
on-key-down-property
|
||||
(fn [property max-value]
|
||||
(on-key-down-step max-value #(apply-property-change property %)))]
|
||||
|
||||
|
||||
;; Updates the inputs values when a property is changed in the parent
|
||||
@@ -127,7 +154,8 @@
|
||||
:min 0
|
||||
:max 255
|
||||
:default-value red
|
||||
:on-change (on-change-property :r 255)}]]
|
||||
:on-change (on-change-property :r 255)
|
||||
:on-key-down (on-key-down-property :r 255)}]]
|
||||
[:div {:class (stl/css :input-wrapper)}
|
||||
[:label {:for "green-value" :class (stl/css :input-label)} "G"]
|
||||
[:input {:id "green-value"
|
||||
@@ -136,7 +164,8 @@
|
||||
:min 0
|
||||
:max 255
|
||||
:default-value green
|
||||
:on-change (on-change-property :g 255)}]]
|
||||
:on-change (on-change-property :g 255)
|
||||
:on-key-down (on-key-down-property :g 255)}]]
|
||||
[:div {:class (stl/css :input-wrapper)}
|
||||
[:label {:for "blue-value" :class (stl/css :input-label)} "B"]
|
||||
[:input {:id "blue-value"
|
||||
@@ -145,7 +174,8 @@
|
||||
:min 0
|
||||
:max 255
|
||||
:default-value blue
|
||||
:on-change (on-change-property :b 255)}]]]
|
||||
:on-change (on-change-property :b 255)
|
||||
:on-key-down (on-key-down-property :b 255)}]]]
|
||||
|
||||
[:*
|
||||
[:div {:class (stl/css :input-wrapper)}
|
||||
@@ -156,7 +186,8 @@
|
||||
:min 0
|
||||
:max 360
|
||||
:default-value hue
|
||||
:on-change (on-change-property :h 360)}]]
|
||||
:on-change (on-change-property :h 360)
|
||||
:on-key-down (on-key-down-property :h 360)}]]
|
||||
[:div {:class (stl/css :input-wrapper)}
|
||||
[:label {:for "saturation-value" :class (stl/css :input-label)} "S"]
|
||||
[:input {:id "saturation-value"
|
||||
@@ -166,7 +197,8 @@
|
||||
:max 100
|
||||
:step 1
|
||||
:default-value saturation
|
||||
:on-change (on-change-property :s 100)}]]
|
||||
:on-change (on-change-property :s 100)
|
||||
:on-key-down (on-key-down-property :s 100)}]]
|
||||
[:div {:class (stl/css :input-wrapper)}
|
||||
[:label {:for "value-value" :class (stl/css :input-label)} "V"]
|
||||
[:input {:id "value-value"
|
||||
@@ -175,7 +207,8 @@
|
||||
:min 0
|
||||
:max 100
|
||||
:default-value value
|
||||
:on-change (on-change-property :v 100)}]]])]
|
||||
:on-change (on-change-property :v 100)
|
||||
:on-key-down (on-key-down-property :v 100)}]]])]
|
||||
[:div {:class (stl/css :hex-alpha-wrapper)}
|
||||
[:div {:class (stl/css-case :input-wrapper true
|
||||
:hex true)}
|
||||
@@ -195,4 +228,5 @@
|
||||
:step 1
|
||||
:max 100
|
||||
:default-value (if (= alpha :multiple) "" alpha)
|
||||
:on-change on-change-opacity}]])]]))
|
||||
:on-change (on-change-property :alpha 100)
|
||||
:on-key-down (on-key-down-property :alpha 100)}]])]]))
|
||||
|
||||
@@ -352,11 +352,9 @@
|
||||
max-height (max height selrect-height)
|
||||
valign (-> shape :content :vertical-align)
|
||||
y (:y selrect)
|
||||
y (if (and valign (> height selrect-height))
|
||||
(case valign
|
||||
"bottom" (- y (- height selrect-height))
|
||||
"center" (- y (/ (- height selrect-height) 2))
|
||||
y)
|
||||
y (case valign
|
||||
"bottom" (+ y (- selrect-height height))
|
||||
"center" (+ y (/ (- selrect-height height) 2))
|
||||
y)]
|
||||
[(assoc selrect :y y :width max-width :height max-height) transform])
|
||||
|
||||
|
||||
@@ -29,6 +29,23 @@
|
||||
|
||||
color: transparent;
|
||||
|
||||
// Match Skia's text layout precision: prevent browser text-size
|
||||
// adjustments and ensure consistent kerning across browsers.
|
||||
text-size-adjust: none;
|
||||
-webkit-text-size-adjust: none;
|
||||
font-kerning: normal;
|
||||
|
||||
&::selection,
|
||||
*::selection {
|
||||
color: transparent;
|
||||
-webkit-text-fill-color: transparent; // WebKit/Safari
|
||||
}
|
||||
|
||||
&::-moz-selection,
|
||||
*::-moz-selection {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
[data-itype="paragraph"] {
|
||||
line-height: inherit;
|
||||
user-select: text;
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
|
||||
on-click
|
||||
(mf/use-fn
|
||||
(mf/deps id)
|
||||
(mf/deps id current-page-id)
|
||||
(fn []
|
||||
;; For the wasm renderer, apply a blur effect to the viewport canvas
|
||||
;; when we navigate to a different page.
|
||||
|
||||
@@ -19,10 +19,14 @@
|
||||
[app.main.data.workspace.media :as dwm]
|
||||
[app.main.data.workspace.path :as dwdp]
|
||||
[app.main.data.workspace.specialized-panel :as-alias dwsp]
|
||||
[app.main.data.workspace.texts :as dwt]
|
||||
[app.main.features :as features]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.workspace.sidebar.assets.components :as wsac]
|
||||
[app.main.ui.workspace.viewport.viewport-ref :as uwvv]
|
||||
[app.render-wasm.api :as wasm.api]
|
||||
[app.render-wasm.wasm :as wasm.wasm]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.dom.dnd :as dnd]
|
||||
[app.util.dom.normalize-wheel :as nw]
|
||||
@@ -91,7 +95,17 @@
|
||||
::dwsp/interrupt)
|
||||
|
||||
(when (and (not= edition id) (or text-editing? grid-editing?))
|
||||
(st/emit! (dw/clear-edition-mode)))
|
||||
(st/emit! (dw/clear-edition-mode))
|
||||
;; Sync and stop WASM text editor when exiting edit mode
|
||||
(when (and text-editing?
|
||||
(features/active-feature? @st/state "render-wasm/v1")
|
||||
wasm.wasm/context-initialized?)
|
||||
(when-let [{:keys [shape-id content]} (wasm.api/text-editor-sync-content)]
|
||||
(st/emit! (dwt/v2-update-text-shape-content
|
||||
shape-id content
|
||||
:update-name? true
|
||||
:finalize? true)))
|
||||
(wasm.api/text-editor-stop)))
|
||||
|
||||
(when (and (not text-editing?)
|
||||
(not blocked)
|
||||
@@ -184,6 +198,20 @@
|
||||
(not drawing-tool))
|
||||
(st/emit! (dw/select-shape (:id @hover) shift?)))
|
||||
|
||||
;; If clicking on a text shape and wasm render is enabled, forward cursor position
|
||||
(when (and hovering?
|
||||
(not @space?)
|
||||
edition ;; Only when already in edit mode
|
||||
(not drawing-path?)
|
||||
(not drawing-tool))
|
||||
(let [hover-shape @hover]
|
||||
(when (and (= :text (:type hover-shape))
|
||||
(features/active-feature? @st/state "text-editor-wasm/v1")
|
||||
wasm.wasm/context-initialized?)
|
||||
(let [raw-pt (dom/get-client-position event)]
|
||||
;; FIXME
|
||||
(wasm.api/text-editor-set-cursor-from-point (.-x raw-pt) (.-y raw-pt))))))
|
||||
|
||||
(when (and @z?
|
||||
(not @space?)
|
||||
(not edition)
|
||||
@@ -223,8 +251,15 @@
|
||||
(when (and (not drawing-path?) shape)
|
||||
(cond
|
||||
(and editable? (not= id edition) (not read-only?))
|
||||
(st/emit! (dw/select-shape id)
|
||||
(dw/start-editing-selected))
|
||||
(do
|
||||
(st/emit! (dw/select-shape id)
|
||||
(dw/start-editing-selected))
|
||||
;; If using wasm text-editor, notify WASM to start editing this shape
|
||||
;; and set cursor position from the double-click location
|
||||
(when (and (= type :text)
|
||||
(features/active-feature? @st/state "text-editor-wasm/v1")
|
||||
wasm.wasm/context-initialized?)
|
||||
(wasm.api/text-editor-start id)))
|
||||
|
||||
(some? selected-shape)
|
||||
(do
|
||||
|
||||
@@ -164,7 +164,6 @@
|
||||
;; for the release of the z key
|
||||
(when-not ^boolean value
|
||||
(reset! z* false))))
|
||||
|
||||
(hooks/use-stream kbd-zoom-s
|
||||
(fn [kevent]
|
||||
(dom/prevent-default kevent)
|
||||
@@ -316,7 +315,7 @@
|
||||
(and (cfh/group-shape? objects %)
|
||||
(not (contains? child-parent? %)))
|
||||
(and (features/active-feature? @st/state "render-wasm/v1")
|
||||
(cfh/text-shape? objects %)
|
||||
(cfh/text-shape? (get objects %))
|
||||
(not (wasm.api/intersect-position-in-shape % @last-point-ref)))))))
|
||||
|
||||
remove-measure-xf
|
||||
|
||||
@@ -66,6 +66,14 @@
|
||||
(gpt/divide zoom)
|
||||
(gpt/add box))))))
|
||||
|
||||
(defn point->viewport-relative
|
||||
"Convert client coordinates to viewport-relative coordinates.
|
||||
Unlike point->viewport, this does NOT convert to canvas coordinates -
|
||||
it just subtracts the viewport's bounding rect offset."
|
||||
[pt]
|
||||
(when (some? @viewport-brect)
|
||||
(gpt/subtract pt @viewport-brect)))
|
||||
|
||||
(defn inside-viewport?
|
||||
[target]
|
||||
(dom/is-child? @viewport-ref target))
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
[app.main.ui.workspace.viewport.viewport-ref :refer [create-viewport-ref]]
|
||||
[app.main.ui.workspace.viewport.widgets :as widgets]
|
||||
[app.render-wasm.api :as wasm.api]
|
||||
[app.render-wasm.text-editor-input :refer [text-editor-input]]
|
||||
[app.util.debug :as dbg]
|
||||
[app.util.text-editor :as ted]
|
||||
[beicon.v2.core :as rx]
|
||||
@@ -407,7 +408,14 @@
|
||||
|
||||
(when picking-color?
|
||||
[:> pixel-overlay/pixel-overlay-wasm* {:viewport-ref viewport-ref
|
||||
:canvas-ref canvas-ref}])]
|
||||
:canvas-ref canvas-ref}])
|
||||
|
||||
;; WASM text editor contenteditable (must be outside SVG to work)
|
||||
(when (and show-text-editor?
|
||||
(features/active-feature? @st/state "text-editor-wasm/v1"))
|
||||
[:& text-editor-input {:shape editing-shape
|
||||
:zoom zoom
|
||||
:vbox vbox}])]
|
||||
|
||||
[:canvas {:id "render"
|
||||
:data-testid "canvas-wasm-shapes"
|
||||
@@ -452,7 +460,10 @@
|
||||
:height (max 0 (- (:height vbox) rule-area-size))}]]]
|
||||
|
||||
[:g {:style {:pointer-events (if disable-events? "none" "auto")}}
|
||||
(when show-text-editor?
|
||||
;; Text editor handling:
|
||||
;; - When text-editor-wasm/v1 is active, contenteditable is rendered in viewport-overlays (HTML DOM)
|
||||
(when (and show-text-editor?
|
||||
(not (features/active-feature? @st/state "text-editor-wasm/v1")))
|
||||
(if (features/active-feature? @st/state "text-editor/v2")
|
||||
[:& editor-v2/text-editor {:shape editing-shape
|
||||
:canvas-ref canvas-ref
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
[app.render-wasm.serializers :as sr]
|
||||
[app.render-wasm.serializers.color :as sr-clr]
|
||||
[app.render-wasm.svg-filters :as svg-filters]
|
||||
[app.render-wasm.text-editor :as text-editor]
|
||||
[app.render-wasm.wasm :as wasm]
|
||||
[app.util.debug :as dbg]
|
||||
[app.util.dom :as dom]
|
||||
@@ -74,6 +75,18 @@
|
||||
;; Threshold below which we use synchronous processing (no chunking overhead)
|
||||
(def ^:const ASYNC_THRESHOLD 100)
|
||||
|
||||
;; Re-export public WebGL functions
|
||||
(def capture-canvas-pixels webgl/capture-canvas-pixels)
|
||||
(def restore-previous-canvas-pixels webgl/restore-previous-canvas-pixels)
|
||||
(def clear-canvas-pixels webgl/clear-canvas-pixels)
|
||||
|
||||
;; Re-export public text editor functions
|
||||
(def text-editor-start text-editor/text-editor-start)
|
||||
(def text-editor-stop text-editor/text-editor-stop)
|
||||
(def text-editor-set-cursor-from-point text-editor/text-editor-set-cursor-from-point)
|
||||
(def text-editor-is-active? text-editor/text-editor-is-active?)
|
||||
(def text-editor-sync-content text-editor/text-editor-sync-content)
|
||||
|
||||
(def dpr
|
||||
(if use-dpr? (if (exists? js/window) js/window.devicePixelRatio 1.0) 1.0))
|
||||
|
||||
@@ -109,11 +122,36 @@
|
||||
(mf/element object-svg #js {:shape shape})
|
||||
(rds/renderToStaticMarkup)))
|
||||
|
||||
;; forward declare helpers so render can call them
|
||||
(declare request-render)
|
||||
(declare set-shape-vertical-align fonts-from-text-content)
|
||||
|
||||
;; This should never be called from the outside.
|
||||
(defn- render
|
||||
[timestamp]
|
||||
(when (and wasm/context-initialized? (not @wasm/context-lost?))
|
||||
(h/call wasm/internal-module "_render" timestamp)
|
||||
|
||||
;; Update text editor blink (so cursor toggles) using the same timestamp
|
||||
(try
|
||||
(when wasm/context-initialized?
|
||||
(text-editor/text-editor-update-blink timestamp)
|
||||
;; Render text editor overlay on top of main canvas (only if feature enabled)
|
||||
;; Determine if text-editor-wasm feature is active without requiring
|
||||
;; app.main.features to avoid circular dependency: check runtime and
|
||||
;; persisted feature sets in the store state.
|
||||
(let [runtime-features (get @st/state :features-runtime)
|
||||
enabled-features (get @st/state :features)]
|
||||
(when (or (contains? runtime-features "text-editor-wasm/v1")
|
||||
(contains? enabled-features "text-editor-wasm/v1"))
|
||||
(text-editor/text-editor-render-overlay)))
|
||||
;; Poll for editor events; if any event occurs, trigger a re-render
|
||||
(let [ev (text-editor/text-editor-poll-event)]
|
||||
(when (and ev (not= ev 0))
|
||||
(request-render "text-editor-event"))))
|
||||
(catch :default e
|
||||
(js/console.error "text-editor overlay/update failed:" e)))
|
||||
|
||||
(set! wasm/internal-frame-id nil)
|
||||
(ug/dispatch! (ug/event "penpot:wasm:render"))))
|
||||
|
||||
@@ -187,25 +225,6 @@
|
||||
|
||||
(declare get-text-dimensions)
|
||||
|
||||
(defn update-text-rect!
|
||||
[id]
|
||||
(when wasm/context-initialized?
|
||||
(let [dimensions (get-text-dimensions id)
|
||||
page-id (:current-page-id @st/state)]
|
||||
(mw/emit!
|
||||
{:cmd :index/update-text-rect
|
||||
:page-id page-id
|
||||
:shape-id id
|
||||
:dimensions dimensions}))))
|
||||
|
||||
|
||||
(defn- ensure-text-content
|
||||
"Guarantee that the shape always sends a valid text tree to WASM. When the
|
||||
content is nil (freshly created text) we fall back to
|
||||
tc/default-text-content so the renderer receives typography information."
|
||||
[content]
|
||||
(or content (tc/v2-default-text-content)))
|
||||
|
||||
(defn use-shape
|
||||
[id]
|
||||
(when wasm/context-initialized?
|
||||
@@ -216,6 +235,47 @@
|
||||
(aget buffer 2)
|
||||
(aget buffer 3)))))
|
||||
|
||||
(defn set-shape-text-content
|
||||
"This function sets shape text content and returns a stream that loads the needed fonts asynchronously"
|
||||
[shape-id content]
|
||||
|
||||
;; Cache content for text editor sync
|
||||
(text-editor/cache-shape-text-content! shape-id content)
|
||||
|
||||
(h/call wasm/internal-module "_clear_shape_text")
|
||||
|
||||
(set-shape-vertical-align (get content :vertical-align))
|
||||
|
||||
(let [fonts (f/get-content-fonts content)
|
||||
fallback-fonts (fonts-from-text-content content true)
|
||||
all-fonts (concat fonts fallback-fonts)
|
||||
result (f/store-fonts all-fonts)]
|
||||
(f/load-fallback-fonts-for-editor! fallback-fonts)
|
||||
(h/call wasm/internal-module "_update_shape_text_layout")
|
||||
result))
|
||||
|
||||
(defn apply-style-to-selection
|
||||
"Apply style attrs to the currently selected text spans.
|
||||
Updates the cached content, pushes to WASM, and returns {:shape-id :content} for saving."
|
||||
[attrs]
|
||||
(text-editor/apply-style-to-selection attrs use-shape set-shape-text-content))
|
||||
|
||||
(defn update-text-rect!
|
||||
[id]
|
||||
(when wasm/context-initialized?
|
||||
(mw/emit!
|
||||
{:cmd :index/update-text-rect
|
||||
:page-id (:current-page-id @st/state)
|
||||
:shape-id id
|
||||
:dimensions (get-text-dimensions id)})))
|
||||
|
||||
(defn- ensure-text-content
|
||||
"Guarantee that the shape always sends a valid text tree to WASM. When the
|
||||
content is nil (freshly created text) we fall back to
|
||||
tc/default-text-content so the renderer receives typography information."
|
||||
[content]
|
||||
(or content (tc/v2-default-text-content)))
|
||||
|
||||
(defn set-parent-id
|
||||
[id]
|
||||
(let [buffer (uuid/get-u32 id)]
|
||||
@@ -859,22 +919,6 @@
|
||||
|
||||
(if fallback-fonts-only? updated-fonts fallback-fonts))))))
|
||||
|
||||
(defn set-shape-text-content
|
||||
"This function sets shape text content and returns a stream that loads the needed fonts asynchronously"
|
||||
[shape-id content]
|
||||
|
||||
(h/call wasm/internal-module "_clear_shape_text")
|
||||
|
||||
(set-shape-vertical-align (get content :vertical-align))
|
||||
|
||||
(let [fonts (f/get-content-fonts content)
|
||||
fallback-fonts (fonts-from-text-content content true)
|
||||
all-fonts (concat fonts fallback-fonts)
|
||||
result (f/store-fonts all-fonts)]
|
||||
(f/load-fallback-fonts-for-editor! fallback-fonts)
|
||||
(f/update-text-layout shape-id)
|
||||
result))
|
||||
|
||||
(defn set-shape-grow-type
|
||||
[grow-type]
|
||||
(h/call wasm/internal-module "_set_shape_grow_type" (sr/translate-grow-type grow-type)))
|
||||
@@ -1072,7 +1116,7 @@
|
||||
(defn- set-objects-async
|
||||
"Asynchronously process shapes in chunks, yielding to the browser between chunks.
|
||||
Returns a promise that resolves when all shapes are processed.
|
||||
|
||||
|
||||
Renders a preview only periodically during loading to show progress,
|
||||
then does a full tile-based render at the end."
|
||||
[shapes render-callback]
|
||||
@@ -1557,33 +1601,41 @@
|
||||
(persistent! result)))
|
||||
|
||||
result
|
||||
(->> result
|
||||
(mapv
|
||||
(fn [{:keys [paragraph span start-pos end-pos direction x y width height]}]
|
||||
(let [content (:content shape)
|
||||
element (-> content :children
|
||||
(get 0) :children ;; paragraph-set
|
||||
(get paragraph) :children ;; paragraph
|
||||
(get span))
|
||||
text (subs (:text element) start-pos end-pos)]
|
||||
(into []
|
||||
(keep
|
||||
(fn [{:keys [paragraph span start-pos end-pos direction x y width height]}]
|
||||
(let [content (:content shape)
|
||||
element (-> content :children
|
||||
(get 0) :children ;; paragraph-set
|
||||
(get paragraph) :children ;; paragraph
|
||||
(get span))
|
||||
element-text (:text element)]
|
||||
|
||||
(d/patch-object
|
||||
txt/default-text-attrs
|
||||
(d/without-nils
|
||||
{:x x
|
||||
:y (+ y height)
|
||||
:width width
|
||||
:height height
|
||||
:direction (dr/translate-direction direction)
|
||||
:font-family (get element :font-family)
|
||||
:font-size (get element :font-size)
|
||||
:font-weight (get element :font-weight)
|
||||
:text-transform (get element :text-transform)
|
||||
:text-decoration (get element :text-decoration)
|
||||
:letter-spacing (get element :letter-spacing)
|
||||
:font-style (get element :font-style)
|
||||
:fills (d/nilv (get element :fills) [{:fill-color "#000000"}])
|
||||
:text text}))))))]
|
||||
;; Add comprehensive nil-safety checks
|
||||
(when (and element
|
||||
element-text
|
||||
(>= start-pos 0)
|
||||
(<= end-pos (count element-text))
|
||||
(<= start-pos end-pos))
|
||||
(let [text (subs element-text start-pos end-pos)]
|
||||
(d/patch-object
|
||||
txt/default-text-attrs
|
||||
(d/without-nils
|
||||
{:x x
|
||||
:y (+ y height)
|
||||
:width width
|
||||
:height height
|
||||
:direction (dr/translate-direction direction)
|
||||
:font-family (get element :font-family)
|
||||
:font-size (get element :font-size)
|
||||
:font-weight (get element :font-weight)
|
||||
:text-transform (get element :text-transform)
|
||||
:text-decoration (get element :text-decoration)
|
||||
:letter-spacing (get element :letter-spacing)
|
||||
:font-style (get element :font-style)
|
||||
:fills (get element :fills)
|
||||
:text text})))))))
|
||||
result)]
|
||||
(mem/free)
|
||||
|
||||
result)))
|
||||
@@ -1617,7 +1669,4 @@
|
||||
(p/resolved false)))))
|
||||
(p/resolved false))))
|
||||
|
||||
;; Re-export public WebGL functions
|
||||
(def capture-canvas-pixels webgl/capture-canvas-pixels)
|
||||
(def restore-previous-canvas-pixels webgl/restore-previous-canvas-pixels)
|
||||
(def clear-canvas-pixels webgl/clear-canvas-pixels)
|
||||
|
||||
|
||||
@@ -320,7 +320,7 @@
|
||||
:style-name style
|
||||
:weight weight
|
||||
:emoji? emoji?
|
||||
:fallbck? fallback?
|
||||
:fallback? fallback?
|
||||
:asset-id asset-id}))
|
||||
|
||||
(defn store-font
|
||||
|
||||
@@ -240,3 +240,12 @@ export const RawGrowType = {
|
||||
"auto-height": 2,
|
||||
};
|
||||
|
||||
export const CursorDirection = {
|
||||
"backward": 0,
|
||||
"forward": 1,
|
||||
"line-before": 2,
|
||||
"line-after": 3,
|
||||
"line-start": 4,
|
||||
"line-end": 5,
|
||||
};
|
||||
|
||||
|
||||
@@ -61,6 +61,29 @@
|
||||
[]
|
||||
(h/call wasm/internal-module "_free_bytes"))
|
||||
|
||||
(defn read-string
|
||||
"Read a UTF-8 string from WASM memory given a byte pointer/offset.
|
||||
Uses Emscripten's UTF8ToString to decode the string."
|
||||
[ptr]
|
||||
(h/call wasm/internal-module "UTF8ToString" ptr))
|
||||
|
||||
(defn read-null-terminated-string
|
||||
"Read a null-terminated UTF-8 string from WASM memory.
|
||||
Manually reads bytes until null terminator and decodes using TextDecoder."
|
||||
[ptr]
|
||||
(when (and ptr (not (zero? ptr)))
|
||||
(let [heap (get-heap-u8)
|
||||
;; Find the null terminator
|
||||
end-idx (loop [idx ptr]
|
||||
(if (zero? (aget heap idx))
|
||||
idx
|
||||
(recur (inc idx))))
|
||||
;; Extract the bytes (excluding null terminator)
|
||||
bytes (.slice heap ptr end-idx)
|
||||
;; Decode using TextDecoder
|
||||
decoder (js/TextDecoder. "utf-8")]
|
||||
(.decode decoder bytes))))
|
||||
|
||||
(defn slice
|
||||
"Returns a copy of a portion of a typed array into a new typed array
|
||||
object selected from start to end."
|
||||
|
||||
300
frontend/src/app/render_wasm/text_editor.cljs
Normal file
@@ -0,0 +1,300 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.render-wasm.text-editor
|
||||
"Text editor WASM bindings"
|
||||
(:require
|
||||
[app.common.uuid :as uuid]
|
||||
[app.render-wasm.helpers :as h]
|
||||
[app.render-wasm.mem :as mem]
|
||||
[app.render-wasm.wasm :as wasm]))
|
||||
|
||||
(defn text-editor-start
|
||||
[id]
|
||||
(when wasm/context-initialized?
|
||||
(let [buffer (uuid/get-u32 id)]
|
||||
(h/call wasm/internal-module "_text_editor_start"
|
||||
(aget buffer 0)
|
||||
(aget buffer 1)
|
||||
(aget buffer 2)
|
||||
(aget buffer 3)))))
|
||||
|
||||
(defn text-editor-set-cursor-from-point
|
||||
[x y]
|
||||
(when wasm/context-initialized?
|
||||
(h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y)))
|
||||
|
||||
(defn text-editor-update-blink
|
||||
[timestamp-ms]
|
||||
(when wasm/context-initialized?
|
||||
(h/call wasm/internal-module "_text_editor_update_blink" timestamp-ms)))
|
||||
|
||||
(defn text-editor-render-overlay
|
||||
[]
|
||||
(when wasm/context-initialized?
|
||||
(h/call wasm/internal-module "_text_editor_render_overlay")))
|
||||
|
||||
(defn text-editor-poll-event
|
||||
[]
|
||||
(when wasm/context-initialized?
|
||||
(let [res (h/call wasm/internal-module "_text_editor_poll_event")]
|
||||
res)))
|
||||
|
||||
(defn text-editor-insert-text
|
||||
[text]
|
||||
(when wasm/context-initialized?
|
||||
(let [encoder (js/TextEncoder.)
|
||||
buf (.encode encoder text)
|
||||
heapu8 (mem/get-heap-u8)
|
||||
size (mem/size buf)
|
||||
offset (mem/alloc size)]
|
||||
(mem/write-buffer offset heapu8 buf)
|
||||
(h/call wasm/internal-module "_text_editor_insert_text")
|
||||
(mem/free))))
|
||||
|
||||
(defn text-editor-delete-backward []
|
||||
(when wasm/context-initialized?
|
||||
(h/call wasm/internal-module "_text_editor_delete_backward")))
|
||||
|
||||
(defn text-editor-delete-forward []
|
||||
(when wasm/context-initialized?
|
||||
(h/call wasm/internal-module "_text_editor_delete_forward")))
|
||||
|
||||
(defn text-editor-insert-paragraph []
|
||||
(when wasm/context-initialized?
|
||||
(h/call wasm/internal-module "_text_editor_insert_paragraph")))
|
||||
|
||||
(defn text-editor-move-cursor
|
||||
[direction extend-selection]
|
||||
(when wasm/context-initialized?
|
||||
(h/call wasm/internal-module "_text_editor_move_cursor" direction (if extend-selection 1 0))))
|
||||
|
||||
(defn text-editor-select-all
|
||||
[]
|
||||
(when wasm/context-initialized?
|
||||
(h/call wasm/internal-module "_text_editor_select_all")))
|
||||
|
||||
(defn text-editor-stop
|
||||
[]
|
||||
(when wasm/context-initialized?
|
||||
(h/call wasm/internal-module "_text_editor_stop")))
|
||||
|
||||
(defn text-editor-is-active?
|
||||
[]
|
||||
(when wasm/context-initialized?
|
||||
(not (zero? (h/call wasm/internal-module "_text_editor_is_active")))))
|
||||
|
||||
(defn text-editor-export-content
|
||||
[]
|
||||
(when wasm/context-initialized?
|
||||
(let [ptr (h/call wasm/internal-module "_text_editor_export_content")]
|
||||
(when (and ptr (not (zero? ptr)))
|
||||
(let [json-str (mem/read-null-terminated-string ptr)]
|
||||
(mem/free)
|
||||
(js/JSON.parse json-str))))))
|
||||
|
||||
(defn text-editor-export-selection
|
||||
"Export only the currently selected text as plain text from the WASM editor. Requires WASM support (_text_editor_export_selection)."
|
||||
[]
|
||||
(when wasm/context-initialized?
|
||||
(let [ptr (h/call wasm/internal-module "_text_editor_export_selection")]
|
||||
(when (and ptr (not (zero? ptr)))
|
||||
(let [text (mem/read-null-terminated-string ptr)]
|
||||
(mem/free)
|
||||
text)))))
|
||||
|
||||
(defn text-editor-get-active-shape-id
|
||||
[]
|
||||
(when wasm/context-initialized?
|
||||
(try
|
||||
(let [byte-offset (mem/alloc 16)
|
||||
u32-offset (mem/->offset-32 byte-offset)
|
||||
heap (mem/get-heap-u32)]
|
||||
(h/call wasm/internal-module "_text_editor_get_active_shape_id" byte-offset)
|
||||
(let [a (aget heap u32-offset)
|
||||
b (aget heap (+ u32-offset 1))
|
||||
c (aget heap (+ u32-offset 2))
|
||||
d (aget heap (+ u32-offset 3))
|
||||
result (when (or (not= a 0) (not= b 0) (not= c 0) (not= d 0))
|
||||
(uuid/from-unsigned-parts a b c d))]
|
||||
(mem/free)
|
||||
result))
|
||||
(catch js/Error e
|
||||
(js/console.error "[text-editor-get-active-shape-id] Error:" e)
|
||||
nil))))
|
||||
|
||||
(defn text-editor-get-selection
|
||||
[]
|
||||
(when wasm/context-initialized?
|
||||
(let [byte-offset (mem/alloc 16)
|
||||
u32-offset (mem/->offset-32 byte-offset)
|
||||
heap (mem/get-heap-u32)
|
||||
active? (h/call wasm/internal-module "_text_editor_get_selection" byte-offset)]
|
||||
(try
|
||||
(when (= active? 1)
|
||||
{:anchor-para (aget heap u32-offset)
|
||||
:anchor-offset (aget heap (+ u32-offset 1))
|
||||
:focus-para (aget heap (+ u32-offset 2))
|
||||
:focus-offset (aget heap (+ u32-offset 3))})
|
||||
(finally
|
||||
(mem/free))))))
|
||||
|
||||
(def ^:private shape-text-contents (atom {}))
|
||||
|
||||
(defn- merge-exported-texts-into-content
|
||||
"Merge exported span texts back into the existing content tree.
|
||||
|
||||
The WASM editor may split or merge paragraphs (Enter / Backspace at
|
||||
paragraph boundary), so the exported structure can differ from the
|
||||
original. When extra paragraphs or spans appear we clone styling from
|
||||
the nearest existing sibling; when fewer appear we truncate.
|
||||
|
||||
exported-texts vector of vectors [[\"span1\" \"span2\"] [\"p2s1\"]]
|
||||
content existing Penpot content map (root -> paragraph-set -> …)"
|
||||
[content exported-texts]
|
||||
(let [para-set (first (get content :children))
|
||||
orig-paras (get para-set :children)
|
||||
num-orig (count orig-paras)
|
||||
last-orig-para (when (seq orig-paras) (last orig-paras))
|
||||
template-span (when last-orig-para
|
||||
(-> last-orig-para :children last))
|
||||
new-paras
|
||||
(mapv (fn [para-idx exported-span-texts]
|
||||
(let [orig-para (if (< para-idx num-orig)
|
||||
(nth orig-paras para-idx)
|
||||
(dissoc last-orig-para :children))
|
||||
orig-spans (get orig-para :children)
|
||||
num-orig-spans (count orig-spans)
|
||||
last-orig-span (when (seq orig-spans) (last orig-spans))]
|
||||
(assoc orig-para :children
|
||||
(mapv (fn [span-idx new-text]
|
||||
(let [orig-span (if (< span-idx num-orig-spans)
|
||||
(nth orig-spans span-idx)
|
||||
(or last-orig-span template-span))]
|
||||
(assoc orig-span :text new-text)))
|
||||
(range (count exported-span-texts))
|
||||
exported-span-texts))))
|
||||
(range (count exported-texts))
|
||||
exported-texts)
|
||||
new-para-set (assoc para-set :children new-paras)]
|
||||
(assoc content :children [new-para-set])))
|
||||
|
||||
(defn text-editor-sync-content
|
||||
"Sync text content from the WASM text editor back to the frontend shape.
|
||||
|
||||
Exports the current span texts from WASM, merges them into the shape's
|
||||
cached content tree (preserving per-span styling), and returns the
|
||||
shape-id and the fully merged content map ready for
|
||||
v2-update-text-shape-content."
|
||||
[]
|
||||
(when (and wasm/context-initialized? (text-editor-is-active?))
|
||||
(let [shape-id (text-editor-get-active-shape-id)
|
||||
new-texts (text-editor-export-content)]
|
||||
(when (and shape-id new-texts)
|
||||
(let [texts-clj (js->clj new-texts)
|
||||
content (get @shape-text-contents shape-id)]
|
||||
(when content
|
||||
(let [merged (merge-exported-texts-into-content content texts-clj)]
|
||||
(swap! shape-text-contents assoc shape-id merged)
|
||||
{:shape-id shape-id
|
||||
:content merged})))))))
|
||||
|
||||
(defn cache-shape-text-content!
|
||||
[shape-id content]
|
||||
(when (some? content)
|
||||
(swap! shape-text-contents assoc shape-id content)))
|
||||
|
||||
(defn get-cached-content
|
||||
[shape-id]
|
||||
(get @shape-text-contents shape-id))
|
||||
|
||||
(defn update-cached-content!
|
||||
[shape-id content]
|
||||
(swap! shape-text-contents assoc shape-id content))
|
||||
|
||||
(defn- normalize-selection
|
||||
"Given anchor/focus para+offset, return {:start-para :start-offset :end-para :end-offset}
|
||||
ordered so start <= end."
|
||||
[{:keys [anchor-para anchor-offset focus-para focus-offset]}]
|
||||
(if (or (< anchor-para focus-para)
|
||||
(and (= anchor-para focus-para) (<= anchor-offset focus-offset)))
|
||||
{:start-para anchor-para :start-offset anchor-offset
|
||||
:end-para focus-para :end-offset focus-offset}
|
||||
{:start-para focus-para :start-offset focus-offset
|
||||
:end-para anchor-para :end-offset anchor-offset}))
|
||||
|
||||
(defn- apply-attrs-to-paragraph
|
||||
"Apply attrs to spans within [sel-start, sel-end) char range of a single paragraph.
|
||||
Splits spans at boundaries as needed."
|
||||
[para sel-start sel-end attrs]
|
||||
(let [spans (:children para)
|
||||
result (loop [spans spans
|
||||
pos 0
|
||||
acc []]
|
||||
(if (empty? spans)
|
||||
acc
|
||||
(let [span (first spans)
|
||||
text (:text span)
|
||||
span-len (count text)
|
||||
span-end (+ pos span-len)
|
||||
ol-start (max pos sel-start)
|
||||
ol-end (min span-end sel-end)
|
||||
has-overlap? (< ol-start ol-end)]
|
||||
(if (not has-overlap?)
|
||||
(recur (rest spans) span-end (conj acc span))
|
||||
(let [before (when (> ol-start pos)
|
||||
(assoc span :text (subs text 0 (- ol-start pos))))
|
||||
selected (merge span attrs
|
||||
{:text (subs text (- ol-start pos) (- ol-end pos))})
|
||||
after (when (< ol-end span-end)
|
||||
(assoc span :text (subs text (- ol-end pos))))]
|
||||
(recur (rest spans) span-end
|
||||
(-> acc
|
||||
(into (keep identity [before selected after])))))))))]
|
||||
(assoc para :children result)))
|
||||
|
||||
(defn- para-char-count
|
||||
[para]
|
||||
(apply + (map (fn [span] (count (:text span))) (:children para))))
|
||||
|
||||
(defn apply-style-to-selection
|
||||
[attrs use-shape-fn set-shape-text-content-fn]
|
||||
(when (and wasm/context-initialized? (text-editor-is-active?))
|
||||
(let [shape-id (text-editor-get-active-shape-id)
|
||||
sel (text-editor-get-selection)]
|
||||
(when (and shape-id sel)
|
||||
(let [content (get @shape-text-contents shape-id)]
|
||||
(when content
|
||||
(let [{:keys [start-para start-offset end-para end-offset]}
|
||||
(normalize-selection sel)
|
||||
collapsed? (and (= start-para end-para) (= start-offset end-offset))
|
||||
para-set (first (:children content))
|
||||
paras (:children para-set)
|
||||
new-paras
|
||||
(when (not collapsed?)
|
||||
(mapv (fn [idx para]
|
||||
(cond
|
||||
(or (< idx start-para) (> idx end-para))
|
||||
para
|
||||
(= start-para end-para)
|
||||
(apply-attrs-to-paragraph para start-offset end-offset attrs)
|
||||
(= idx start-para)
|
||||
(apply-attrs-to-paragraph para start-offset (para-char-count para) attrs)
|
||||
(= idx end-para)
|
||||
(apply-attrs-to-paragraph para 0 end-offset attrs)
|
||||
:else
|
||||
(apply-attrs-to-paragraph para 0 (para-char-count para) attrs)))
|
||||
(range (count paras))
|
||||
paras))
|
||||
new-content (when new-paras
|
||||
(assoc content :children
|
||||
[(assoc para-set :children new-paras)]))]
|
||||
(when new-content
|
||||
(swap! shape-text-contents assoc shape-id new-content)
|
||||
(use-shape-fn shape-id)
|
||||
(set-shape-text-content-fn shape-id new-content)
|
||||
{:shape-id shape-id
|
||||
:content new-content}))))))))
|
||||
240
frontend/src/app/render_wasm/text_editor_input.cljs
Normal file
@@ -0,0 +1,240 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.render-wasm.text-editor-input
|
||||
"Contenteditable DOM element for WASM text editor input"
|
||||
(:require
|
||||
[app.common.geom.shapes :as gsh]
|
||||
[app.main.data.workspace.texts :as dwt]
|
||||
[app.main.store :as st]
|
||||
[app.render-wasm.api :as wasm.api]
|
||||
[app.render-wasm.text-editor :as text-editor]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.object :as obj]
|
||||
[cuerdas.core :as str]
|
||||
[goog.events :as events]
|
||||
[rumext.v2 :as mf])
|
||||
(:import goog.events.EventType))
|
||||
|
||||
(defn- sync-wasm-text-editor-content!
|
||||
"Sync WASM text editor content back to the shape via the standard
|
||||
commit pipeline. Called after every text-modifying input."
|
||||
[& {:keys [finalize?]}]
|
||||
(when-let [{:keys [shape-id content]} (text-editor/text-editor-sync-content)]
|
||||
(st/emit! (dwt/v2-update-text-shape-content
|
||||
shape-id content
|
||||
:update-name? true
|
||||
:finalize? finalize?))))
|
||||
|
||||
(mf/defc text-editor-input
|
||||
"Contenteditable element positioned over the text shape to capture input events."
|
||||
{::mf/wrap-props false}
|
||||
[props]
|
||||
(let [shape (obj/get props "shape")
|
||||
zoom (obj/get props "zoom")
|
||||
vbox (obj/get props "vbox")
|
||||
|
||||
contenteditable-ref (mf/use-ref nil)
|
||||
composing? (mf/use-state false)
|
||||
|
||||
;; Calculate screen position from shape bounds
|
||||
shape-bounds (gsh/shape->rect shape)
|
||||
screen-x (* (- (:x shape-bounds) (:x vbox)) zoom)
|
||||
screen-y (* (- (:y shape-bounds) (:y vbox)) zoom)
|
||||
screen-w (* (:width shape-bounds) zoom)
|
||||
screen-h (* (:height shape-bounds) zoom)]
|
||||
|
||||
;; Focus contenteditable on mount
|
||||
(mf/use-effect
|
||||
(fn []
|
||||
(when-let [node (mf/ref-val contenteditable-ref)]
|
||||
(.focus node))
|
||||
js/undefined))
|
||||
|
||||
;; Animation loop for cursor blink
|
||||
(mf/use-effect
|
||||
(fn []
|
||||
(let [raf-id (atom nil)
|
||||
animate (fn animate []
|
||||
(when (text-editor/text-editor-is-active?)
|
||||
(wasm.api/request-render "cursor-blink")
|
||||
(reset! raf-id (js/requestAnimationFrame animate))))]
|
||||
(animate)
|
||||
(fn []
|
||||
(when @raf-id
|
||||
(js/cancelAnimationFrame @raf-id))))))
|
||||
|
||||
;; Document-level keydown handler for control keys
|
||||
(mf/use-effect
|
||||
(fn []
|
||||
(let [on-doc-keydown
|
||||
(fn [e]
|
||||
(when (and (text-editor/text-editor-is-active?)
|
||||
(not @composing?))
|
||||
(let [key (.-key e)
|
||||
ctrl? (or (.-ctrlKey e) (.-metaKey e))
|
||||
shift? (.-shiftKey e)]
|
||||
(cond
|
||||
;; Escape: finalize and stop
|
||||
(= key "Escape")
|
||||
(do
|
||||
(dom/prevent-default e)
|
||||
(sync-wasm-text-editor-content! :finalize? true)
|
||||
(text-editor/text-editor-stop))
|
||||
|
||||
;; Ctrl+A: select all (key is "a" or "A" depending on platform)
|
||||
(and ctrl? (= (str/lower key) "a"))
|
||||
(do
|
||||
(dom/prevent-default e)
|
||||
(text-editor/text-editor-select-all)
|
||||
(wasm.api/request-render "text-select-all"))
|
||||
|
||||
;; Enter
|
||||
(= key "Enter")
|
||||
(do
|
||||
(dom/prevent-default e)
|
||||
(text-editor/text-editor-insert-paragraph)
|
||||
(sync-wasm-text-editor-content!)
|
||||
(wasm.api/request-render "text-paragraph"))
|
||||
|
||||
;; Backspace
|
||||
(= key "Backspace")
|
||||
(do
|
||||
(dom/prevent-default e)
|
||||
(text-editor/text-editor-delete-backward)
|
||||
(sync-wasm-text-editor-content!)
|
||||
(wasm.api/request-render "text-delete-backward"))
|
||||
|
||||
;; Delete
|
||||
(= key "Delete")
|
||||
(do
|
||||
(dom/prevent-default e)
|
||||
(text-editor/text-editor-delete-forward)
|
||||
(sync-wasm-text-editor-content!)
|
||||
(wasm.api/request-render "text-delete-forward"))
|
||||
|
||||
;; Arrow keys
|
||||
(= key "ArrowLeft")
|
||||
(do
|
||||
(dom/prevent-default e)
|
||||
(text-editor/text-editor-move-cursor 0 shift?)
|
||||
(wasm.api/request-render "text-cursor-move"))
|
||||
|
||||
(= key "ArrowRight")
|
||||
(do
|
||||
(dom/prevent-default e)
|
||||
(text-editor/text-editor-move-cursor 1 shift?)
|
||||
(wasm.api/request-render "text-cursor-move"))
|
||||
|
||||
(= key "ArrowUp")
|
||||
(do
|
||||
(dom/prevent-default e)
|
||||
(text-editor/text-editor-move-cursor 2 shift?)
|
||||
(wasm.api/request-render "text-cursor-move"))
|
||||
|
||||
(= key "ArrowDown")
|
||||
(do
|
||||
(dom/prevent-default e)
|
||||
(text-editor/text-editor-move-cursor 3 shift?)
|
||||
(wasm.api/request-render "text-cursor-move"))
|
||||
|
||||
(= key "Home")
|
||||
(do
|
||||
(dom/prevent-default e)
|
||||
(text-editor/text-editor-move-cursor 4 shift?)
|
||||
(wasm.api/request-render "text-cursor-move"))
|
||||
|
||||
(= key "End")
|
||||
(do
|
||||
(dom/prevent-default e)
|
||||
(text-editor/text-editor-move-cursor 5 shift?)
|
||||
(wasm.api/request-render "text-cursor-move"))
|
||||
|
||||
;; Let contenteditable handle text input via on-input
|
||||
:else nil))))]
|
||||
(events/listen js/document EventType.KEYDOWN on-doc-keydown true)
|
||||
(fn []
|
||||
(events/unlisten js/document EventType.KEYDOWN on-doc-keydown true)))))
|
||||
|
||||
;; Composition and input events
|
||||
(let [on-composition-start
|
||||
(mf/use-fn
|
||||
(fn [_event]
|
||||
(reset! composing? true)))
|
||||
|
||||
on-composition-end
|
||||
(mf/use-fn
|
||||
(fn [^js event]
|
||||
(reset! composing? false)
|
||||
(let [data (.-data event)]
|
||||
(when data
|
||||
(text-editor/text-editor-insert-text data)
|
||||
(sync-wasm-text-editor-content!)
|
||||
(wasm.api/request-render "text-composition"))
|
||||
(when-let [node (mf/ref-val contenteditable-ref)]
|
||||
(set! (.-textContent node) "")))))
|
||||
|
||||
on-paste
|
||||
(mf/use-fn
|
||||
(fn [^js event]
|
||||
(dom/prevent-default event)
|
||||
(let [clipboard-data (.-clipboardData event)
|
||||
text (.getData clipboard-data "text/plain")]
|
||||
(when (and text (seq text))
|
||||
(text-editor/text-editor-insert-text text)
|
||||
(sync-wasm-text-editor-content!)
|
||||
(wasm.api/request-render "text-paste"))
|
||||
(when-let [node (mf/ref-val contenteditable-ref)]
|
||||
(set! (.-textContent node) "")))))
|
||||
|
||||
on-copy
|
||||
(mf/use-fn
|
||||
(fn [^js event]
|
||||
(when (text-editor/text-editor-is-active?)
|
||||
(dom/prevent-default event)
|
||||
(when (text-editor/text-editor-get-selection)
|
||||
(let [text (text-editor/text-editor-export-selection)]
|
||||
(.setData (.-clipboardData event) "text/plain" text))))))
|
||||
|
||||
on-input
|
||||
(mf/use-fn
|
||||
(fn [^js event]
|
||||
(let [native-event (.-nativeEvent event)
|
||||
input-type (.-inputType native-event)
|
||||
data (.-data native-event)]
|
||||
;; Skip composition-related input events - composition-end handles those
|
||||
(when (and (not @composing?)
|
||||
(not= input-type "insertCompositionText"))
|
||||
(when (and data (seq data))
|
||||
(text-editor/text-editor-insert-text data)
|
||||
(sync-wasm-text-editor-content!)
|
||||
(wasm.api/request-render "text-input"))
|
||||
(when-let [node (mf/ref-val contenteditable-ref)]
|
||||
(set! (.-textContent node) ""))))))]
|
||||
|
||||
[:div
|
||||
{:ref contenteditable-ref
|
||||
:contentEditable true
|
||||
:suppressContentEditableWarning true
|
||||
:on-composition-start on-composition-start
|
||||
:on-composition-end on-composition-end
|
||||
:on-input on-input
|
||||
:on-paste on-paste
|
||||
:on-copy on-copy
|
||||
;; FIXME on-click
|
||||
;; :on-click on-click
|
||||
:id "text-editor-wasm-input"
|
||||
;; FIXME
|
||||
:style {:position "absolute"
|
||||
:left (str screen-x "px")
|
||||
:top (str screen-y "px")
|
||||
:width (str screen-w "px")
|
||||
:height (str screen-h "px")
|
||||
:opacity 0
|
||||
:overflow "hidden"
|
||||
:white-space "pre"
|
||||
:cursor "text"
|
||||
:z-index 10}}])))
|
||||
@@ -82,6 +82,26 @@ The `TextEditor` contains a series of references to DOM elements, one of them is
|
||||
|
||||
`ChangeController` is called by the `TextEditor` instance everytime a change is performed on the content of the `contenteditable` element.
|
||||
|
||||
### Best practices
|
||||
|
||||
#### Use `isType` functions
|
||||
|
||||
Instead of handling elements by their properties like this:
|
||||
|
||||
```javascript
|
||||
if (element.tagName === "SPAN") {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Use functions like `isParagraph`, `isTextSpan` or `isLineBreak`:
|
||||
|
||||
```javascript
|
||||
if (isTextSpan(element)) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Events
|
||||
|
||||
- `change`: This event is dispatched every time a change is made in the editor. All changes are debounced to prevent dispatching too many change events. This event is also dispatched when there are pending change events and the user blurs the textarea element.
|
||||
|
||||
@@ -326,9 +326,7 @@ export class TextEditor extends EventTarget {
|
||||
* @param {FocusEvent} e
|
||||
*/
|
||||
#onBlur = (e) => {
|
||||
if (!this.isEmpty) {
|
||||
this.#changeController.notifyImmediately();
|
||||
}
|
||||
this.#changeController.notifyImmediately();
|
||||
this.#selectionController.saveSelection();
|
||||
this.dispatchEvent(new FocusEvent(e.type, e));
|
||||
};
|
||||
@@ -685,7 +683,7 @@ export function createRootFromString(string) {
|
||||
* Returns true if the passed object is a TextEditor
|
||||
* instance.
|
||||
*
|
||||
* @param {TextEditor} instance
|
||||
* @param {*} instance
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isTextEditor(instance) {
|
||||
@@ -716,7 +714,7 @@ export function getRoot(instance) {
|
||||
if (isTextEditor(instance)) {
|
||||
return instance.root;
|
||||
}
|
||||
throw new TypeError("Instance is not a TextEditor");
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -756,7 +754,7 @@ export function getCurrentStyle(instance) {
|
||||
if (isTextEditor(instance)) {
|
||||
return instance.currentStyle;
|
||||
}
|
||||
throw new TypeError("Instance is not a TextEditor");
|
||||
throw new TypeError('Instance is not a TextEditor');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -771,7 +769,7 @@ export function applyStylesToSelection(instance, styles) {
|
||||
if (isTextEditor(instance)) {
|
||||
return instance.applyStylesToSelection(styles);
|
||||
}
|
||||
throw new TypeError("Instance is not a TextEditor");
|
||||
throw new TypeError('Instance is not a TextEditor');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -785,7 +783,7 @@ export function dispose(instance) {
|
||||
if (isTextEditor(instance)) {
|
||||
return instance.dispose();
|
||||
}
|
||||
throw new TypeError("Instance is not a TextEditor");
|
||||
throw new TypeError('Instance is not a TextEditor');
|
||||
}
|
||||
|
||||
export default TextEditor;
|
||||
|
||||
@@ -336,20 +336,22 @@ export function getStyle(element, styleName, styleUnit) {
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
export function setStylesFromObject(element, allowedStyles, styleObject) {
|
||||
if (element.tagName === "SPAN")
|
||||
for (const [styleName, styleUnit] of allowedStyles) {
|
||||
if (!(styleName in styleObject)) {
|
||||
continue;
|
||||
}
|
||||
let styleValue = styleObject[styleName];
|
||||
if (!styleValue) continue;
|
||||
|
||||
if (styleName === "font-family") {
|
||||
styleValue = sanitizeFontFamily(styleValue);
|
||||
}
|
||||
|
||||
setStyle(element, styleName, styleValue, styleUnit);
|
||||
for (const [styleName, styleUnit] of allowedStyles) {
|
||||
if (!(styleName in styleObject)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let styleValue = styleObject[styleName];
|
||||
if (!styleValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (styleName === "font-family") {
|
||||
styleValue = sanitizeFontFamily(styleValue);
|
||||
}
|
||||
|
||||
setStyle(element, styleName, styleValue, styleUnit);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
|
||||
@@ -1961,7 +1961,8 @@ export class SelectionController extends EventTarget {
|
||||
this.setSelection(newTextSpan.firstChild, 0, newTextSpan.firstChild, 0);
|
||||
}
|
||||
// The styles are applied to the paragraph
|
||||
else {
|
||||
else
|
||||
{
|
||||
const paragraph = this.startParagraph;
|
||||
setParagraphStyles(paragraph, newStyles);
|
||||
// Apply styles to child text spans.
|
||||
@@ -1969,11 +1970,9 @@ export class SelectionController extends EventTarget {
|
||||
setTextSpanStyles(textSpan, newStyles);
|
||||
}
|
||||
}
|
||||
return this.#notifyStyleChange();
|
||||
|
||||
// If the startContainer and endContainer are different
|
||||
// then we need to iterate through those nodes to apply
|
||||
// the styles.
|
||||
// If the startContainer and endContainer are different
|
||||
// then we need to iterate through those nodes to apply
|
||||
// the styles.
|
||||
} else if (startNode !== endNode) {
|
||||
const safeGuard = new SafeGuard("applyStylesTo");
|
||||
safeGuard.start();
|
||||
@@ -2022,12 +2021,12 @@ export class SelectionController extends EventTarget {
|
||||
}
|
||||
|
||||
// We've reached the final node so we can return safely.
|
||||
if (this.#textNodeIterator.currentNode === expectedEndNode) return;
|
||||
if (this.#textNodeIterator.currentNode === expectedEndNode)
|
||||
break;
|
||||
|
||||
this.#textNodeIterator.nextNode();
|
||||
} while (this.#textNodeIterator.currentNode);
|
||||
}
|
||||
|
||||
return this.#notifyStyleChange();
|
||||
}
|
||||
|
||||
|
||||
@@ -90,32 +90,6 @@ This bootstrap command will:
|
||||
* build all components (`pnpm -r run build`)
|
||||
* start all components (`pnpm -r --parallel run start`)
|
||||
|
||||
If you want to have types scrapped from a remote repository, the best
|
||||
approach is executing the following:
|
||||
|
||||
```shell
|
||||
PENPOT_PLUGINS_API_DOC_URL=https://doc.plugins.penpot.app pnpm run build:types
|
||||
pnpm run bootstrap
|
||||
```
|
||||
|
||||
Or this, if you want skip build step bacause you have already have all
|
||||
build artifacts ready (per example from previous `bootstrap` command):
|
||||
|
||||
```
|
||||
PENPOT_PLUGINS_API_DOC_URL=https://doc.plugins.penpot.app pnpm run build:types
|
||||
pnpm run start
|
||||
```
|
||||
|
||||
If you want just to update the types definitions with the plugins api doc from the
|
||||
current branch:
|
||||
|
||||
```shell
|
||||
pnpm run build:types
|
||||
```
|
||||
|
||||
(That command will build plugins doc locally and will generate the types yaml from
|
||||
the locally build documentation)
|
||||
|
||||
### 2. Load the Plugin in Penpot and Establish the Connection
|
||||
|
||||
> [!NOTE]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"scripts": {
|
||||
"build": "pnpm -r run build",
|
||||
"build:multi-user": "pnpm -r run build:multi-user",
|
||||
"build:types": "./scripts/build-types",
|
||||
"build:types": "bash ./scripts/build-types",
|
||||
"start": "pnpm -r --parallel run start",
|
||||
"start:multi-user": "pnpm -r --parallel --filter \"./packages/*\" run start:multi-user",
|
||||
"bootstrap": "pnpm -r install && pnpm run build && pnpm run start",
|
||||
|
||||
@@ -25,7 +25,6 @@ export class PenpotUtils {
|
||||
id: shape.id,
|
||||
name: shape.name,
|
||||
type: shape.type,
|
||||
children: children,
|
||||
};
|
||||
|
||||
// add layout information if present
|
||||
@@ -48,6 +47,23 @@ export class PenpotUtils {
|
||||
};
|
||||
}
|
||||
|
||||
// add component instance information if present
|
||||
if (shape.isComponentInstance()) {
|
||||
result.componentInstance = {};
|
||||
const component = shape.component();
|
||||
if (component) {
|
||||
result.componentInstance.componentId = component.id;
|
||||
result.componentInstance.componentName = component.name;
|
||||
const mainInstance = component.mainInstance();
|
||||
if (mainInstance) {
|
||||
result.componentInstance.mainInstanceId = mainInstance.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// finally, add children (last for more readable nesting order)
|
||||
result.children = children;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -55,9 +71,9 @@ export class PenpotUtils {
|
||||
* Finds all shapes that matches the given predicate in the given shape tree.
|
||||
*
|
||||
* @param predicate - A function that takes a shape and returns true if it matches the criteria
|
||||
* @param root - The root shape to start the search from (defaults to penpot.root)
|
||||
* @param root - The root shape to start the search from (if null, searches all pages)
|
||||
*/
|
||||
public static findShapes(predicate: (shape: Shape) => boolean, root: Shape | null = penpot.root): Shape[] {
|
||||
public static findShapes(predicate: (shape: Shape) => boolean, root: Shape | null = null): Shape[] {
|
||||
let result = new Array<Shape>();
|
||||
|
||||
let find = function (shape: Shape | null) {
|
||||
@@ -74,7 +90,16 @@ export class PenpotUtils {
|
||||
}
|
||||
};
|
||||
|
||||
find(root);
|
||||
if (root === null) {
|
||||
const pages = penpot.currentFile?.pages;
|
||||
if (pages) {
|
||||
for (let page of pages) {
|
||||
find(page.root);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
find(root);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -422,4 +447,94 @@ export class PenpotUtils {
|
||||
throw new Error(`Unsupported export mode: ${mode}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all tokens that match the given name across all token sets.
|
||||
*
|
||||
* @param name - The name of the token to search for (case-sensitive exact match)
|
||||
* @returns An array of all matching tokens (may be empty)
|
||||
*/
|
||||
public static findTokensByName(name: string): any[] {
|
||||
const tokens: any[] = [];
|
||||
// @ts-ignore
|
||||
const tokenCatalog = penpot.library.local.tokens;
|
||||
|
||||
for (const set of tokenCatalog.sets) {
|
||||
for (const token of set.tokens) {
|
||||
if (token.name === name) {
|
||||
tokens.push(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the first token that matches the given name across all token sets.
|
||||
*
|
||||
* @param name - The name of the token to search for (case-sensitive exact match)
|
||||
* @returns The first matching token, or null if not found
|
||||
*/
|
||||
public static findTokenByName(name: string): any | null {
|
||||
// @ts-ignore
|
||||
const tokenCatalog = penpot.library.local.tokens;
|
||||
|
||||
for (const set of tokenCatalog.sets) {
|
||||
for (const token of set.tokens) {
|
||||
if (token.name === name) {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the token set that contains the given token.
|
||||
*
|
||||
* @param token - The token whose set to find
|
||||
* @returns The TokenSet containing this token, or null if not found
|
||||
*/
|
||||
public static getTokenSet(token: any): any | null {
|
||||
// @ts-ignore
|
||||
const tokenCatalog = penpot.library.local.tokens;
|
||||
|
||||
for (const set of tokenCatalog.sets) {
|
||||
if (set.tokens.includes(token)) {
|
||||
return set;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an overview of all tokens organized by token set name, token type, and token name.
|
||||
* The result is a nested object structure: {tokenSetName: {tokenType: [tokenName, ...]}}.
|
||||
*
|
||||
* @returns An object mapping token set names to objects that map token types to arrays of token names
|
||||
*/
|
||||
public static tokenOverview(): Record<string, Record<string, string[]>> {
|
||||
const overview: Record<string, Record<string, string[]>> = {};
|
||||
// @ts-ignore
|
||||
const tokenCatalog = penpot.library.local.tokens;
|
||||
|
||||
for (const set of tokenCatalog.sets) {
|
||||
const setOverview: Record<string, string[]> = {};
|
||||
|
||||
for (const token of set.tokens) {
|
||||
const tokenType = token.type;
|
||||
if (!setOverview[tokenType]) {
|
||||
setOverview[tokenType] = [];
|
||||
}
|
||||
setOverview[tokenType].push(token.name);
|
||||
}
|
||||
|
||||
overview[set.name] = setOverview;
|
||||
}
|
||||
|
||||
return overview;
|
||||
}
|
||||
}
|
||||
|
||||
333
mcp/packages/server/data/initial_instructions.md
Normal file
@@ -0,0 +1,333 @@
|
||||
You have access to Penpot tools in order to interact with a Penpot design project directly.
|
||||
As a precondition, the user must connect the Penpot design project to the MCP server using the Penpot MCP Plugin.
|
||||
|
||||
IMPORTANT: When transferring styles from a Penpot design to code, make sure that you strictly adhere to the design.
|
||||
NEVER make assumptions about missing values and don't get overly creative (e.g. don't pick your own colours and stick to
|
||||
non-creative defaults such as white/black if you are lacking information).
|
||||
|
||||
# Executing Code
|
||||
|
||||
One of your key tools is the `execute_code` tool, which allows you to run JavaScript code using the Penpot Plugin API
|
||||
directly in the connected project.
|
||||
|
||||
VERY IMPORTANT: When writing code, NEVER LOG INFORMATION YOU ARE ALSO RETURNING. It would duplicate the information you receive!
|
||||
|
||||
To execute code correctly, you need to understand the Penpot Plugin API. You can retrieve API documentation via
|
||||
the `penpot_api_info` tool.
|
||||
|
||||
This is the full list of types/interfaces in the Penpot API: $api_types
|
||||
|
||||
You use the `storage` object extensively to store data and utility functions you define across tool calls.
|
||||
This allows you to inspect intermediate results while still being able to build on them in subsequent code executions.
|
||||
|
||||
# The Structure of Penpot Designs
|
||||
|
||||
A Penpot design ultimately consists of shapes.
|
||||
The type `Shape` is a union type, which encompasses both containers and low-level shapes.
|
||||
Shapes in a Penpot design are organized hierarchically.
|
||||
At the top level, a design project contains one or more `Page` objects.
|
||||
Each `Page` contains a tree of elements. For a given instance `page`, its root shape is `page.root`.
|
||||
A Page is frequently structured into boards. A `Board` is a high-level grouping element.
|
||||
A `Group` is a more low-level grouping element used to organize low-level shapes into a logical unit.
|
||||
Actual low-level shape types are `Rectangle`, `Path`, `Text`, `Ellipse`, `Image`, `Boolean`, and `SvgRaw`.
|
||||
`ShapeBase` is a base type most shapes build upon.
|
||||
|
||||
# Core Shape Properties and Methods
|
||||
|
||||
**Type**:
|
||||
Any given shape contains information on the concrete type via its `type` field.
|
||||
|
||||
**Position and Dimensions**:
|
||||
* The location properties `x` and `y` refer to the top left corner of a shape's bounding box in the absolute (Page) coordinate system.
|
||||
These are writable - set them directly to position shapes.
|
||||
* `parentX` and `parentY` (as well as `boardX` and `boardY`) are READ-ONLY computed properties showing position relative to parent/board.
|
||||
To position relative to parent, use `penpotUtils.setParentXY(shape, parentX, parentY)` or manually set `shape.x = parent.x + parentX`.
|
||||
* `width` and `height` are READ-ONLY. Use `resize(width, height)` method to change dimensions.
|
||||
* `bounds` is a READ-ONLY property. Use `x`, `y` with `resize()` to modify shape bounds.
|
||||
|
||||
**Other Writable Properties**:
|
||||
* `name` - Shape name
|
||||
* `fills`, `strokes` - Styling properties
|
||||
* `rotation`, `opacity`, `blocked`, `hidden`, `visible`
|
||||
|
||||
**Z-Order**:
|
||||
* The z-order of shapes is determined by the order in the `children` array of the parent shape.
|
||||
Therefore, when creating shapes that should be on top of each other, add them to the parent in the correct order
|
||||
(i.e. add background shapes first, then foreground shapes later).
|
||||
CRITICAL: NEVER use the broken function `appendChild` to achieve this, ALWAYS use `parent.insertChild(parent.children.length, shape)`
|
||||
* To modify z-order after creation, use these methods: `bringToFront()`, `sendToBack()`, `bringForward()`, `sendBackward()`,
|
||||
and, for precise control, `setParentIndex(index)` (0-based).
|
||||
|
||||
**Modification Methods**:
|
||||
* `resize(width, height)` - Change dimensions (required for width/height since they're read-only)
|
||||
* `rotate(angle, center?)` - Rotate shape
|
||||
* `remove()` - Permanently destroy the shape (use only for deletion, NOT for reparenting).
|
||||
Exception: When the shape is a descendant of a board that is a component (asset), the shape will not be removed but instead be made invisible.
|
||||
|
||||
**Hierarchical Structure**:
|
||||
* `parent` - The parent shape (null for root shapes)
|
||||
Note: Hierarchical nesting does not necessarily imply visual containment
|
||||
* CRITICAL: To add children to a parent shape (e.g. a `Board`):
|
||||
- ALWAYS use `parent.insertChild(index, shape)` to add a child, e.g. `parent.insertChild(parent.children.length, shape)` to append
|
||||
- NEVER use `parent.appendChild(shape)` as it is BROKEN and will not insert in a predictable place (except in flex layout boards)
|
||||
* Reparenting: `newParent.appendChild(shape)` or `newParent.insertChild(index, shape)` will move a shape to new parent
|
||||
- Automatically removes the shape from its old parent
|
||||
- Absolute x/y positions are preserved (use `penpotUtils.setParentXY` to adjust relative position)
|
||||
|
||||
Cloning: Use `shape.clone(): Shape` to create an exact duplicate (including all properties and children) of a shape; same position as original.
|
||||
|
||||
# Images
|
||||
|
||||
The `Image` type is a legacy type. Images are now typically embedded in a `Fill`, with `fillImage` set to an
|
||||
`ImageData` object, i.e. the `fills` property of of a shape (e.g. a `Rectangle`) will contain a fill where `fillImage` is set.
|
||||
Use the `export_shape` and `import_image` tools to export and import images.
|
||||
|
||||
# Layout Systems
|
||||
|
||||
Boards can have layout systems that automatically control the positioning and spacing of their children:
|
||||
|
||||
* If a board has a layout system, then child positions are controlled by the layout system.
|
||||
For every child, key properties of the child within the layout are stored in `child.layoutChild: LayoutChildProperties`:
|
||||
- `absolute: boolean` - if true, child position is not controlled by layout system. x/y will set *relative* position within parent!
|
||||
- margins (`topMargin`, `rightMargin`, `bottomMargin`, `leftMargin` or combined `verticalMargin`, `horizontalMargin`)
|
||||
- sizing (`verticalSizing`, `horizontalSizing`: "fill" | "auto" | "fix")
|
||||
- min/max sizes (`minWidth`, `maxWidth`, `minHeight`, `maxHeight`)
|
||||
- `zIndex: number` (higher numbers on top)
|
||||
|
||||
* **Flex Layout**: A flexbox-style layout system
|
||||
- Properties: `dir`, `rowGap`, `columnGap`, `alignItems`, `justifyContent`;
|
||||
- `dir`: "row" | "column" | "row-reverse" | "column-reverse"
|
||||
- Padding: `topPadding`, `rightPadding`, `bottomPadding`, `leftPadding`, or combined `verticalPadding`, `horizontalPadding`
|
||||
- To modify spacing: adjust `rowGap` and `columnGap` properties, not individual child positions.
|
||||
Optionally, adjust indivudual child margins via `child.layoutChild`.
|
||||
- When a board has flex layout,
|
||||
- child positions are controlled by the layout system, not by individual x/y coordinates (unless `child.layoutChild.absolute` is true);
|
||||
appending or inserting children automatically positions them according to the layout rules.
|
||||
- CRITICAL: For for dir="column" or dir="row", the order of the `children` array is reversed relative to the visual order!
|
||||
Therefore, the element that appears first in the array, appears visually at the end (bottom/right) and vice versa.
|
||||
ALWAYS BEAR IN MIND THAT THE CHILDREN ARRAY ORDER IS REVERSED FOR dir="column" OR dir="row"!
|
||||
- CRITICAL: The FlexLayout method `board.flex.appendChild` is BROKEN. To append children to a flex layout board such that
|
||||
they appear visually at the end, ALWAYS use the Board's method `board.appendChild(shape)`; it will insert at the front
|
||||
of the `children` array for dir="column" or dir="row", which is what you want. So call it in the order of visual appearance.
|
||||
To insert at a specific index, use `board.insertChild(index, shape)`, bearing in mind the reversed order for dir="column"
|
||||
or dir="row".
|
||||
- Add to a board with `board.addFlexLayout(): FlexLayout`; instance then accessible via `board.flex`.
|
||||
IMPORTANT: When adding a flex layout to a container that already has children,
|
||||
use `penpotUtils.addFlexLayout(container, dir)` instead! This preserves the existing visual order of children.
|
||||
Otherwise, children will be arbitrarily reordered when the children order suddenly determines the display order.
|
||||
- Check with: `if (board.flex) { ... }`
|
||||
|
||||
* **Grid Layout**: A CSS grid-style layout system
|
||||
- Add to a board with `board.addGridLayout(): GridLayout`; instance then accessibly via `board.grid`;
|
||||
Check with: `if (board.grid) { ... }`
|
||||
- Properties: `rows`, `columns`, `rowGap`, `columnGap`
|
||||
- Children are positioned via 1-based row/column indices
|
||||
- Add to grid via `board.flex.appendChild(shape, row, column)`
|
||||
- Modify grid positioning after the fact via `shape.layoutCell: LayoutCellProperties`
|
||||
|
||||
* When working with boards:
|
||||
- ALWAYS check if the board has a layout system before attempting to reposition children
|
||||
- Modify layout properties (gaps, padding) instead of trying to set child x/y positions directly
|
||||
- Layout systems override manual positioning of children
|
||||
|
||||
# Text Elements
|
||||
|
||||
The rendered content of `Text` element is given by the `characters` property.
|
||||
|
||||
To change the size of the text, change the `fontSize` property; applying `resize()` does NOT change the font size,
|
||||
it only changes the formal bounding box; if the text does not fit it, it will overflow.
|
||||
The bounding box is sized automatically as long as the `growType` property is set to "auto-width" or "auto-height".
|
||||
`resize` always sets `growType` to "fixed", so ALWAYS set it back to "auto-*" if you want automatic sizing - otherwise the bounding box will be meaningless, with the text overflowing!
|
||||
The auto-sizing is not immediate; sleep for a short time (100ms) if you want to read the updated bounding box.
|
||||
|
||||
# The `penpot` and `penpotUtils` Objects, Exploring Designs
|
||||
|
||||
A key object to use in your code is the `penpot` object (which is of type `Penpot`):
|
||||
* `penpot.selection` provides the list of shapes the user has selected in the Penpot UI.
|
||||
If it is unclear which elements to work on, you can ask the user to select them for you.
|
||||
ALWAYS immediately copy the selected shape(s) into `storage`! Do not assume that the selection remains unchanged.
|
||||
* `penpot.root` provides the root shape of the currently active page.
|
||||
* Generation of CSS content for elements via `penpot.generateStyle`
|
||||
* Generation of HTML/SVG content for elements via `penpot.generateMarkup`
|
||||
|
||||
For example, to generate CSS for the currently selected elements, you can execute this:
|
||||
return penpot.generateStyle(penpot.selection, { type: "css", withChildren: true });
|
||||
|
||||
CRITICAL: The `penpotUtils` object provides essential utilities - USE THESE INSTEAD OF WRITING YOUR OWN:
|
||||
* getPages(): { id: string; name: string }[]
|
||||
* getPageById(id: string): Page | null
|
||||
* getPageByName(name: string): Page | null
|
||||
* shapeStructure(shape: Shape, maxDepth: number | undefined = undefined): { id, name, type, children?, layout? }
|
||||
Generates an overview structure of the given shape.
|
||||
- children: recursive, limited by maxDepth
|
||||
- layout: present if shape has flex/grid layout, contains { type: "flex" | "grid", ... }
|
||||
* findShapeById(id: string): Shape | null
|
||||
* findShape(predicate: (shape: Shape) => boolean, root: Shape | null = null): Shape | null
|
||||
If no root is provided, search globally (in all pages).
|
||||
* findShapes(predicate: (shape: Shape) => boolean, root: Shape | null = null): Shape[]
|
||||
* isContainedIn(shape: Shape, container: Shape): boolean
|
||||
Returns true iff shape is fully within the container's geometric bounds.
|
||||
Note that a shape's bounds may not always reflect its actual visual content - descendants can overflow; check using analyzeDescendants (see below).
|
||||
* setParentXY(shape: Shape, parentX: number, parentY: number): void
|
||||
Sets shape position relative to its parent (since parentX/parentY are read-only)
|
||||
* analyzeDescendants<T>(root: Shape, evaluator: (root: Shape, descendant: Shape) => T | null | undefined, maxDepth?: number): Array<{ shape: Shape, result: T }>
|
||||
General-purpose utility for analyzing/validating descendants
|
||||
Calls evaluator on each descendant; collects non-null/undefined results
|
||||
Powerful pattern: evaluator can return corrector functions or diagnostic data
|
||||
* Further functions for specific tasks (described in the sections below)
|
||||
|
||||
General pointers for working with Penpot designs:
|
||||
* Prefer `penpotUtils` helper functions — avoid reimplementing shape searching.
|
||||
* To get an overview of a single page, use `penpotUtils.shapeStructure(page.root, 3)`.
|
||||
Note that `penpot.root` refers to the current page only. When working across pages, first determine the relevant page(s).
|
||||
* Use `penpotUtils.findShapes()` or `penpotUtils.findShape()` with predicates to locate elements efficiently.
|
||||
|
||||
Common tasks - Quick Reference (ALWAYS use penpotUtils for these):
|
||||
* Find all images:
|
||||
const images = penpotUtils.findShapes(
|
||||
shape => shape.type === 'image' || shape.fills?.some(fill => fill.fillImage),
|
||||
penpot.root
|
||||
);
|
||||
* Find text elements:
|
||||
const texts = penpotUtils.findShapes(shape => shape.type === 'text', penpot.root);
|
||||
* Find (the first) shape with a given name:
|
||||
const shape = penpotUtils.findShape(shape => shape.name === 'MyShape');
|
||||
* Get structure of current selection:
|
||||
const structure = penpotUtils.shapeStructure(penpot.selection[0]);
|
||||
* Find shapes in current selection/board:
|
||||
const shapes = penpotUtils.findShapes(predicate, penpot.selection[0] || penpot.root);
|
||||
* Validate/analyze descendants (returning corrector functions):
|
||||
const fixes = penpotUtils.analyzeDescendants(board, (root, shape) => {
|
||||
const xMod = shape.parentX % 4;
|
||||
if (xMod !== 0) {
|
||||
return () => penpotUtils.setParentXY(shape, Math.round(shape.parentX / 4) * 4, shape.parentY);
|
||||
}
|
||||
});
|
||||
fixes.forEach(f => f.result()); // Apply all fixes
|
||||
* Find containment violations:
|
||||
const violations = penpotUtils.analyzeDescendants(board, (root, shape) => {
|
||||
return !penpotUtils.isContainedIn(shape, root) ? 'outside-bounds' : null;
|
||||
});
|
||||
Always validate against the root container that is supposed to contain the shapes.
|
||||
|
||||
# Visual Inspection of Designs
|
||||
|
||||
For many tasks, it can be critical to visually inspect the design. Remember to use the `export_shape` tool for this purpose!
|
||||
|
||||
# Revising Designs
|
||||
|
||||
* Before applying design changes, ask: "Would a designer consider this appropriate?"
|
||||
* When dealing with containment issues, ask: Is the parent too small OR is the child too large?
|
||||
Container sizes are usually intentional, check content first.
|
||||
* Check for reasonable font sizes and typefaces
|
||||
* The use of flex layouts is encouraged for cases where elements are arranged in rows or columns with consistent spacing/positioning.
|
||||
Consider converting boards to flex layout when appropriate.
|
||||
|
||||
# Asset Libraries
|
||||
|
||||
Libraries in Penpot are collections of reusable design assets (components, colors, and typographies) that can be shared across files.
|
||||
They enable design systems and consistent styling across projects.
|
||||
Each Penpot file has its own local library and can connect to external shared libraries.
|
||||
|
||||
Accessing libraries: via `penpot.library` (type: `LibraryContext`):
|
||||
* `penpot.library.local` (type: `Library`) - The current file's own library
|
||||
* `penpot.library.connected` (type: `Library[]`) - Array of already-connected external libraries
|
||||
* `penpot.library.availableLibraries()` (returns: `Promise<LibrarySummary[]>`) - Libraries available to connect
|
||||
* `penpot.library.connectLibrary(libraryId: string)` (returns: `Promise<Library>`) - Connect a new library
|
||||
|
||||
Each `Library` object has:
|
||||
* `id: string`
|
||||
* `name: string`
|
||||
* `components: LibraryComponent[]` - Array of components
|
||||
* `colors: LibraryColor[]` - Array of colors
|
||||
* `typographies: LibraryTypography[]` - Array of typographies
|
||||
|
||||
Using library components:
|
||||
* find a component in the library by name:
|
||||
const component: LibraryComponent = library.components.find(comp => comp.name.includes('Button'));
|
||||
* create a new instance of the component on the current page:
|
||||
const instance: Shape = component.instance();
|
||||
This returns a `Shape` (often a `Board` containing child elements).
|
||||
After instantiation, modify the instance's properties as desired.
|
||||
* get the reference to the main component shape:
|
||||
const mainShape: Shape = component.mainInstance();
|
||||
|
||||
Adding assets to a library:
|
||||
* const newColor: LibraryColor = penpot.library.local.createColor();
|
||||
newColor.name = 'Brand Primary';
|
||||
newColor.color = '#0066FF';
|
||||
* const newTypo: LibraryTypography = penpot.library.local.createTypography();
|
||||
newTypo.name = 'Heading Large';
|
||||
// Set typography properties...
|
||||
* const shapes: Shape[] = [shape1, shape2]; // shapes to include
|
||||
const newComponent: LibraryComponent = penpot.library.local.createComponent(shapes);
|
||||
newComponent.name = 'My Button';
|
||||
|
||||
Detaching:
|
||||
* When creating new design elements based on a component instance/copy, use `shape.detach()` to break the link to the main component, allowing independent modification.
|
||||
* Without detaching, some manipulations will have no effect; e.g. child/descendant removal will not work.
|
||||
|
||||
# Design Tokens
|
||||
|
||||
Design tokens are reusable design values (colors, dimensions, typography, etc.) for consistent styling.
|
||||
|
||||
The token library: `penpot.library.local.tokens` (type: `TokenCatalog`)
|
||||
* `sets: TokenSet[]` - Token collections (order matters for precedence)
|
||||
* `themes: TokenTheme[]` - Presets that activate specific sets
|
||||
* `addSet(name: string): TokenSet` - Create new set
|
||||
* `addTheme(group: string, name: string): TokenTheme` - Create new theme
|
||||
|
||||
`TokenSet` contains tokens with unique names:
|
||||
* `active: boolean` - Only active sets affect shapes; use `set.toggleActive()` to change: `if (!set.active) set.toggleActive();`
|
||||
* `tokens: Token[]` - All tokens in set
|
||||
* `addToken(type: TokenType, name: string, value: TokenValueString): Token` - Creates a token, adding it to the set.
|
||||
- `TokenType`: "color" | "dimension" | "spacing" | "typography" | "shadow" | "opacity" | "borderRadius" | "borderWidth" | "fontWeights" | "fontSizes" | "fontFamilies" | "letterSpacing" | "textDecoration" | "textCase"
|
||||
- Examples:
|
||||
const token = set.addToken("color", "color.primary", "#0066FF"); // direct value
|
||||
const token2 = set.addToken("color", "color.accent", "{color.primary}"); // reference to another token
|
||||
|
||||
`Token`:
|
||||
* `name: string` - Token name (may include group path like "color.base.white")
|
||||
* `value: string | TokenValueString` - Raw value (may be direct value or reference to another token like "{color.primary}")
|
||||
* `resolvedValue` - Computed final value (follows references)
|
||||
* `type: TokenType`
|
||||
|
||||
Discovering tokens:
|
||||
* `penpotUtils.tokenOverview()`: Maps from token set name to a mapping from token type to list of token names
|
||||
* `penpotUtils.findTokenByName(name: string): Token | null`: Finds the first applicable token matching the given name
|
||||
* `penpotUtils.findTokensByName(name: string): Token[]`: Finds all tokens that match the given name across all token sets
|
||||
* `penpotUtils.getTokenSet(token: Token): TokenSet | null`: Gets the token set that contains the given token
|
||||
|
||||
Applying tokens:
|
||||
* `shape.applyToken(token, properties: undefined | TokenProperty[])` - Apply a token to a shape for one or more properties
|
||||
(if properties is undefined, use a default property based on the token type - not usually recommended).
|
||||
`TokenProperty` is a union type; possible values are:
|
||||
- "all": applies the token to all properties it can control
|
||||
- TokenBorderRadiusProps: "r1", "r2", "r3", "r4"
|
||||
- TokenShadowProps: "shadow"
|
||||
- TokenColorProps: "fill", "stroke-color"
|
||||
- TokenDimensionProps: "x", "y", "stroke-width"
|
||||
- TokenFontFamiliesProps: "font-families"
|
||||
- TokenFontSizesProps: "font-size"
|
||||
- TokenFontWeightProps: "font-weight"
|
||||
- TokenLetterSpacingProps: "letter-spacing"
|
||||
- TokenNumberProps: "rotation", "line-height"
|
||||
- TokenOpacityProps: "opacity"
|
||||
- TokenSizingProps: "width", "height", "layout-item-min-w", "layout-item-max-w", "layout-item-min-h", "layout-item-max-h"
|
||||
- TokenSpacingProps: "row-gap", "column-gap", "p1", "p2", "p3", "p4", "m1", "m2", "m3", "m4"
|
||||
- TokenBorderWidthProps: "stroke-width"
|
||||
- TokenTextCaseProps: "text-case"
|
||||
- TokenTextDecorationProps: "text-decoration"
|
||||
- TokenTypographyProps: "typography"
|
||||
* `token.applyToShapes(shapes, properties)` - Apply from token
|
||||
* Application is **asynchronous** (wait for ~100ms to see the effects)
|
||||
* After application:
|
||||
- `shape.tokens` returns a mapping `{ propertyName: "token.name" }` from `TokenProperty` to token name
|
||||
- The actual shape properties that the tokens control will reflect the token's resolved value.
|
||||
|
||||
Removing tokens:
|
||||
Simply set the respective property directly - token binding is automatically removed, e.g.
|
||||
shape.fills = [{ fillColor: "#000000", fillOpacity: 1 }]; // Removes fill token
|
||||
|
||||
--
|
||||
You have hereby read the 'Penpot High-Level Overview' and need not use a tool to read it again.
|
||||
@@ -1,267 +0,0 @@
|
||||
# Prompts configuration for Penpot MCP Server
|
||||
# This file contains various prompts and instructions that can be used by the server
|
||||
|
||||
initial_instructions: |
|
||||
You have access to Penpot tools in order to interact with a Penpot design project directly.
|
||||
As a precondition, the user must connect the Penpot design project to the MCP server using the Penpot MCP Plugin.
|
||||
|
||||
IMPORTANT: When transferring styles from a Penpot design to code, make sure that you strictly adhere to the design.
|
||||
NEVER make assumptions about missing values and don't get overly creative (e.g. don't pick your own colours and stick to
|
||||
non-creative defaults such as white/black if you are lacking information).
|
||||
|
||||
# Executing Code
|
||||
|
||||
One of your key tools is the `execute_code` tool, which allows you to run JavaScript code using the Penpot Plugin API
|
||||
directly in the connected project.
|
||||
|
||||
VERY IMPORTANT: When writing code, NEVER LOG INFORMATION YOU ARE ALSO RETURNING. It would duplicate the information you receive!
|
||||
|
||||
To execute code correctly, you need to understand the Penpot Plugin API. You can retrieve API documentation via
|
||||
the `penpot_api_info` tool.
|
||||
|
||||
This is the full list of types/interfaces in the Penpot API: $api_types
|
||||
|
||||
You use the `storage` object extensively to store data and utility functions you define across tool calls.
|
||||
This allows you to inspect intermediate results while still being able to build on them in subsequent code executions.
|
||||
|
||||
# The Structure of Penpot Designs
|
||||
|
||||
A Penpot design ultimately consists of shapes.
|
||||
The type `Shape` is a union type, which encompasses both containers and low-level shapes.
|
||||
Shapes in a Penpot design are organized hierarchically.
|
||||
At the top level, a design project contains one or more `Page` objects.
|
||||
Each `Page` contains a tree of elements. For a given instance `page`, its root shape is `page.root`.
|
||||
A Page is frequently structured into boards. A `Board` is a high-level grouping element.
|
||||
A `Group` is a more low-level grouping element used to organize low-level shapes into a logical unit.
|
||||
Actual low-level shape types are `Rectangle`, `Path`, `Text`, `Ellipse`, `Image`, `Boolean`, and `SvgRaw`.
|
||||
`ShapeBase` is a base type most shapes build upon.
|
||||
|
||||
# Core Shape Properties and Methods
|
||||
|
||||
**Type**:
|
||||
Any given shape contains information on the concrete type via its `type` field.
|
||||
|
||||
**Position and Dimensions**:
|
||||
* The location properties `x` and `y` refer to the top left corner of a shape's bounding box in the absolute (Page) coordinate system.
|
||||
These are writable - set them directly to position shapes.
|
||||
* `parentX` and `parentY` (as well as `boardX` and `boardY`) are READ-ONLY computed properties showing position relative to parent/board.
|
||||
To position relative to parent, use `penpotUtils.setParentXY(shape, parentX, parentY)` or manually set `shape.x = parent.x + parentX`.
|
||||
* `width` and `height` are READ-ONLY. Use `resize(width, height)` method to change dimensions.
|
||||
* `bounds` is a READ-ONLY property. Use `x`, `y` with `resize()` to modify shape bounds.
|
||||
|
||||
**Other Writable Properties**:
|
||||
* `name` - Shape name
|
||||
* `fills`, `strokes` - Styling properties
|
||||
* `rotation`, `opacity`, `blocked`, `hidden`, `visible`
|
||||
|
||||
**Z-Order**:
|
||||
* The z-order of shapes is determined by the order in the `children` array of the parent shape.
|
||||
Therefore, when creating shapes that should be on top of each other, add them to the parent in the correct order
|
||||
(i.e. add background shapes first, then foreground shapes later).
|
||||
CRITICAL: NEVER use the broken function `appendChild` to achieve this, ALWAYS use `parent.insertChild(parent.children.length, shape)`
|
||||
* To modify z-order after creation, use these methods: `bringToFront()`, `sendToBack()`, `bringForward()`, `sendBackward()`,
|
||||
and, for precise control, `setParentIndex(index)` (0-based).
|
||||
|
||||
**Modification Methods**:
|
||||
* `resize(width, height)` - Change dimensions (required for width/height since they're read-only)
|
||||
* `rotate(angle, center?)` - Rotate shape
|
||||
* `remove()` - Permanently destroy the shape (use only for deletion, NOT for reparenting)
|
||||
|
||||
**Hierarchical Structure**:
|
||||
* `parent` - The parent shape (null for root shapes)
|
||||
Note: Hierarchical nesting does not necessarily imply visual containment
|
||||
* CRITICAL: To add children to a parent shape (e.g. a `Board`):
|
||||
- ALWAYS use `parent.insertChild(index, shape)` to add a child, e.g. `parent.insertChild(parent.children.length, shape)` to append
|
||||
- NEVER use `parent.appendChild(shape)` as it is BROKEN and will not insert in a predictable place (except in flex layout boards)
|
||||
* Reparenting: `newParent.appendChild(shape)` or `newParent.insertChild(index, shape)` will move a shape to new parent
|
||||
- Automatically removes the shape from its old parent
|
||||
- Absolute x/y positions are preserved (use `penpotUtils.setParentXY` to adjust relative position)
|
||||
|
||||
# Images
|
||||
|
||||
The `Image` type is a legacy type. Images are now typically embedded in a `Fill`, with `fillImage` set to an
|
||||
`ImageData` object, i.e. the `fills` property of of a shape (e.g. a `Rectangle`) will contain a fill where `fillImage` is set.
|
||||
Use the `export_shape` and `import_image` tools to export and import images.
|
||||
|
||||
# Layout Systems
|
||||
|
||||
Boards can have layout systems that automatically control the positioning and spacing of their children:
|
||||
|
||||
* If a board has a layout system, then child positions are controlled by the layout system.
|
||||
For every child, key properties of the child within the layout are stored in `child.layoutChild: LayoutChildProperties`:
|
||||
- `absolute: boolean` - if true, child position is not controlled by layout system. x/y will set *relative* position within parent!
|
||||
- margins (`topMargin`, `rightMargin`, `bottomMargin`, `leftMargin` or combined `verticalMargin`, `horizontalMargin`)
|
||||
- sizing (`verticalSizing`, `horizontalSizing`: "fill" | "auto" | "fix")
|
||||
- min/max sizes (`minWidth`, `maxWidth`, `minHeight`, `maxHeight`)
|
||||
- `zIndex: number` (higher numbers on top)
|
||||
|
||||
* **Flex Layout**: A flexbox-style layout system
|
||||
- Properties: `dir`, `rowGap`, `columnGap`, `alignItems`, `justifyContent`;
|
||||
- `dir`: "row" | "column" | "row-reverse" | "column-reverse"
|
||||
- Padding: `topPadding`, `rightPadding`, `bottomPadding`, `leftPadding`, or combined `verticalPadding`, `horizontalPadding`
|
||||
- To modify spacing: adjust `rowGap` and `columnGap` properties, not individual child positions.
|
||||
Optionally, adjust indivudual child margins via `child.layoutChild`.
|
||||
- When a board has flex layout,
|
||||
- child positions are controlled by the layout system, not by individual x/y coordinates (unless `child.layoutChild.absolute` is true);
|
||||
appending or inserting children automatically positions them according to the layout rules.
|
||||
- CRITICAL: For for dir="column" or dir="row", the order of the `children` array is reversed relative to the visual order!
|
||||
Therefore, the element that appears first in the array, appears visually at the end (bottom/right) and vice versa.
|
||||
ALWAYS BEAR IN MIND THAT THE CHILDREN ARRAY ORDER IS REVERSED FOR dir="column" OR dir="row"!
|
||||
- CRITICAL: The FlexLayout method `board.flex.appendChild` is BROKEN. To append children to a flex layout board such that
|
||||
they appear visually at the end, ALWAYS use the Board's method `board.appendChild(shape)`; it will insert at the front
|
||||
of the `children` array for dir="column" or dir="row", which is what you want. So call it in the order of visual appearance.
|
||||
To insert at a specific index, use `board.insertChild(index, shape)`, bearing in mind the reversed order for dir="column"
|
||||
or dir="row".
|
||||
- Add to a board with `board.addFlexLayout(): FlexLayout`; instance then accessible via `board.flex`.
|
||||
IMPORTANT: When adding a flex layout to a container that already has children,
|
||||
use `penpotUtils.addFlexLayout(container, dir)` instead! This preserves the existing visual order of children.
|
||||
Otherwise, children will be arbitrarily reordered when the children order suddenly determines the display order.
|
||||
- Check with: `if (board.flex) { ... }`
|
||||
|
||||
* **Grid Layout**: A CSS grid-style layout system
|
||||
- Add to a board with `board.addGridLayout(): GridLayout`; instance then accessibly via `board.grid`;
|
||||
Check with: `if (board.grid) { ... }`
|
||||
- Properties: `rows`, `columns`, `rowGap`, `columnGap`
|
||||
- Children are positioned via 1-based row/column indices
|
||||
- Add to grid via `board.flex.appendChild(shape, row, column)`
|
||||
- Modify grid positioning after the fact via `shape.layoutCell: LayoutCellProperties`
|
||||
|
||||
* When working with boards:
|
||||
- ALWAYS check if the board has a layout system before attempting to reposition children
|
||||
- Modify layout properties (gaps, padding) instead of trying to set child x/y positions directly
|
||||
- Layout systems override manual positioning of children
|
||||
|
||||
# Text Elements
|
||||
|
||||
The rendered content of `Text` element is given by the `characters` property.
|
||||
|
||||
To change the size of the text, change the `fontSize` property; applying `resize()` does NOT change the font size,
|
||||
it only changes the formal bounding box; if the text does not fit it, it will overflow.
|
||||
The bounding box is sized automatically as long as the `growType` property is set to "auto-width" or "auto-height".
|
||||
`resize` always sets `growType` to "fixed", so ALWAYS set it back to "auto-*" if you want automatic sizing - otherwise the bounding box will be meaningless, with the text overflowing!
|
||||
The auto-sizing is not immediate; sleep for a short time (100ms) if you want to read the updated bounding box.
|
||||
|
||||
# The `penpot` and `penpotUtils` Objects, Exploring Designs
|
||||
|
||||
A key object to use in your code is the `penpot` object (which is of type `Penpot`):
|
||||
* `penpot.selection` provides the list of shapes the user has selected in the Penpot UI.
|
||||
If it is unclear which elements to work on, you can ask the user to select them for you.
|
||||
ALWAYS immediately copy the selected shape(s) into `storage`! Do not assume that the selection remains unchanged.
|
||||
* `penpot.root` provides the root shape of the currently active page.
|
||||
* Generation of CSS content for elements via `penpot.generateStyle`
|
||||
* Generation of HTML/SVG content for elements via `penpot.generateMarkup`
|
||||
|
||||
For example, to generate CSS for the currently selected elements, you can execute this:
|
||||
return penpot.generateStyle(penpot.selection, { type: "css", withChildren: true });
|
||||
|
||||
CRITICAL: The `penpotUtils` object provides essential utilities - USE THESE INSTEAD OF WRITING YOUR OWN:
|
||||
* getPages(): { id: string; name: string }[]
|
||||
* getPageById(id: string): Page | null
|
||||
* getPageByName(name: string): Page | null
|
||||
* shapeStructure(shape: Shape, maxDepth: number | undefined = undefined): { id, name, type, children?, layout? }
|
||||
Generates an overview structure of the given shape.
|
||||
- children: recursive, limited by maxDepth
|
||||
- layout: present if shape has flex/grid layout, contains { type: "flex" | "grid", ... }
|
||||
* findShapeById(id: string): Shape | null
|
||||
* findShape(predicate: (shape: Shape) => boolean, root: Shape | null = null): Shape | null
|
||||
If no root is provided, search globally (in all pages).
|
||||
* findShapes(predicate: (shape: Shape) => boolean, root: Shape | null = null): Shape[]
|
||||
* isContainedIn(shape: Shape, container: Shape): boolean
|
||||
Returns true iff shape is fully within the container's geometric bounds.
|
||||
Note that a shape's bounds may not always reflect its actual visual content - descendants can overflow; check using analyzeDescendants (see below).
|
||||
* setParentXY(shape: Shape, parentX: number, parentY: number): void
|
||||
Sets shape position relative to its parent (since parentX/parentY are read-only)
|
||||
* analyzeDescendants<T>(root: Shape, evaluator: (root: Shape, descendant: Shape) => T | null | undefined, maxDepth?: number): Array<{ shape: Shape, result: T }>
|
||||
General-purpose utility for analyzing/validating descendants
|
||||
Calls evaluator on each descendant; collects non-null/undefined results
|
||||
Powerful pattern: evaluator can return corrector functions or diagnostic data
|
||||
|
||||
General pointers for working with Penpot designs:
|
||||
* Prefer `penpotUtils` helper functions — avoid reimplementing shape searching.
|
||||
* To get an overview of a single page, use `penpotUtils.shapeStructure(page.root, 3)`.
|
||||
Note that `penpot.root` refers to the current page only. When working across pages, first determine the relevant page(s).
|
||||
* Use `penpotUtils.findShapes()` or `penpotUtils.findShape()` with predicates to locate elements efficiently.
|
||||
|
||||
Common tasks - Quick Reference (ALWAYS use penpotUtils for these):
|
||||
* Find all images:
|
||||
const images = penpotUtils.findShapes(
|
||||
shape => shape.type === 'image' || shape.fills?.some(fill => fill.fillImage),
|
||||
penpot.root
|
||||
);
|
||||
* Find text elements:
|
||||
const texts = penpotUtils.findShapes(shape => shape.type === 'text', penpot.root);
|
||||
* Find (the first) shape with a given name:
|
||||
const shape = penpotUtils.findShape(shape => shape.name === 'MyShape');
|
||||
* Get structure of current selection:
|
||||
const structure = penpotUtils.shapeStructure(penpot.selection[0]);
|
||||
* Find shapes in current selection/board:
|
||||
const shapes = penpotUtils.findShapes(predicate, penpot.selection[0] || penpot.root);
|
||||
* Validate/analyze descendants (returning corrector functions):
|
||||
const fixes = penpotUtils.analyzeDescendants(board, (root, shape) => {
|
||||
const xMod = shape.parentX % 4;
|
||||
if (xMod !== 0) {
|
||||
return () => penpotUtils.setParentXY(shape, Math.round(shape.parentX / 4) * 4, shape.parentY);
|
||||
}
|
||||
});
|
||||
fixes.forEach(f => f.result()); // Apply all fixes
|
||||
* Find containment violations:
|
||||
const violations = penpotUtils.analyzeDescendants(board, (root, shape) => {
|
||||
return !penpotUtils.isContainedIn(shape, root) ? 'outside-bounds' : null;
|
||||
});
|
||||
Always validate against the root container that is supposed to contain the shapes.
|
||||
|
||||
# Visual Inspection of Designs
|
||||
|
||||
For many tasks, it can be critical to visually inspect the design. Remember to use the `export_shape` tool for this purpose!
|
||||
|
||||
# Revising Designs
|
||||
|
||||
* Before applying design changes, ask: "Would a designer consider this appropriate?"
|
||||
* When dealing with containment issues, ask: Is the parent too small OR is the child too large?
|
||||
Container sizes are usually intentional, check content first.
|
||||
* Check for reasonable font sizes and typefaces
|
||||
* The use of flex layouts is encouraged for cases where elements are arranged in rows or columns with consistent spacing/positioning.
|
||||
Consider converting boards to flex layout when appropriate.
|
||||
|
||||
# Asset Libraries
|
||||
|
||||
Libraries in Penpot are collections of reusable design assets (components, colors, and typographies) that can be shared across files.
|
||||
They enable design systems and consistent styling across projects.
|
||||
Each Penpot file has its own local library and can connect to external shared libraries.
|
||||
|
||||
Accessing libraries: via `penpot.library` (type: `LibraryContext`):
|
||||
* `penpot.library.local` (type: `Library`) - The current file's own library
|
||||
* `penpot.library.connected` (type: `Library[]`) - Array of already-connected external libraries
|
||||
* `penpot.library.availableLibraries()` (returns: `Promise<LibrarySummary[]>`) - Libraries available to connect
|
||||
* `penpot.library.connectLibrary(libraryId: string)` (returns: `Promise<Library>`) - Connect a new library
|
||||
|
||||
Each `Library` object has:
|
||||
* `id: string`
|
||||
* `name: string`
|
||||
* `components: LibraryComponent[]` - Array of components
|
||||
* `colors: LibraryColor[]` - Array of colors
|
||||
* `typographies: LibraryTypography[]` - Array of typographies
|
||||
|
||||
Using library components:
|
||||
* find a component in the library by name:
|
||||
const component: LibraryComponent = library.components.find(comp => comp.name.includes('Button'));
|
||||
* create a new instance of the component on the current page:
|
||||
const instance: Shape = component.instance();
|
||||
This returns a `Shape` (often a `Board` containing child elements).
|
||||
After instantiation, modify the instance's properties as desired.
|
||||
* get the reference to the main component shape:
|
||||
const mainShape: Shape = component.mainInstance();
|
||||
|
||||
Adding assets to a library:
|
||||
* const newColor: LibraryColor = penpot.library.local.createColor();
|
||||
newColor.name = 'Brand Primary';
|
||||
newColor.color = '#0066FF';
|
||||
* const newTypo: LibraryTypography = penpot.library.local.createTypography();
|
||||
newTypo.name = 'Heading Large';
|
||||
// Set typography properties...
|
||||
* const shapes: Shape[] = [shape1, shape2]; // shapes to include
|
||||
const newComponent: LibraryComponent = penpot.library.local.createComponent(shapes);
|
||||
newComponent.name = 'My Button';
|
||||
|
||||
--
|
||||
You have hereby read the 'Penpot High-Level Overview' and need not use a tool to read it again.
|
||||
@@ -1,18 +1,7 @@
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import yaml from "js-yaml";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { createLogger } from "./logger.js";
|
||||
|
||||
/**
|
||||
* Interface defining the structure of the prompts configuration file.
|
||||
*/
|
||||
export interface PromptsConfig {
|
||||
/** Initial instructions displayed when the server starts or connects to a client */
|
||||
initial_instructions: string;
|
||||
[key: string]: any; // Allow for future extension with additional prompt types
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration loader for prompts and server settings.
|
||||
*
|
||||
@@ -23,7 +12,7 @@ export interface PromptsConfig {
|
||||
export class ConfigurationLoader {
|
||||
private readonly logger = createLogger("ConfigurationLoader");
|
||||
private readonly baseDir: string;
|
||||
private promptsConfig: PromptsConfig | null = null;
|
||||
private initialInstructions: string;
|
||||
|
||||
/**
|
||||
* Creates a new configuration loader instance.
|
||||
@@ -32,34 +21,14 @@ export class ConfigurationLoader {
|
||||
*/
|
||||
constructor(baseDir: string) {
|
||||
this.baseDir = baseDir;
|
||||
this.initialInstructions = this.loadFileContent(join(this.baseDir, "data", "initial_instructions.md"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the prompts configuration from the YAML file.
|
||||
*
|
||||
* Reads and parses the prompts.yml file, providing cached access
|
||||
* to configuration values on subsequent calls.
|
||||
*
|
||||
* @returns The parsed prompts configuration object
|
||||
*/
|
||||
public getPromptsConfig(): PromptsConfig {
|
||||
if (this.promptsConfig !== null) {
|
||||
return this.promptsConfig;
|
||||
private loadFileContent(filePath: string): string {
|
||||
if (!existsSync(filePath)) {
|
||||
throw new Error(`Configuration file not found at ${filePath}`);
|
||||
}
|
||||
|
||||
const promptsPath = join(this.baseDir, "data", "prompts.yml");
|
||||
|
||||
if (!existsSync(promptsPath)) {
|
||||
throw new Error(`Prompts configuration file not found at ${promptsPath}, using defaults`);
|
||||
}
|
||||
|
||||
const fileContent = readFileSync(promptsPath, "utf8");
|
||||
const parsedConfig = yaml.load(fileContent) as PromptsConfig;
|
||||
|
||||
this.promptsConfig = parsedConfig || {};
|
||||
this.logger.info(`Loaded prompts configuration from ${promptsPath}`);
|
||||
|
||||
return this.promptsConfig;
|
||||
return readFileSync(filePath, "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,18 +37,6 @@ export class ConfigurationLoader {
|
||||
* @returns The initial instructions string, or undefined if not configured
|
||||
*/
|
||||
public getInitialInstructions(): string {
|
||||
const config = this.getPromptsConfig();
|
||||
return config.initial_instructions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads the configuration from disk.
|
||||
*
|
||||
* Forces a fresh read of the configuration file on the next access,
|
||||
* useful for development or when configuration files are updated at runtime.
|
||||
*/
|
||||
public reloadConfiguration(): void {
|
||||
this.promptsConfig = null;
|
||||
this.logger.info("Configuration cache cleared, will reload on next access");
|
||||
return this.initialInstructions;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@ fi
|
||||
if [[ "$URL" = "http://localhost:9090" ]]; then
|
||||
pnpx concurrently --kill-others-on-fail -s last -k \
|
||||
"caddy file-server --root ../../plugins/dist/doc/ --listen :9090" \
|
||||
"../types-generator/build $URL";
|
||||
"bash ../types-generator/build $URL";
|
||||
else
|
||||
../types-generator/build $URL;
|
||||
bash ../types-generator/build $URL;
|
||||
fi
|
||||
|
||||
popd
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Types Generator
|
||||
|
||||
This subproject contains helper scripts used in the development of the
|
||||
Penpot MCP server for generate the types yaml.
|
||||
Penpot MCP server, specifically for the generation of a YAML file containing
|
||||
Penpot plugin API types and their documentation.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -12,15 +13,41 @@ Install the environment via (optional, already handled by `build` script)
|
||||
|
||||
pixi install
|
||||
|
||||
## Running the API Documentation Preparation Script
|
||||
|
||||
### Buld API types
|
||||
|
||||
The script `prepare_api_docs.py` reads API documentation from the Web
|
||||
and collects it in a single yaml file, which is then used by an MCP
|
||||
The script `prepare_api_docs.py` reads API documentation from a Web URL
|
||||
and collects it in a single YAML file, which is then used by an MCP
|
||||
tool to provide API documentation to an LLM on demand.
|
||||
|
||||
Successful execution will generate the output file `../packages/server/data/api_types.yml`.
|
||||
|
||||
### Generating the YAML File for a Given URL
|
||||
|
||||
Running the script:
|
||||
|
||||
./build <optional-url>
|
||||
pixi run python prepare_api_docs.py <url>
|
||||
|
||||
You can alternatively run `./build <url>`, which additionally performs pixi environment installation.
|
||||
|
||||
For example, to generate the API documentation based on the current PROD Penpot API documentation,
|
||||
use the URL
|
||||
|
||||
https://doc.plugins.penpot.app
|
||||
|
||||
### Generating the YAML File Based on the Current Documentation in the Repository
|
||||
|
||||
Requirement: [Caddy](https://caddyserver.com/download) must be installed and available in the system path.
|
||||
|
||||
To generate the API documentation based on the current documentation in the repository,
|
||||
run the `build:types` script in the parent directory, i.e.
|
||||
|
||||
cd ..
|
||||
pnpm run build:types
|
||||
|
||||
This will spawn a local HTTP server on port 9090 and run the `prepare_api_docs.py` script with the
|
||||
URL `http://localhost:9090`.
|
||||
To run only the server without executing the script, run
|
||||
|
||||
cd ..
|
||||
caddy file-server --root ../plugins/dist/doc/ --listen 127.0.0.1:9090
|
||||
|
||||
This will generate `../packages/server/data/api_types.yml`.
|
||||
|
||||
@@ -80,6 +80,25 @@ class PenpotAPIContentMarkdownConverter(MarkdownConverter):
|
||||
# return as code block
|
||||
return f"\n```\n{soup.get_text()}\n```\n\n"
|
||||
|
||||
# check for <ul> tag with a single <li>: move the <li> content a <div> and process it as normal,
|
||||
# to avoid single list items with superfluous bullet points and indentations.
|
||||
# This happens frequently, especially in new versions of the docs generator, e.g. for methods:
|
||||
# <ul class="tsd-signatures tsd-is-inherited">
|
||||
# <li class="tsd-is-inherited">
|
||||
# <div class="tsd-signature tsd-anchor-link" id="remove-1">...</div>
|
||||
# </li>
|
||||
# </ul>
|
||||
if node.name == "ul" and "class" in node.attrs and "tsd-signatures" in node.attrs["class"]:
|
||||
soup_ul = soup.find("ul")
|
||||
if soup_ul is not None:
|
||||
li_children = soup_ul.find_all("li", recursive=False)
|
||||
if len(li_children) == 1:
|
||||
# create a new div with the content of the single li
|
||||
new_div = soup.new_tag("div")
|
||||
for child in list(li_children[0].contents):
|
||||
new_div.append(child)
|
||||
return self.process_tag(new_div, parent_tags=parent_tags)
|
||||
|
||||
# other cases: use the default processing
|
||||
return super().process_tag(node, parent_tags=parent_tags)
|
||||
|
||||
@@ -135,7 +154,7 @@ class YamlConverter:
|
||||
|
||||
|
||||
class PenpotAPIDocsProcessor:
|
||||
def __init__(self, url=None):
|
||||
def __init__(self, url: str):
|
||||
self.md_converter = PenpotAPIContentMarkdownConverter()
|
||||
self.base_url = url
|
||||
self.types: dict[str, TypeInfo] = {}
|
||||
@@ -157,7 +176,7 @@ class PenpotAPIDocsProcessor:
|
||||
type_name = href.split("/")[-1].replace(".html", "")
|
||||
log.info("Processing page: %s", type_name)
|
||||
type_info = self.process_page(href, type_name)
|
||||
print(f"Adding '{type_name}' with {type_info}")
|
||||
log.info(f"Adding '{type_name}' with {type_info}")
|
||||
self.types[type_name] = type_info
|
||||
|
||||
# add type reference information
|
||||
@@ -201,11 +220,21 @@ class PenpotAPIDocsProcessor:
|
||||
members_in_group = {}
|
||||
members[members_type] = members_in_group
|
||||
for member_tag in el.find_all(attrs={"class": "tsd-member"}):
|
||||
# determine member name
|
||||
member_name = None
|
||||
member_anchor = member_tag.find("a", attrs={"class": "tsd-anchor"}, recursive=False)
|
||||
member_name = member_anchor.attrs["id"]
|
||||
member_heading = member_tag.find("h3")
|
||||
if member_anchor is not None:
|
||||
member_name = member_anchor.attrs["id"]
|
||||
else:
|
||||
member_h3 = member_tag.find("h3", recursive=False)
|
||||
if member_h3 is not None:
|
||||
h3_span = member_h3.find("span", recursive=False)
|
||||
if h3_span is not None:
|
||||
member_name = h3_span.get_text().strip()
|
||||
assert member_name is not None, f"Could not determine member name for\n{member_tag}"
|
||||
# extract tsd-tag info (e.g., "Readonly") from the heading and reinsert it into the signature,
|
||||
# where we want to see it. The heading is removed, as it is redundant.
|
||||
member_heading = member_tag.find("h3")
|
||||
if member_heading:
|
||||
tags_in_heading = member_heading.find_all(attrs={"class": "tsd-tag"})
|
||||
if tags_in_heading:
|
||||
@@ -237,25 +266,29 @@ class PenpotAPIDocsProcessor:
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_API_DOCS_URL = "http://localhost:9090"
|
||||
LOCAL_API_DOCS_URL = "http://localhost:9090"
|
||||
PROD_API_DOCS_URL = "https://doc.plugins.penpot.app"
|
||||
DEFAULT_API_DOCS_URL = LOCAL_API_DOCS_URL
|
||||
|
||||
|
||||
def main():
|
||||
target_dir = Path(__file__).parent.parent / "packages" / "server" / "data"
|
||||
url = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_API_DOCS_URL
|
||||
|
||||
print("Fetching plugin data from: {}".format(url))
|
||||
log.info("Fetching plugin data from: {}".format(url))
|
||||
PenpotAPIDocsProcessor(url).run(target_dir=str(target_dir))
|
||||
|
||||
|
||||
def debug_type_conversion(rel_url: str):
|
||||
def debug_type_conversion(rel_url: str, base_url: str):
|
||||
"""
|
||||
This function is for debugging purposes only.
|
||||
It processes a single type page and prints the converted markdown to the console.
|
||||
|
||||
:param base_url: base URL of the API docs (e.g., "http://localhost:9090")
|
||||
:param rel_url: relative URL of the type page (e.g., "interfaces/ShapeBase")
|
||||
"""
|
||||
type_name = rel_url.split("/")[-1]
|
||||
processor = PenpotAPIDocsProcessor()
|
||||
type_name = rel_url.split("/")[-1].replace(".html", "")
|
||||
processor = PenpotAPIDocsProcessor(url=base_url)
|
||||
type_info = processor.process_page(rel_url, type_name)
|
||||
print(f"--- overview ---\n{type_info.overview}\n")
|
||||
for member_type, members in type_info.members.items():
|
||||
@@ -265,5 +298,5 @@ def debug_type_conversion(rel_url: str):
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# debug_type_conversion("interfaces/LayoutChildProperties")
|
||||
# debug_type_conversion("interfaces/Path.html", LOCAL_API_DOCS_URL)
|
||||
logging.run_main(main)
|
||||
|
||||
217
render-wasm/docs/text_editor.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# Text Editor Architecture
|
||||
|
||||
## Overview (Simplified)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Browser["Browser / DOM"]
|
||||
CE[contenteditable]
|
||||
Events[DOM Events]
|
||||
end
|
||||
|
||||
subgraph CLJS["ClojureScript"]
|
||||
InputHandler[text_editor_input.cljs]
|
||||
Bindings[text_editor.cljs]
|
||||
ContentCache[(content cache)]
|
||||
end
|
||||
|
||||
subgraph WASM["WASM Boundary"]
|
||||
FFI["_text_editor_* functions"]
|
||||
end
|
||||
|
||||
subgraph Rust["Rust"]
|
||||
subgraph StateModule["state/text_editor.rs"]
|
||||
TES[TextEditorState]
|
||||
Selection[TextSelection]
|
||||
Cursor[TextCursor]
|
||||
end
|
||||
|
||||
subgraph WASMImpl["wasm/text_editor.rs"]
|
||||
StateOps[start / stop]
|
||||
CursorOps[cursor / selection]
|
||||
EditOps[insert / delete]
|
||||
ExportOps[export content]
|
||||
end
|
||||
|
||||
subgraph RenderMod["render/text_editor.rs"]
|
||||
RenderOverlay[render_overlay]
|
||||
end
|
||||
|
||||
Shapes[(ShapesPool)]
|
||||
end
|
||||
|
||||
subgraph Skia["Skia"]
|
||||
Canvas[Canvas]
|
||||
Paragraph[Paragraph layout]
|
||||
end
|
||||
|
||||
%% Flow
|
||||
CE --> Events
|
||||
Events --> InputHandler
|
||||
InputHandler --> Bindings
|
||||
Bindings --> FFI
|
||||
FFI --> StateOps & CursorOps & EditOps & ExportOps
|
||||
|
||||
StateOps --> TES
|
||||
CursorOps --> TES
|
||||
EditOps --> TES
|
||||
EditOps --> Shapes
|
||||
ExportOps --> Shapes
|
||||
TES --> Selection --> Cursor
|
||||
|
||||
RenderOverlay --> TES
|
||||
RenderOverlay --> Shapes
|
||||
Shapes --> Paragraph
|
||||
RenderOverlay --> Canvas
|
||||
Paragraph --> Canvas
|
||||
|
||||
ExportOps --> ContentCache
|
||||
ContentCache --> InputHandler
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Detailed Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Browser["Browser / DOM"]
|
||||
CE[contenteditable element]
|
||||
KeyEvents[keydown / keyup]
|
||||
MouseEvents[mousedown / mousemove]
|
||||
IME[compositionstart / end]
|
||||
end
|
||||
|
||||
subgraph CLJS["ClojureScript Layer"]
|
||||
subgraph InputMod["text_editor_input.cljs"]
|
||||
EventHandler[Event Handler]
|
||||
BlinkLoop[RAF Blink Loop]
|
||||
SyncFn[sync-content!]
|
||||
end
|
||||
|
||||
subgraph BindingsMod["text_editor.cljs"]
|
||||
direction TB
|
||||
StartStop[start / stop]
|
||||
CursorFns[set-cursor / move]
|
||||
SelectFns[select-all / extend]
|
||||
EditFns[insert / delete]
|
||||
ExportFns[export-content]
|
||||
StyleFns[apply-style]
|
||||
end
|
||||
|
||||
ContentCache[(shape-text-contents<br/>atom)]
|
||||
end
|
||||
|
||||
subgraph WASM["WASM Boundary"]
|
||||
direction TB
|
||||
FFI_State["_text_editor_start<br/>_text_editor_stop<br/>_text_editor_is_active"]
|
||||
FFI_Cursor["_text_editor_set_cursor_from_point<br/>_text_editor_move_cursor<br/>_text_editor_select_all"]
|
||||
FFI_Edit["_text_editor_insert_text<br/>_text_editor_delete_backward<br/>_text_editor_insert_paragraph"]
|
||||
FFI_Query["_text_editor_export_content<br/>_text_editor_get_selection<br/>_text_editor_poll_event"]
|
||||
FFI_Render["_text_editor_render_overlay<br/>_text_editor_update_blink"]
|
||||
end
|
||||
|
||||
subgraph Rust["Rust Layer"]
|
||||
subgraph StateMod["state/text_editor.rs"]
|
||||
TES[TextEditorState]
|
||||
Selection[TextSelection]
|
||||
Cursor[TextCursor]
|
||||
Events[EditorEvent queue]
|
||||
end
|
||||
|
||||
subgraph WASMMod["wasm/text_editor.rs"]
|
||||
direction TB
|
||||
WStateOps[State ops]
|
||||
WCursorOps[Cursor ops]
|
||||
WEditOps[Edit ops]
|
||||
WQueryOps[Query ops]
|
||||
end
|
||||
|
||||
subgraph RenderMod["render/text_editor.rs"]
|
||||
RenderOverlay[render_overlay]
|
||||
RenderCursor[render_cursor]
|
||||
RenderSelection[render_selection]
|
||||
end
|
||||
|
||||
Shapes[(ShapesPool<br/>TextContent)]
|
||||
end
|
||||
|
||||
subgraph Skia["Skia"]
|
||||
Canvas[Canvas]
|
||||
SkParagraph[textlayout::Paragraph]
|
||||
TextBoxes[get_rects_for_range]
|
||||
end
|
||||
|
||||
%% Browser to CLJS
|
||||
CE --> KeyEvents & MouseEvents & IME
|
||||
KeyEvents --> EventHandler
|
||||
MouseEvents --> EventHandler
|
||||
IME --> EventHandler
|
||||
|
||||
%% CLJS internal
|
||||
EventHandler --> StartStop & CursorFns & EditFns & SelectFns
|
||||
BlinkLoop --> FFI_Render
|
||||
SyncFn --> ExportFns
|
||||
ExportFns --> ContentCache
|
||||
ContentCache --> SyncFn
|
||||
StyleFns --> ContentCache
|
||||
|
||||
%% CLJS to WASM
|
||||
StartStop --> FFI_State
|
||||
CursorFns --> FFI_Cursor
|
||||
SelectFns --> FFI_Cursor
|
||||
EditFns --> FFI_Edit
|
||||
ExportFns --> FFI_Query
|
||||
|
||||
%% WASM to Rust impl
|
||||
FFI_State --> WStateOps
|
||||
FFI_Cursor --> WCursorOps
|
||||
FFI_Edit --> WEditOps
|
||||
FFI_Query --> WQueryOps
|
||||
FFI_Render --> RenderOverlay
|
||||
|
||||
%% Rust internal
|
||||
WStateOps --> TES
|
||||
WCursorOps --> TES
|
||||
WEditOps --> TES
|
||||
WEditOps --> Shapes
|
||||
WQueryOps --> TES
|
||||
WQueryOps --> Shapes
|
||||
|
||||
TES --> Selection
|
||||
Selection --> Cursor
|
||||
TES --> Events
|
||||
|
||||
%% Render flow
|
||||
RenderOverlay --> RenderCursor & RenderSelection
|
||||
RenderCursor --> TES
|
||||
RenderSelection --> TES
|
||||
RenderCursor --> Shapes
|
||||
RenderSelection --> Shapes
|
||||
|
||||
%% Skia
|
||||
Shapes --> SkParagraph
|
||||
SkParagraph --> TextBoxes
|
||||
RenderCursor --> Canvas
|
||||
RenderSelection --> Canvas
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Files
|
||||
|
||||
| Layer | File | Purpose |
|
||||
|-------|------|---------|
|
||||
| DOM | - | contenteditable captures keyboard/IME input |
|
||||
| CLJS | `text_editor_input.cljs` | Event handling, blink loop, content sync |
|
||||
| CLJS | `text_editor.cljs` | WASM bindings, content cache, style application |
|
||||
| Rust | `state/text_editor.rs` | TextEditorState, TextSelection, TextCursor |
|
||||
| Rust | `wasm/text_editor.rs` | WASM exported functions |
|
||||
| Rust | `render/text_editor.rs` | Cursor & selection overlay rendering |
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. **Input**: DOM events → ClojureScript handler → WASM function → Rust state
|
||||
2. **Edit**: Rust modifies TextContent in ShapesPool → triggers layout
|
||||
3. **Sync**: Export content → merge with cached styles → update shape
|
||||
4. **Render**: RAF loop → render_overlay → Skia draws cursor/selection
|
||||
@@ -301,11 +301,7 @@ pub extern "C" fn set_view_end() {
|
||||
#[cfg(feature = "profile-macros")]
|
||||
{
|
||||
let total_time = performance::get_time() - unsafe { VIEW_INTERACTION_START };
|
||||
performance::console_log!(
|
||||
"[PERF] view_interaction (zoom_changed={}): {}ms",
|
||||
zoom_changed,
|
||||
total_time
|
||||
);
|
||||
performance::console_log!("[PERF] view_interaction: {}ms", total_time);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ mod shadows;
|
||||
mod strokes;
|
||||
mod surfaces;
|
||||
pub mod text;
|
||||
pub mod text_editor;
|
||||
mod ui;
|
||||
|
||||
use skia_safe::{self as skia, Matrix, RRect, Rect};
|
||||
@@ -22,7 +23,7 @@ pub use surfaces::{SurfaceId, Surfaces};
|
||||
|
||||
use crate::performance;
|
||||
use crate::shapes::{
|
||||
all_with_ancestors, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor, Type,
|
||||
all_with_ancestors, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor, Stroke, Type,
|
||||
};
|
||||
use crate::state::{ShapesPoolMutRef, ShapesPoolRef};
|
||||
use crate::tiles::{self, PendingTiles, TileRect};
|
||||
@@ -33,8 +34,9 @@ use crate::wapi;
|
||||
pub use fonts::*;
|
||||
pub use images::*;
|
||||
|
||||
// This is the extra are used for tile rendering.
|
||||
const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 2;
|
||||
// This is the extra area used for tile rendering (tiles beyond viewport).
|
||||
// Higher values pre-render more tiles, reducing empty squares during pan but using more memory.
|
||||
const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3;
|
||||
const MAX_BLOCKING_TIME_MS: i32 = 32;
|
||||
const NODE_BATCH_THRESHOLD: i32 = 3;
|
||||
|
||||
@@ -697,20 +699,17 @@ impl RenderState {
|
||||
canvas.translate(translation);
|
||||
});
|
||||
|
||||
for fill in shape.fills().rev() {
|
||||
fills::render(self, shape, fill, antialias, SurfaceId::Current);
|
||||
}
|
||||
fills::render(self, shape, &shape.fills, antialias, SurfaceId::Current);
|
||||
|
||||
for stroke in shape.visible_strokes().rev() {
|
||||
strokes::render(
|
||||
self,
|
||||
shape,
|
||||
stroke,
|
||||
Some(SurfaceId::Current),
|
||||
None,
|
||||
antialias,
|
||||
);
|
||||
}
|
||||
// Pass strokes in natural order; stroke merging handles top-most ordering internally.
|
||||
let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect();
|
||||
strokes::render(
|
||||
self,
|
||||
shape,
|
||||
&visible_strokes,
|
||||
Some(SurfaceId::Current),
|
||||
antialias,
|
||||
);
|
||||
|
||||
self.surfaces.apply_mut(SurfaceId::Current as u32, |s| {
|
||||
s.canvas().restore();
|
||||
@@ -1014,33 +1013,35 @@ impl RenderState {
|
||||
{
|
||||
if let Some(fills_to_render) = self.nested_fills.last() {
|
||||
let fills_to_render = fills_to_render.clone();
|
||||
for fill in fills_to_render.iter() {
|
||||
fills::render(self, shape, fill, antialias, fills_surface_id);
|
||||
}
|
||||
fills::render(self, shape, &fills_to_render, antialias, fills_surface_id);
|
||||
}
|
||||
} else {
|
||||
for fill in shape.fills().rev() {
|
||||
fills::render(self, shape, fill, antialias, fills_surface_id);
|
||||
}
|
||||
fills::render(self, shape, &shape.fills, antialias, fills_surface_id);
|
||||
}
|
||||
|
||||
for stroke in shape.visible_strokes().rev() {
|
||||
// Skip stroke rendering for clipped frames - they are drawn in render_shape_exit
|
||||
// over the children. Drawing twice would cause incorrect opacity blending.
|
||||
let skip_strokes = matches!(shape.shape_type, Type::Frame(_)) && shape.clip_content;
|
||||
if !skip_strokes {
|
||||
// Pass strokes in natural order; stroke merging handles top-most ordering internally.
|
||||
let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect();
|
||||
strokes::render(
|
||||
self,
|
||||
shape,
|
||||
stroke,
|
||||
&visible_strokes,
|
||||
Some(strokes_surface_id),
|
||||
None,
|
||||
antialias,
|
||||
);
|
||||
if !fast_mode {
|
||||
shadows::render_stroke_inner_shadows(
|
||||
self,
|
||||
shape,
|
||||
stroke,
|
||||
antialias,
|
||||
innershadows_surface_id,
|
||||
);
|
||||
for stroke in &visible_strokes {
|
||||
shadows::render_stroke_inner_shadows(
|
||||
self,
|
||||
shape,
|
||||
stroke,
|
||||
antialias,
|
||||
innershadows_surface_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1240,8 +1241,6 @@ impl RenderState {
|
||||
if self.render_in_progress {
|
||||
if tree.len() != 0 {
|
||||
self.render_shape_tree_partial(base_object, tree, timestamp, true)?;
|
||||
} else {
|
||||
println!("Empty tree");
|
||||
}
|
||||
self.flush_and_submit();
|
||||
|
||||
@@ -1264,8 +1263,6 @@ impl RenderState {
|
||||
) -> Result<(), String> {
|
||||
if tree.len() != 0 {
|
||||
self.render_shape_tree_partial(base_object, tree, timestamp, false)?;
|
||||
} else {
|
||||
println!("Empty tree");
|
||||
}
|
||||
self.flush_and_submit();
|
||||
|
||||
@@ -1402,6 +1399,10 @@ impl RenderState {
|
||||
element_strokes.to_mut().clear_fills();
|
||||
element_strokes.to_mut().clear_shadows();
|
||||
element_strokes.to_mut().clip_content = false;
|
||||
// Frame blur is applied at the save_layer level - avoid double blur on the stroke paint
|
||||
if Self::frame_clip_layer_blur(element).is_some() {
|
||||
element_strokes.to_mut().set_blur(None);
|
||||
}
|
||||
self.render_shape(
|
||||
&element_strokes,
|
||||
clip_bounds,
|
||||
@@ -1551,6 +1552,11 @@ impl RenderState {
|
||||
plain_shape_mut.clear_shadows();
|
||||
plain_shape_mut.blur = None;
|
||||
|
||||
// Shadow rendering uses a single render_shape call with no render_shape_exit,
|
||||
// so strokes must be drawn here. Disable clip_content to avoid skip_strokes
|
||||
// (which defers strokes to render_shape_exit for clipped frames).
|
||||
plain_shape_mut.clip_content = false;
|
||||
|
||||
let Some(drop_filter) = transformed_shadow.get_drop_shadow_filter() else {
|
||||
return;
|
||||
};
|
||||
@@ -1660,6 +1666,158 @@ impl RenderState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders element drop shadows to DropShadows surface and composites to Current.
|
||||
/// Used for both normal shadow rendering and pre-layer rendering (frame_clip_layer_blur).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_element_drop_shadows_and_composite(
|
||||
&mut self,
|
||||
element: &Shape,
|
||||
tree: ShapesPoolRef,
|
||||
extrect: &mut Option<Rect>,
|
||||
clip_bounds: Option<ClipStack>,
|
||||
scale: f32,
|
||||
translation: (f32, f32),
|
||||
node_render_state: &NodeRenderState,
|
||||
) {
|
||||
let element_extrect = extrect.get_or_insert_with(|| element.extrect(tree, scale));
|
||||
let inherited_layer_blur = match element.shape_type {
|
||||
Type::Frame(_) | Type::Group(_) => element.blur,
|
||||
_ => None,
|
||||
};
|
||||
|
||||
for shadow in element.drop_shadows_visible() {
|
||||
let paint = skia::Paint::default();
|
||||
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
|
||||
self.surfaces
|
||||
.canvas(SurfaceId::DropShadows)
|
||||
.save_layer(&layer_rec);
|
||||
|
||||
self.render_drop_black_shadow(
|
||||
element,
|
||||
element_extrect,
|
||||
shadow,
|
||||
clip_bounds.clone(),
|
||||
scale,
|
||||
translation,
|
||||
None,
|
||||
);
|
||||
|
||||
if !matches!(element.shape_type, Type::Bool(_)) {
|
||||
for shadow_shape_id in element.children.iter() {
|
||||
let Some(shadow_shape) = tree.get(shadow_shape_id) else {
|
||||
continue;
|
||||
};
|
||||
if shadow_shape.hidden {
|
||||
continue;
|
||||
}
|
||||
let nested_clip_bounds =
|
||||
node_render_state.get_nested_shadow_clip_bounds(element, shadow);
|
||||
|
||||
if !matches!(shadow_shape.shape_type, Type::Text(_)) {
|
||||
self.render_drop_black_shadow(
|
||||
shadow_shape,
|
||||
&shadow_shape.extrect(tree, scale),
|
||||
shadow,
|
||||
nested_clip_bounds,
|
||||
scale,
|
||||
translation,
|
||||
inherited_layer_blur,
|
||||
);
|
||||
} else {
|
||||
let paint = skia::Paint::default();
|
||||
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
|
||||
self.surfaces
|
||||
.canvas(SurfaceId::DropShadows)
|
||||
.save_layer(&layer_rec);
|
||||
self.surfaces
|
||||
.canvas(SurfaceId::DropShadows)
|
||||
.scale((scale, scale));
|
||||
self.surfaces
|
||||
.canvas(SurfaceId::DropShadows)
|
||||
.translate(translation);
|
||||
|
||||
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;
|
||||
transformed_shadow.to_mut().spread = transformed_shadow.spread * scale;
|
||||
|
||||
let mut new_shadow_paint = skia::Paint::default();
|
||||
new_shadow_paint
|
||||
.set_image_filter(transformed_shadow.get_drop_shadow_filter());
|
||||
new_shadow_paint.set_blend_mode(skia::BlendMode::SrcOver);
|
||||
|
||||
self.with_nested_blurs_suppressed(|state| {
|
||||
state.render_shape(
|
||||
shadow_shape,
|
||||
nested_clip_bounds,
|
||||
SurfaceId::DropShadows,
|
||||
SurfaceId::DropShadows,
|
||||
SurfaceId::DropShadows,
|
||||
SurfaceId::DropShadows,
|
||||
true,
|
||||
None,
|
||||
Some(vec![new_shadow_paint.clone()]),
|
||||
);
|
||||
});
|
||||
self.surfaces.canvas(SurfaceId::DropShadows).restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut paint = skia::Paint::default();
|
||||
paint.set_color(shadow.color);
|
||||
paint.set_blend_mode(skia::BlendMode::SrcIn);
|
||||
self.surfaces
|
||||
.canvas(SurfaceId::DropShadows)
|
||||
.draw_paint(&paint);
|
||||
|
||||
self.surfaces.canvas(SurfaceId::DropShadows).restore();
|
||||
}
|
||||
|
||||
if let Some(clips) = clip_bounds.as_ref() {
|
||||
let antialias = element.should_use_antialias(scale);
|
||||
self.surfaces.canvas(SurfaceId::Current).save();
|
||||
for (bounds, corners, transform) in clips.iter() {
|
||||
let mut total_matrix = Matrix::new_identity();
|
||||
total_matrix.pre_scale((scale, scale), None);
|
||||
total_matrix.pre_translate((translation.0, translation.1));
|
||||
total_matrix.pre_concat(transform);
|
||||
|
||||
self.surfaces
|
||||
.canvas(SurfaceId::Current)
|
||||
.concat(&total_matrix);
|
||||
|
||||
if let Some(corners) = corners {
|
||||
let rrect = RRect::new_rect_radii(*bounds, corners);
|
||||
self.surfaces.canvas(SurfaceId::Current).clip_rrect(
|
||||
rrect,
|
||||
skia::ClipOp::Intersect,
|
||||
antialias,
|
||||
);
|
||||
} else {
|
||||
self.surfaces.canvas(SurfaceId::Current).clip_rect(
|
||||
*bounds,
|
||||
skia::ClipOp::Intersect,
|
||||
antialias,
|
||||
);
|
||||
}
|
||||
|
||||
self.surfaces
|
||||
.canvas(SurfaceId::Current)
|
||||
.concat(&total_matrix.invert().unwrap_or_default());
|
||||
}
|
||||
self.surfaces
|
||||
.draw_into(SurfaceId::DropShadows, SurfaceId::Current, None);
|
||||
self.surfaces.canvas(SurfaceId::Current).restore();
|
||||
} else {
|
||||
self.surfaces
|
||||
.draw_into(SurfaceId::DropShadows, SurfaceId::Current, None);
|
||||
}
|
||||
self.surfaces
|
||||
.canvas(SurfaceId::DropShadows)
|
||||
.clear(skia::Color::TRANSPARENT);
|
||||
}
|
||||
|
||||
pub fn render_shape_tree_partial_uncached(
|
||||
&mut self,
|
||||
tree: ShapesPoolRef,
|
||||
@@ -1742,6 +1900,33 @@ impl RenderState {
|
||||
// If a container was flattened, it doesn't affect children visually, so we skip
|
||||
// the expensive enter/exit operations and process children directly
|
||||
if !element.can_flatten() {
|
||||
// Enter focus early so shadow_before_layer can run (it needs focus_mode.is_active())
|
||||
self.focus_mode.enter(&element.id);
|
||||
|
||||
// For frames with layer blur, render shadow BEFORE the layer so it doesn't get
|
||||
// the layer blur (which would make it more diffused than without clipping)
|
||||
let shadow_before_layer = !node_render_state.is_root()
|
||||
&& self.focus_mode.is_active()
|
||||
&& !self.options.is_fast_mode()
|
||||
&& !matches!(element.shape_type, Type::Text(_))
|
||||
&& Self::frame_clip_layer_blur(element).is_some()
|
||||
&& element.drop_shadows_visible().next().is_some();
|
||||
|
||||
if shadow_before_layer {
|
||||
let translation = self
|
||||
.surfaces
|
||||
.get_render_context_translation(self.render_area, scale);
|
||||
self.render_element_drop_shadows_and_composite(
|
||||
element,
|
||||
tree,
|
||||
&mut extrect,
|
||||
clip_bounds.clone(),
|
||||
scale,
|
||||
translation,
|
||||
&node_render_state,
|
||||
);
|
||||
}
|
||||
|
||||
self.render_shape_enter(element, mask);
|
||||
}
|
||||
|
||||
@@ -1753,180 +1938,25 @@ impl RenderState {
|
||||
// Skip expensive drop shadow rendering in fast mode (during pan/zoom)
|
||||
let skip_shadows = self.options.is_fast_mode();
|
||||
|
||||
// Skip shadow block when already rendered before the layer (frame_clip_layer_blur)
|
||||
let shadows_already_rendered = Self::frame_clip_layer_blur(element).is_some();
|
||||
|
||||
// For text shapes, render drop shadow using text rendering logic
|
||||
if !skip_shadows && !matches!(element.shape_type, Type::Text(_)) {
|
||||
// Shadow rendering technique: Two-pass approach for proper opacity handling
|
||||
//
|
||||
// The shadow rendering uses a two-pass technique to ensure that overlapping
|
||||
// shadow areas maintain correct opacity without unwanted darkening:
|
||||
//
|
||||
// 1. First pass: Render shadow shape in pure black (alpha channel preserved)
|
||||
// - This creates the shadow silhouette with proper alpha gradients
|
||||
// - The black color acts as a mask for the final shadow color
|
||||
//
|
||||
// 2. Second pass: Apply actual shadow color using SrcIn blend mode
|
||||
// - SrcIn preserves the alpha channel from the black shadow
|
||||
// - Only the color channels are replaced, maintaining transparency
|
||||
// - This prevents overlapping shadows from accumulating opacity
|
||||
//
|
||||
// This approach is essential for complex shapes with transparency where
|
||||
// multiple shadow areas might overlap, ensuring visual consistency.
|
||||
let inherited_layer_blur = match element.shape_type {
|
||||
Type::Frame(_) | Type::Group(_) => element.blur,
|
||||
_ => None,
|
||||
};
|
||||
|
||||
for shadow in element.drop_shadows_visible() {
|
||||
let paint = skia::Paint::default();
|
||||
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
|
||||
|
||||
self.surfaces
|
||||
.canvas(SurfaceId::DropShadows)
|
||||
.save_layer(&layer_rec);
|
||||
|
||||
// First pass: Render shadow in black to establish alpha mask
|
||||
let element_extrect =
|
||||
extrect.get_or_insert_with(|| element.extrect(tree, scale));
|
||||
self.render_drop_black_shadow(
|
||||
element,
|
||||
element_extrect,
|
||||
shadow,
|
||||
clip_bounds.clone(),
|
||||
scale,
|
||||
translation,
|
||||
None,
|
||||
);
|
||||
|
||||
if !matches!(element.shape_type, Type::Bool(_)) {
|
||||
// Nested shapes shadowing - apply black shadow to child shapes too
|
||||
for shadow_shape_id in element.children.iter() {
|
||||
let Some(shadow_shape) = tree.get(shadow_shape_id) else {
|
||||
continue;
|
||||
};
|
||||
if shadow_shape.hidden {
|
||||
continue;
|
||||
}
|
||||
let clip_bounds = node_render_state
|
||||
.get_nested_shadow_clip_bounds(element, shadow);
|
||||
|
||||
if !matches!(shadow_shape.shape_type, Type::Text(_)) {
|
||||
self.render_drop_black_shadow(
|
||||
shadow_shape,
|
||||
&shadow_shape.extrect(tree, scale),
|
||||
shadow,
|
||||
clip_bounds,
|
||||
scale,
|
||||
translation,
|
||||
inherited_layer_blur,
|
||||
);
|
||||
} else {
|
||||
let paint = skia::Paint::default();
|
||||
let layer_rec =
|
||||
skia::canvas::SaveLayerRec::default().paint(&paint);
|
||||
|
||||
self.surfaces
|
||||
.canvas(SurfaceId::DropShadows)
|
||||
.save_layer(&layer_rec);
|
||||
self.surfaces
|
||||
.canvas(SurfaceId::DropShadows)
|
||||
.scale((scale, scale));
|
||||
self.surfaces
|
||||
.canvas(SurfaceId::DropShadows)
|
||||
.translate(translation);
|
||||
|
||||
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;
|
||||
transformed_shadow.to_mut().spread =
|
||||
transformed_shadow.spread * scale;
|
||||
|
||||
let mut new_shadow_paint = skia::Paint::default();
|
||||
new_shadow_paint.set_image_filter(
|
||||
transformed_shadow.get_drop_shadow_filter(),
|
||||
);
|
||||
new_shadow_paint.set_blend_mode(skia::BlendMode::SrcOver);
|
||||
|
||||
self.with_nested_blurs_suppressed(|state| {
|
||||
state.render_shape(
|
||||
shadow_shape,
|
||||
clip_bounds,
|
||||
SurfaceId::DropShadows,
|
||||
SurfaceId::DropShadows,
|
||||
SurfaceId::DropShadows,
|
||||
SurfaceId::DropShadows,
|
||||
true,
|
||||
None,
|
||||
Some(vec![new_shadow_paint.clone()]),
|
||||
);
|
||||
});
|
||||
self.surfaces.canvas(SurfaceId::DropShadows).restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: Apply actual shadow color using SrcIn blend mode
|
||||
// This preserves the alpha channel from the black shadow while
|
||||
// replacing only the color channels, preventing opacity accumulation
|
||||
let mut paint = skia::Paint::default();
|
||||
paint.set_color(shadow.color);
|
||||
paint.set_blend_mode(skia::BlendMode::SrcIn);
|
||||
self.surfaces
|
||||
.canvas(SurfaceId::DropShadows)
|
||||
.draw_paint(&paint);
|
||||
|
||||
self.surfaces.canvas(SurfaceId::DropShadows).restore();
|
||||
}
|
||||
if !skip_shadows
|
||||
&& !shadows_already_rendered
|
||||
&& !matches!(element.shape_type, Type::Text(_))
|
||||
{
|
||||
self.render_element_drop_shadows_and_composite(
|
||||
element,
|
||||
tree,
|
||||
&mut extrect,
|
||||
clip_bounds.clone(),
|
||||
scale,
|
||||
translation,
|
||||
&node_render_state,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(clips) = clip_bounds.as_ref() {
|
||||
let antialias = element.should_use_antialias(scale);
|
||||
|
||||
self.surfaces.canvas(SurfaceId::Current).save();
|
||||
for (bounds, corners, transform) in clips.iter() {
|
||||
let mut total_matrix = Matrix::new_identity();
|
||||
total_matrix.pre_scale((scale, scale), None);
|
||||
total_matrix.pre_translate((translation.0, translation.1));
|
||||
total_matrix.pre_concat(transform);
|
||||
|
||||
self.surfaces
|
||||
.canvas(SurfaceId::Current)
|
||||
.concat(&total_matrix);
|
||||
|
||||
if let Some(corners) = corners {
|
||||
let rrect = RRect::new_rect_radii(*bounds, corners);
|
||||
self.surfaces.canvas(SurfaceId::Current).clip_rrect(
|
||||
rrect,
|
||||
skia::ClipOp::Intersect,
|
||||
antialias,
|
||||
);
|
||||
} else {
|
||||
self.surfaces.canvas(SurfaceId::Current).clip_rect(
|
||||
*bounds,
|
||||
skia::ClipOp::Intersect,
|
||||
antialias,
|
||||
);
|
||||
}
|
||||
|
||||
self.surfaces
|
||||
.canvas(SurfaceId::Current)
|
||||
.concat(&total_matrix.invert().unwrap_or_default());
|
||||
}
|
||||
|
||||
self.surfaces
|
||||
.draw_into(SurfaceId::DropShadows, SurfaceId::Current, None);
|
||||
|
||||
self.surfaces.canvas(SurfaceId::Current).restore();
|
||||
} else {
|
||||
self.surfaces
|
||||
.draw_into(SurfaceId::DropShadows, SurfaceId::Current, None);
|
||||
}
|
||||
|
||||
self.surfaces
|
||||
.canvas(SurfaceId::DropShadows)
|
||||
.clear(skia::Color::TRANSPARENT);
|
||||
|
||||
self.render_shape(
|
||||
element,
|
||||
clip_bounds.clone(),
|
||||
@@ -2063,8 +2093,13 @@ 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.
|
||||
let tile_is_visible = self.tile_viewbox.is_visible(¤t_tile);
|
||||
let can_stop = allow_stop && !tile_is_visible;
|
||||
let (is_empty, early_return) =
|
||||
self.render_shape_tree_partial_uncached(tree, timestamp, allow_stop)?;
|
||||
self.render_shape_tree_partial_uncached(tree, timestamp, can_stop)?;
|
||||
|
||||
if early_return {
|
||||
return Ok(());
|
||||
@@ -2189,17 +2224,20 @@ impl RenderState {
|
||||
* Given a shape, check the indexes and update it's location in the tile set
|
||||
* returns the tiles that have changed in the process.
|
||||
*/
|
||||
pub fn update_shape_tiles(&mut self, shape: &Shape, tree: ShapesPoolRef) -> Vec<tiles::Tile> {
|
||||
pub fn update_shape_tiles(
|
||||
&mut self,
|
||||
shape: &Shape,
|
||||
tree: ShapesPoolRef,
|
||||
) -> HashSet<tiles::Tile> {
|
||||
let TileRect(rsx, rsy, rex, rey) = self.get_tiles_for_shape(shape, tree);
|
||||
|
||||
let old_tiles = self
|
||||
// Collect old tiles to avoid borrow conflict with remove_shape_at
|
||||
let old_tiles: Vec<_> = self
|
||||
.tiles
|
||||
.get_tiles_of(shape.id)
|
||||
.map_or(Vec::new(), |tiles| tiles.iter().copied().collect());
|
||||
.map_or(Vec::new(), |t| t.iter().copied().collect());
|
||||
|
||||
let new_tiles = (rsx..=rex).flat_map(|x| (rsy..=rey).map(move |y| tiles::Tile::from(x, y)));
|
||||
|
||||
let mut result = HashSet::<tiles::Tile>::new();
|
||||
let mut result = HashSet::<tiles::Tile>::with_capacity(old_tiles.len());
|
||||
|
||||
// First, remove the shape from all tiles where it was previously located
|
||||
for tile in old_tiles {
|
||||
@@ -2208,12 +2246,66 @@ impl RenderState {
|
||||
}
|
||||
|
||||
// Then, add the shape to the new tiles
|
||||
for tile in new_tiles {
|
||||
for tile in (rsx..=rex).flat_map(|x| (rsy..=rey).map(move |y| tiles::Tile::from(x, y))) {
|
||||
self.tiles.add_shape_at(tile, shape.id);
|
||||
result.insert(tile);
|
||||
}
|
||||
|
||||
result.iter().copied().collect()
|
||||
result
|
||||
}
|
||||
|
||||
/*
|
||||
* Incremental version of update_shape_tiles for pan/zoom operations.
|
||||
* Updates the tile index and returns ONLY tiles that need cache invalidation.
|
||||
*
|
||||
* During pan operations, shapes don't move in world coordinates. The interest
|
||||
* area (viewport) moves, which changes which tiles we track in the index, but
|
||||
* tiles that were already cached don't need re-rendering just because the
|
||||
* viewport moved.
|
||||
*
|
||||
* This function:
|
||||
* 1. Updates the tile index (adds/removes shapes from tiles based on interest area)
|
||||
* 2. Returns empty vec for cache invalidation (pan doesn't change tile content)
|
||||
*
|
||||
* Tile cache invalidation only happens when shapes actually move or change,
|
||||
* which is handled by rebuild_touched_tiles, not during pan/zoom.
|
||||
*/
|
||||
pub fn update_shape_tiles_incremental(
|
||||
&mut self,
|
||||
shape: &Shape,
|
||||
tree: ShapesPoolRef,
|
||||
) -> Vec<tiles::Tile> {
|
||||
let TileRect(rsx, rsy, rex, rey) = self.get_tiles_for_shape(shape, tree);
|
||||
|
||||
let old_tiles: HashSet<tiles::Tile> = self
|
||||
.tiles
|
||||
.get_tiles_of(shape.id)
|
||||
.map_or(HashSet::new(), |tiles| tiles.iter().copied().collect());
|
||||
|
||||
let new_tiles: HashSet<tiles::Tile> = (rsx..=rex)
|
||||
.flat_map(|x| (rsy..=rey).map(move |y| tiles::Tile::from(x, y)))
|
||||
.collect();
|
||||
|
||||
// Tiles where shape is being removed from index (left interest area)
|
||||
let removed: Vec<_> = old_tiles.difference(&new_tiles).copied().collect();
|
||||
// Tiles where shape is being added to index (entered interest area)
|
||||
let added: Vec<_> = new_tiles.difference(&old_tiles).copied().collect();
|
||||
|
||||
// Update the index: remove from old tiles
|
||||
for tile in &removed {
|
||||
self.tiles.remove_shape_at(*tile, shape.id);
|
||||
}
|
||||
|
||||
// Update the index: add to new tiles
|
||||
for tile in &added {
|
||||
self.tiles.add_shape_at(*tile, shape.id);
|
||||
}
|
||||
|
||||
// Don't invalidate cache for pan/zoom - the tile content hasn't changed,
|
||||
// only the interest area moved. Tiles that were cached are still valid.
|
||||
// New tiles that entered the interest area will be rendered fresh since
|
||||
// they weren't in the cache anyway.
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -2239,12 +2331,22 @@ impl RenderState {
|
||||
pub fn rebuild_tiles_shallow(&mut self, tree: ShapesPoolRef) {
|
||||
performance::begin_measure!("rebuild_tiles_shallow");
|
||||
|
||||
let mut all_tiles = HashSet::<tiles::Tile>::new();
|
||||
// Check if zoom changed - if so, we need full cache invalidation
|
||||
// because tiles are rendered at specific zoom levels
|
||||
let zoom_changed = self.zoom_changed();
|
||||
|
||||
let mut tiles_to_invalidate = HashSet::<tiles::Tile>::new();
|
||||
let mut nodes = vec![Uuid::nil()];
|
||||
while let Some(shape_id) = nodes.pop() {
|
||||
if let Some(shape) = tree.get(&shape_id) {
|
||||
if shape_id != Uuid::nil() {
|
||||
all_tiles.extend(self.update_shape_tiles(shape, tree));
|
||||
if zoom_changed {
|
||||
// Zoom changed: use full update that tracks all affected tiles
|
||||
tiles_to_invalidate.extend(self.update_shape_tiles(shape, tree));
|
||||
} else {
|
||||
// Pan only: use incremental update that preserves valid cached tiles
|
||||
self.update_shape_tiles_incremental(shape, tree);
|
||||
}
|
||||
} else {
|
||||
// We only need to rebuild tiles from the first level.
|
||||
for child_id in shape.children_ids_iter(false) {
|
||||
@@ -2256,9 +2358,6 @@ impl RenderState {
|
||||
|
||||
// Invalidate changed tiles - old content stays visible until new tiles render
|
||||
self.surfaces.remove_cached_tiles(self.background_color);
|
||||
for tile in all_tiles {
|
||||
self.remove_cached_tile(tile);
|
||||
}
|
||||
|
||||
performance::end_measure!("rebuild_tiles_shallow");
|
||||
}
|
||||
@@ -2307,7 +2406,7 @@ impl RenderState {
|
||||
|
||||
let mut all_tiles = HashSet::<tiles::Tile>::new();
|
||||
|
||||
let ids = self.touched_ids.clone();
|
||||
let ids = std::mem::take(&mut self.touched_ids);
|
||||
|
||||
for shape_id in ids.iter() {
|
||||
if let Some(shape) = tree.get(shape_id) {
|
||||
@@ -2322,8 +2421,6 @@ impl RenderState {
|
||||
self.remove_cached_tile(tile);
|
||||
}
|
||||
|
||||
self.clean_touched();
|
||||
|
||||
performance::end_measure!("rebuild_touched_tiles");
|
||||
}
|
||||
|
||||
@@ -2380,6 +2477,7 @@ impl RenderState {
|
||||
self.touched_ids.insert(uuid);
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn clean_touched(&mut self) {
|
||||
self.touched_ids.clear();
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use skia_safe::{self as skia, Paint, RRect};
|
||||
|
||||
use super::{filters, RenderState, SurfaceId};
|
||||
use crate::render::get_source_rect;
|
||||
use crate::shapes::{Fill, Frame, ImageFill, Rect, Shape, Type};
|
||||
use crate::shapes::{merge_fills, Fill, Frame, ImageFill, Rect, Shape, Type};
|
||||
|
||||
fn draw_image_fill(
|
||||
render_state: &mut RenderState,
|
||||
@@ -92,6 +92,76 @@ fn draw_image_fill(
|
||||
* This SHOULD be the only public function in this module.
|
||||
*/
|
||||
pub fn render(
|
||||
render_state: &mut RenderState,
|
||||
shape: &Shape,
|
||||
fills: &[Fill],
|
||||
antialias: bool,
|
||||
surface_id: SurfaceId,
|
||||
) {
|
||||
if fills.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Image fills use draw_image_fill which needs render_state for GPU images
|
||||
// and sampling options that get_fill_shader (used by merge_fills) lacks.
|
||||
let has_image_fills = fills.iter().any(|f| matches!(f, Fill::Image(_)));
|
||||
if has_image_fills {
|
||||
for fill in fills.iter().rev() {
|
||||
render_single_fill(render_state, shape, fill, antialias, surface_id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let mut paint = merge_fills(fills, shape.selrect);
|
||||
paint.set_anti_alias(antialias);
|
||||
|
||||
if let Some(image_filter) = shape.image_filter(1.) {
|
||||
let bounds = image_filter.compute_fast_bounds(shape.selrect);
|
||||
if filters::render_with_filter_surface(
|
||||
render_state,
|
||||
bounds,
|
||||
surface_id,
|
||||
|state, temp_surface| {
|
||||
let mut filtered_paint = paint.clone();
|
||||
filtered_paint.set_image_filter(image_filter.clone());
|
||||
draw_fill_to_surface(state, shape, temp_surface, &filtered_paint);
|
||||
},
|
||||
) {
|
||||
return;
|
||||
} else {
|
||||
paint.set_image_filter(image_filter);
|
||||
}
|
||||
}
|
||||
|
||||
draw_fill_to_surface(render_state, shape, surface_id, &paint);
|
||||
}
|
||||
|
||||
/// Draws a single paint (with a merged shader) to the appropriate surface
|
||||
/// based on the shape type.
|
||||
fn draw_fill_to_surface(
|
||||
render_state: &mut RenderState,
|
||||
shape: &Shape,
|
||||
surface_id: SurfaceId,
|
||||
paint: &Paint,
|
||||
) {
|
||||
match &shape.shape_type {
|
||||
Type::Rect(_) | Type::Frame(_) => {
|
||||
render_state.surfaces.draw_rect_to(surface_id, shape, paint);
|
||||
}
|
||||
Type::Circle => {
|
||||
render_state
|
||||
.surfaces
|
||||
.draw_circle_to(surface_id, shape, paint);
|
||||
}
|
||||
Type::Path(_) | Type::Bool(_) => {
|
||||
render_state.surfaces.draw_path_to(surface_id, shape, paint);
|
||||
}
|
||||
Type::Group(_) => {}
|
||||
_ => unreachable!("This shape should not have fills"),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_single_fill(
|
||||
render_state: &mut RenderState,
|
||||
shape: &Shape,
|
||||
fill: &Fill,
|
||||
@@ -108,7 +178,14 @@ pub fn render(
|
||||
|state, temp_surface| {
|
||||
let mut filtered_paint = paint.clone();
|
||||
filtered_paint.set_image_filter(image_filter.clone());
|
||||
draw_fill_to_surface(state, shape, fill, antialias, temp_surface, &filtered_paint);
|
||||
draw_single_fill_to_surface(
|
||||
state,
|
||||
shape,
|
||||
fill,
|
||||
antialias,
|
||||
temp_surface,
|
||||
&filtered_paint,
|
||||
);
|
||||
},
|
||||
) {
|
||||
return;
|
||||
@@ -117,10 +194,10 @@ pub fn render(
|
||||
}
|
||||
}
|
||||
|
||||
draw_fill_to_surface(render_state, shape, fill, antialias, surface_id, &paint);
|
||||
draw_single_fill_to_surface(render_state, shape, fill, antialias, surface_id, &paint);
|
||||
}
|
||||
|
||||
fn draw_fill_to_surface(
|
||||
fn draw_single_fill_to_surface(
|
||||
render_state: &mut RenderState,
|
||||
shape: &Shape,
|
||||
fill: &Fill,
|
||||
@@ -153,8 +230,6 @@ fn draw_fill_to_surface(
|
||||
(_, Type::Group(_)) => {
|
||||
// Groups can have fills but they propagate them to their children
|
||||
}
|
||||
(_, _) => {
|
||||
unreachable!("This shape should not have fills")
|
||||
}
|
||||
_ => unreachable!("This shape should not have fills"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ pub fn render_stroke_inner_shadows(
|
||||
if !shape.has_fills() {
|
||||
for shadow in shape.inner_shadows_visible() {
|
||||
let filter = shadow.get_inner_shadow_filter();
|
||||
strokes::render(
|
||||
strokes::render_single(
|
||||
render_state,
|
||||
shape,
|
||||
stroke,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::math::{Matrix, Point, Rect};
|
||||
|
||||
use crate::shapes::{
|
||||
Corners, Fill, ImageFill, Path, Shape, Stroke, StrokeCap, StrokeKind, SvgAttrs, Type,
|
||||
merge_fills, Corners, Fill, ImageFill, Path, Shape, Stroke, StrokeCap, StrokeKind, Type,
|
||||
};
|
||||
use skia_safe::{self as skia, ImageFilter, RRect};
|
||||
|
||||
@@ -9,32 +9,28 @@ use super::{filters, RenderState, SurfaceId};
|
||||
use crate::render::filters::compose_filters;
|
||||
use crate::render::{get_dest_rect, get_source_rect};
|
||||
|
||||
// FIXME: See if we can simplify these arguments
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_stroke_on_rect(
|
||||
canvas: &skia::Canvas,
|
||||
stroke: &Stroke,
|
||||
rect: &Rect,
|
||||
selrect: &Rect,
|
||||
corners: &Option<Corners>,
|
||||
svg_attrs: Option<&SvgAttrs>,
|
||||
paint: &skia::Paint,
|
||||
scale: f32,
|
||||
shadow: Option<&ImageFilter>,
|
||||
blur: Option<&ImageFilter>,
|
||||
antialias: bool,
|
||||
) {
|
||||
// Draw the different kind of strokes for a rect is straightforward, we just need apply a stroke to:
|
||||
// - The same rect if it's a center stroke
|
||||
// - A bigger rect if it's an outer stroke
|
||||
// - A smaller rect if it's an outer stroke
|
||||
let stroke_rect = stroke.aligned_rect(rect, scale);
|
||||
let mut paint = stroke.to_paint(selrect, svg_attrs, antialias);
|
||||
let mut paint = paint.clone();
|
||||
|
||||
// Apply both blur and shadow filters if present, composing them if necessary.
|
||||
let filter = compose_filters(blur, shadow);
|
||||
paint.set_image_filter(filter);
|
||||
|
||||
match corners {
|
||||
// By default just draw the rect. Only dotted inner/outer strokes need
|
||||
// clipping to prevent the dotted pattern from appearing in wrong areas.
|
||||
let draw_stroke = || match corners {
|
||||
Some(radii) => {
|
||||
let radii = stroke.outer_corners(radii);
|
||||
let rrect = RRect::new_rect_radii(stroke_rect, &radii);
|
||||
@@ -43,34 +39,58 @@ fn draw_stroke_on_rect(
|
||||
None => {
|
||||
canvas.draw_rect(stroke_rect, &paint);
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(clip_op) = stroke.clip_op() {
|
||||
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
|
||||
canvas.save_layer(&layer_rec);
|
||||
match corners {
|
||||
Some(radii) => {
|
||||
let rrect = RRect::new_rect_radii(*rect, radii);
|
||||
canvas.clip_rrect(rrect, clip_op, antialias);
|
||||
}
|
||||
None => {
|
||||
canvas.clip_rect(*rect, clip_op, antialias);
|
||||
}
|
||||
}
|
||||
draw_stroke();
|
||||
canvas.restore();
|
||||
} else {
|
||||
draw_stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: See if we can simplify these arguments
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_stroke_on_circle(
|
||||
canvas: &skia::Canvas,
|
||||
stroke: &Stroke,
|
||||
rect: &Rect,
|
||||
selrect: &Rect,
|
||||
svg_attrs: Option<&SvgAttrs>,
|
||||
paint: &skia::Paint,
|
||||
scale: f32,
|
||||
shadow: Option<&ImageFilter>,
|
||||
blur: Option<&ImageFilter>,
|
||||
antialias: bool,
|
||||
) {
|
||||
// Draw the different kind of strokes for an oval is straightforward, we just need apply a stroke to:
|
||||
// - The same oval if it's a center stroke
|
||||
// - A bigger oval if it's an outer stroke
|
||||
// - A smaller oval if it's an outer stroke
|
||||
let stroke_rect = stroke.aligned_rect(rect, scale);
|
||||
let mut paint = stroke.to_paint(selrect, svg_attrs, antialias);
|
||||
let mut paint = paint.clone();
|
||||
|
||||
// Apply both blur and shadow filters if present, composing them if necessary.
|
||||
let filter = compose_filters(blur, shadow);
|
||||
paint.set_image_filter(filter);
|
||||
|
||||
canvas.draw_oval(stroke_rect, &paint);
|
||||
// By default just draw the circle. Only dotted inner/outer strokes need
|
||||
// clipping to prevent the dotted pattern from appearing in wrong areas.
|
||||
if let Some(clip_op) = stroke.clip_op() {
|
||||
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
|
||||
canvas.save_layer(&layer_rec);
|
||||
let mut clip_path = skia::Path::new();
|
||||
clip_path.add_oval(rect, None);
|
||||
canvas.clip_path(&clip_path, clip_op, antialias);
|
||||
canvas.draw_oval(stroke_rect, &paint);
|
||||
canvas.restore();
|
||||
} else {
|
||||
canvas.draw_oval(stroke_rect, &paint);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_outer_stroke_path(
|
||||
@@ -122,15 +142,13 @@ fn draw_inner_stroke_path(
|
||||
}
|
||||
|
||||
// For outer stroke we draw a center stroke (with double width) and use another path with blend mode clear to remove the inner stroke added
|
||||
// FIXME: See if we can simplify these arguments
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn draw_stroke_on_path(
|
||||
fn draw_stroke_on_path(
|
||||
canvas: &skia::Canvas,
|
||||
stroke: &Stroke,
|
||||
path: &Path,
|
||||
selrect: &Rect,
|
||||
paint: &skia::Paint,
|
||||
path_transform: Option<&Matrix>,
|
||||
svg_attrs: Option<&SvgAttrs>,
|
||||
shadow: Option<&ImageFilter>,
|
||||
blur: Option<&ImageFilter>,
|
||||
antialias: bool,
|
||||
@@ -140,31 +158,28 @@ pub fn draw_stroke_on_path(
|
||||
|
||||
let is_open = path.is_open();
|
||||
|
||||
let mut paint: skia_safe::Handle<_> =
|
||||
stroke.to_stroked_paint(is_open, selrect, svg_attrs, antialias);
|
||||
|
||||
let mut draw_paint = paint.clone();
|
||||
let filter = compose_filters(blur, shadow);
|
||||
paint.set_image_filter(filter);
|
||||
draw_paint.set_image_filter(filter);
|
||||
|
||||
match stroke.render_kind(is_open) {
|
||||
StrokeKind::Inner => {
|
||||
draw_inner_stroke_path(canvas, &skia_path, &paint, blur, antialias);
|
||||
draw_inner_stroke_path(canvas, &skia_path, &draw_paint, blur, antialias);
|
||||
}
|
||||
StrokeKind::Center => {
|
||||
canvas.draw_path(&skia_path, &paint);
|
||||
canvas.draw_path(&skia_path, &draw_paint);
|
||||
}
|
||||
StrokeKind::Outer => {
|
||||
draw_outer_stroke_path(canvas, &skia_path, &paint, blur, antialias);
|
||||
draw_outer_stroke_path(canvas, &skia_path, &draw_paint, blur, antialias);
|
||||
}
|
||||
}
|
||||
|
||||
handle_stroke_caps(
|
||||
&mut skia_path,
|
||||
stroke,
|
||||
selrect,
|
||||
canvas,
|
||||
is_open,
|
||||
svg_attrs,
|
||||
paint,
|
||||
blur,
|
||||
antialias,
|
||||
);
|
||||
@@ -207,17 +222,15 @@ fn handle_stroke_cap(
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: See if we can simplify these arguments
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_stroke_caps(
|
||||
path: &mut skia::Path,
|
||||
stroke: &Stroke,
|
||||
selrect: &Rect,
|
||||
canvas: &skia::Canvas,
|
||||
is_open: bool,
|
||||
svg_attrs: Option<&SvgAttrs>,
|
||||
paint: &skia::Paint,
|
||||
blur: Option<&ImageFilter>,
|
||||
antialias: bool,
|
||||
_antialias: bool,
|
||||
) {
|
||||
let mut points = vec![Point::default(); path.count_points()];
|
||||
path.get_points(&mut points);
|
||||
@@ -230,7 +243,7 @@ fn handle_stroke_caps(
|
||||
let first_point = points.first().unwrap();
|
||||
let last_point = points.last().unwrap();
|
||||
|
||||
let mut paint_stroke = stroke.to_stroked_paint(is_open, selrect, svg_attrs, antialias);
|
||||
let mut paint_stroke = paint.clone();
|
||||
|
||||
if let Some(filter) = blur {
|
||||
paint_stroke.set_image_filter(filter.clone());
|
||||
@@ -405,30 +418,25 @@ fn draw_image_stroke_in_container(
|
||||
|
||||
match &shape.shape_type {
|
||||
shape_type @ (Type::Rect(_) | Type::Frame(_)) => {
|
||||
let paint = stroke.to_paint(&outer_rect, svg_attrs, antialias);
|
||||
draw_stroke_on_rect(
|
||||
canvas,
|
||||
stroke,
|
||||
container,
|
||||
&outer_rect,
|
||||
&shape_type.corners(),
|
||||
svg_attrs,
|
||||
&paint,
|
||||
scale,
|
||||
None,
|
||||
None,
|
||||
antialias,
|
||||
);
|
||||
}
|
||||
Type::Circle => draw_stroke_on_circle(
|
||||
canvas,
|
||||
stroke,
|
||||
container,
|
||||
&outer_rect,
|
||||
svg_attrs,
|
||||
scale,
|
||||
None,
|
||||
None,
|
||||
antialias,
|
||||
),
|
||||
Type::Circle => {
|
||||
let paint = stroke.to_paint(&outer_rect, svg_attrs, antialias);
|
||||
draw_stroke_on_circle(
|
||||
canvas, stroke, container, &paint, scale, None, None, antialias,
|
||||
);
|
||||
}
|
||||
|
||||
shape_type @ (Type::Path(_) | Type::Bool(_)) => {
|
||||
if let Some(p) = shape_type.path() {
|
||||
@@ -446,21 +454,21 @@ fn draw_image_stroke_in_container(
|
||||
}
|
||||
}
|
||||
let is_open = p.is_open();
|
||||
let mut paint = stroke.to_stroked_paint(is_open, &outer_rect, svg_attrs, antialias);
|
||||
let paint = stroke.to_stroked_paint(is_open, &outer_rect, svg_attrs, antialias);
|
||||
canvas.draw_path(&path, &paint);
|
||||
if stroke.render_kind(is_open) == StrokeKind::Outer {
|
||||
// Small extra inner stroke to overlap with the fill
|
||||
// and avoid unnecesary artifacts.
|
||||
paint.set_stroke_width(1. / scale);
|
||||
canvas.draw_path(&path, &paint);
|
||||
let mut thin_paint = paint.clone();
|
||||
thin_paint.set_stroke_width(1. / scale);
|
||||
canvas.draw_path(&path, &thin_paint);
|
||||
}
|
||||
handle_stroke_caps(
|
||||
&mut path,
|
||||
stroke,
|
||||
&outer_rect,
|
||||
canvas,
|
||||
is_open,
|
||||
svg_attrs,
|
||||
&paint,
|
||||
shape.image_filter(1.).as_ref(),
|
||||
antialias,
|
||||
);
|
||||
@@ -509,8 +517,230 @@ fn draw_image_stroke_in_container(
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
/// Renders all strokes for a shape. Merges strokes that share the same
|
||||
/// geometry (kind, width, style, caps) into a single draw call to avoid
|
||||
/// anti-aliasing edge bleed between them.
|
||||
pub fn render(
|
||||
render_state: &mut RenderState,
|
||||
shape: &Shape,
|
||||
strokes: &[&Stroke],
|
||||
surface_id: Option<SurfaceId>,
|
||||
antialias: bool,
|
||||
) {
|
||||
if strokes.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let has_image_fills = strokes.iter().any(|s| matches!(s.fill, Fill::Image(_)));
|
||||
let can_merge = !has_image_fills && strokes.len() > 1 && strokes_share_geometry(strokes);
|
||||
|
||||
if !can_merge {
|
||||
// When blur is active, render all strokes into a single offscreen surface
|
||||
// and apply blur once to the composite. This prevents blur from making
|
||||
// edges semi-transparent and revealing strokes underneath.
|
||||
if let Some(image_filter) = shape.image_filter(1.) {
|
||||
let mut content_bounds = shape.selrect;
|
||||
let max_margin = strokes
|
||||
.iter()
|
||||
.map(|s| s.bounds_width(shape.is_open()))
|
||||
.fold(0.0f32, f32::max);
|
||||
if max_margin > 0.0 {
|
||||
content_bounds.inset((-max_margin, -max_margin));
|
||||
}
|
||||
let max_cap = strokes
|
||||
.iter()
|
||||
.map(|s| s.cap_bounds_margin())
|
||||
.fold(0.0f32, f32::max);
|
||||
if max_cap > 0.0 {
|
||||
content_bounds.inset((-max_cap, -max_cap));
|
||||
}
|
||||
let bounds = image_filter.compute_fast_bounds(content_bounds);
|
||||
let target = surface_id.unwrap_or(SurfaceId::Strokes);
|
||||
if filters::render_with_filter_surface(
|
||||
render_state,
|
||||
bounds,
|
||||
target,
|
||||
|state, temp_surface| {
|
||||
// Use save_layer with the blur filter so it applies once
|
||||
// to the composite of all strokes, not per-stroke.
|
||||
let canvas = state.surfaces.canvas(temp_surface);
|
||||
let mut blur_paint = skia::Paint::default();
|
||||
blur_paint.set_image_filter(image_filter.clone());
|
||||
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&blur_paint);
|
||||
canvas.save_layer(&layer_rec);
|
||||
|
||||
for stroke in strokes.iter().rev() {
|
||||
// bypass_filter=true prevents each stroke from creating
|
||||
// its own filter surface. The blur on the paint inside
|
||||
// draw functions is harmless — it composes with the
|
||||
// layer's filter but the layer filter is the dominant one.
|
||||
render_single_internal(
|
||||
state,
|
||||
shape,
|
||||
stroke,
|
||||
Some(temp_surface),
|
||||
None,
|
||||
antialias,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
state.surfaces.canvas(temp_surface).restore();
|
||||
},
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No blur or filter surface unavailable — draw strokes individually.
|
||||
for stroke in strokes.iter().rev() {
|
||||
render_single(render_state, shape, stroke, surface_id, None, antialias);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
render_merged(render_state, shape, strokes, surface_id, antialias, false);
|
||||
}
|
||||
|
||||
fn strokes_share_geometry(strokes: &[&Stroke]) -> bool {
|
||||
strokes.windows(2).all(|pair| {
|
||||
pair[0].kind == pair[1].kind
|
||||
&& pair[0].width == pair[1].width
|
||||
&& pair[0].style == pair[1].style
|
||||
&& pair[0].cap_start == pair[1].cap_start
|
||||
&& pair[0].cap_end == pair[1].cap_end
|
||||
})
|
||||
}
|
||||
|
||||
fn render_merged(
|
||||
render_state: &mut RenderState,
|
||||
shape: &Shape,
|
||||
strokes: &[&Stroke],
|
||||
surface_id: Option<SurfaceId>,
|
||||
antialias: bool,
|
||||
bypass_filter: bool,
|
||||
) {
|
||||
let representative = *strokes
|
||||
.last()
|
||||
.expect("render_merged expects at least one stroke");
|
||||
|
||||
let blur_filter = if bypass_filter {
|
||||
None
|
||||
} else {
|
||||
shape.image_filter(1.)
|
||||
};
|
||||
|
||||
// Handle blur filter
|
||||
if !bypass_filter {
|
||||
if let Some(image_filter) = blur_filter.clone() {
|
||||
let mut content_bounds = shape.selrect;
|
||||
let stroke_margin = representative.bounds_width(shape.is_open());
|
||||
if stroke_margin > 0.0 {
|
||||
content_bounds.inset((-stroke_margin, -stroke_margin));
|
||||
}
|
||||
let cap_margin = representative.cap_bounds_margin();
|
||||
if cap_margin > 0.0 {
|
||||
content_bounds.inset((-cap_margin, -cap_margin));
|
||||
}
|
||||
let bounds = image_filter.compute_fast_bounds(content_bounds);
|
||||
let target = surface_id.unwrap_or(SurfaceId::Strokes);
|
||||
if filters::render_with_filter_surface(
|
||||
render_state,
|
||||
bounds,
|
||||
target,
|
||||
|state, temp_surface| {
|
||||
let blur_filter = image_filter.clone();
|
||||
|
||||
state.surfaces.apply_mut(temp_surface as u32, |surface| {
|
||||
let canvas = surface.canvas();
|
||||
let mut blur_paint = skia::Paint::default();
|
||||
blur_paint.set_image_filter(blur_filter.clone());
|
||||
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&blur_paint);
|
||||
canvas.save_layer(&layer_rec);
|
||||
});
|
||||
|
||||
render_merged(state, shape, strokes, Some(temp_surface), antialias, true);
|
||||
|
||||
state.surfaces.apply_mut(temp_surface as u32, |surface| {
|
||||
surface.canvas().restore();
|
||||
});
|
||||
},
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `merge_fills` puts fills[0] on top (each new fill goes under the accumulated shader
|
||||
// via SrcOver), matching the non-merged path where strokes[0] is drawn last (on top).
|
||||
let fills: Vec<Fill> = strokes.iter().map(|s| s.fill.clone()).collect();
|
||||
|
||||
let merged = merge_fills(&fills, shape.selrect);
|
||||
let scale = render_state.get_scale();
|
||||
let target_surface = surface_id.unwrap_or(SurfaceId::Strokes);
|
||||
let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface);
|
||||
let selrect = shape.selrect;
|
||||
let svg_attrs = shape.svg_attrs.as_ref();
|
||||
let path_transform = shape.to_path_transform();
|
||||
|
||||
match &shape.shape_type {
|
||||
shape_type @ (Type::Rect(_) | Type::Frame(_)) => {
|
||||
let mut paint = representative.to_paint(&selrect, svg_attrs, antialias);
|
||||
paint.set_shader(merged.shader());
|
||||
draw_stroke_on_rect(
|
||||
canvas,
|
||||
representative,
|
||||
&selrect,
|
||||
&shape_type.corners(),
|
||||
&paint,
|
||||
scale,
|
||||
None,
|
||||
blur_filter.as_ref(),
|
||||
antialias,
|
||||
);
|
||||
}
|
||||
Type::Circle => {
|
||||
let mut paint = representative.to_paint(&selrect, svg_attrs, antialias);
|
||||
paint.set_shader(merged.shader());
|
||||
draw_stroke_on_circle(
|
||||
canvas,
|
||||
representative,
|
||||
&selrect,
|
||||
&paint,
|
||||
scale,
|
||||
None,
|
||||
blur_filter.as_ref(),
|
||||
antialias,
|
||||
);
|
||||
}
|
||||
Type::Text(_) => {}
|
||||
shape_type @ (Type::Path(_) | Type::Bool(_)) => {
|
||||
if let Some(path) = shape_type.path() {
|
||||
let is_open = path.is_open();
|
||||
let mut paint =
|
||||
representative.to_stroked_paint(is_open, &selrect, svg_attrs, antialias);
|
||||
paint.set_shader(merged.shader());
|
||||
draw_stroke_on_path(
|
||||
canvas,
|
||||
representative,
|
||||
path,
|
||||
&paint,
|
||||
path_transform.as_ref(),
|
||||
None,
|
||||
blur_filter.as_ref(),
|
||||
antialias,
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => unreachable!("This shape should not have strokes"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a single stroke. Used by the shadow module which needs per-stroke
|
||||
/// shadow filters.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn render_single(
|
||||
render_state: &mut RenderState,
|
||||
shape: &Shape,
|
||||
stroke: &Stroke,
|
||||
@@ -518,7 +748,7 @@ pub fn render(
|
||||
shadow: Option<&ImageFilter>,
|
||||
antialias: bool,
|
||||
) {
|
||||
render_internal(
|
||||
render_single_internal(
|
||||
render_state,
|
||||
shape,
|
||||
stroke,
|
||||
@@ -526,34 +756,12 @@ pub fn render(
|
||||
shadow,
|
||||
antialias,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Internal function to render a stroke with support for offscreen blur rendering.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `render_state`: The rendering state containing surfaces and context.
|
||||
/// - `shape`: The shape to render the stroke for.
|
||||
/// - `stroke`: The stroke configuration (width, fill, style, etc.).
|
||||
/// - `surface_id`: Optional target surface ID. Defaults to `SurfaceId::Strokes` if `None`.
|
||||
/// - `shadow`: Optional shadow filter to apply to the stroke.
|
||||
/// - `antialias`: Whether to use antialiasing for rendering.
|
||||
/// - `bypass_filter`:
|
||||
/// - If `false`, attempts to use offscreen filter surface for blur effects.
|
||||
/// - If `true`, renders directly to the target surface (used for recursive calls to avoid infinite loops when rendering into the filter surface).
|
||||
///
|
||||
/// # Behavior
|
||||
/// When `bypass_filter` is `false` and the shape has a blur filter:
|
||||
/// 1. Calculates bounds including stroke width and cap margins.
|
||||
/// 2. Attempts to render into an offscreen filter surface at unscaled coordinates.
|
||||
/// 3. If successful, composites the result back to the target surface and returns early.
|
||||
/// 4. If the offscreen render fails or `bypass_filter` is `true`, renders directly to the target
|
||||
/// surface using the appropriate drawing function for the shape type.
|
||||
///
|
||||
/// The recursive call with `bypass_filter=true` ensures that when rendering into the filter
|
||||
/// surface, we don't attempt to create another filter surface, avoiding infinite recursion.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_internal(
|
||||
fn render_single_internal(
|
||||
render_state: &mut RenderState,
|
||||
shape: &Shape,
|
||||
stroke: &Stroke,
|
||||
@@ -561,10 +769,10 @@ fn render_internal(
|
||||
shadow: Option<&ImageFilter>,
|
||||
antialias: bool,
|
||||
bypass_filter: bool,
|
||||
skip_blur: bool,
|
||||
) {
|
||||
if !bypass_filter {
|
||||
if let Some(image_filter) = shape.image_filter(1.) {
|
||||
// We have to calculate the bounds considering the stroke and the cap margins.
|
||||
let mut content_bounds = shape.selrect;
|
||||
let stroke_margin = stroke.bounds_width(shape.is_open());
|
||||
if stroke_margin > 0.0 {
|
||||
@@ -582,7 +790,7 @@ fn render_internal(
|
||||
bounds,
|
||||
target,
|
||||
|state, temp_surface| {
|
||||
render_internal(
|
||||
render_single_internal(
|
||||
state,
|
||||
shape,
|
||||
stroke,
|
||||
@@ -590,6 +798,7 @@ fn render_internal(
|
||||
shadow,
|
||||
antialias,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
},
|
||||
) {
|
||||
@@ -605,6 +814,12 @@ fn render_internal(
|
||||
let path_transform = shape.to_path_transform();
|
||||
let svg_attrs = shape.svg_attrs.as_ref();
|
||||
|
||||
let blur = if skip_blur {
|
||||
None
|
||||
} else {
|
||||
shape.image_filter(1.)
|
||||
};
|
||||
|
||||
if !matches!(shape.shape_type, Type::Text(_))
|
||||
&& shadow.is_none()
|
||||
&& matches!(stroke.fill, Fill::Image(_))
|
||||
@@ -622,42 +837,45 @@ fn render_internal(
|
||||
} else {
|
||||
match &shape.shape_type {
|
||||
shape_type @ (Type::Rect(_) | Type::Frame(_)) => {
|
||||
let paint = stroke.to_paint(&selrect, svg_attrs, antialias);
|
||||
draw_stroke_on_rect(
|
||||
canvas,
|
||||
stroke,
|
||||
&selrect,
|
||||
&selrect,
|
||||
&shape_type.corners(),
|
||||
svg_attrs,
|
||||
&paint,
|
||||
scale,
|
||||
shadow,
|
||||
shape.image_filter(1.).as_ref(),
|
||||
blur.as_ref(),
|
||||
antialias,
|
||||
);
|
||||
}
|
||||
Type::Circle => {
|
||||
let paint = stroke.to_paint(&selrect, svg_attrs, antialias);
|
||||
draw_stroke_on_circle(
|
||||
canvas,
|
||||
stroke,
|
||||
&selrect,
|
||||
&paint,
|
||||
scale,
|
||||
shadow,
|
||||
blur.as_ref(),
|
||||
antialias,
|
||||
);
|
||||
}
|
||||
Type::Circle => draw_stroke_on_circle(
|
||||
canvas,
|
||||
stroke,
|
||||
&selrect,
|
||||
&selrect,
|
||||
svg_attrs,
|
||||
scale,
|
||||
shadow,
|
||||
shape.image_filter(1.).as_ref(),
|
||||
antialias,
|
||||
),
|
||||
Type::Text(_) => {}
|
||||
shape_type @ (Type::Path(_) | Type::Bool(_)) => {
|
||||
if let Some(path) = shape_type.path() {
|
||||
let is_open = path.is_open();
|
||||
let paint = stroke.to_stroked_paint(is_open, &selrect, svg_attrs, antialias);
|
||||
draw_stroke_on_path(
|
||||
canvas,
|
||||
stroke,
|
||||
path,
|
||||
&selrect,
|
||||
&paint,
|
||||
path_transform.as_ref(),
|
||||
svg_attrs,
|
||||
shadow,
|
||||
shape.image_filter(1.).as_ref(),
|
||||
blur.as_ref(),
|
||||
antialias,
|
||||
);
|
||||
}
|
||||
|
||||
240
render-wasm/src/render/text_editor.rs
Normal file
@@ -0,0 +1,240 @@
|
||||
use crate::shapes::{Shape, TextContent, Type, VerticalAlign};
|
||||
use crate::state::{TextEditorState, TextSelection};
|
||||
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
|
||||
use skia_safe::{BlendMode, Canvas, Matrix, Paint, Rect};
|
||||
|
||||
pub fn render_overlay(
|
||||
canvas: &Canvas,
|
||||
editor_state: &TextEditorState,
|
||||
shape: &Shape,
|
||||
transform: &Matrix,
|
||||
) {
|
||||
if !editor_state.is_active {
|
||||
return;
|
||||
}
|
||||
|
||||
let Type::Text(text_content) = &shape.shape_type else {
|
||||
return;
|
||||
};
|
||||
|
||||
canvas.save();
|
||||
canvas.concat(transform);
|
||||
|
||||
if editor_state.selection.is_selection() {
|
||||
render_selection(canvas, editor_state, text_content, shape);
|
||||
}
|
||||
|
||||
if editor_state.cursor_visible {
|
||||
render_cursor(canvas, editor_state, text_content, shape);
|
||||
}
|
||||
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
fn render_cursor(
|
||||
canvas: &Canvas,
|
||||
editor_state: &TextEditorState,
|
||||
text_content: &TextContent,
|
||||
shape: &Shape,
|
||||
) {
|
||||
let Some(rect) = calculate_cursor_rect(editor_state, text_content, shape) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color(editor_state.theme.cursor_color);
|
||||
paint.set_anti_alias(true);
|
||||
|
||||
canvas.draw_rect(rect, &paint);
|
||||
}
|
||||
|
||||
fn render_selection(
|
||||
canvas: &Canvas,
|
||||
editor_state: &TextEditorState,
|
||||
text_content: &TextContent,
|
||||
shape: &Shape,
|
||||
) {
|
||||
let selection = &editor_state.selection;
|
||||
let rects = calculate_selection_rects(selection, text_content, shape);
|
||||
|
||||
if rects.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut paint = Paint::default();
|
||||
paint.set_blend_mode(BlendMode::Multiply);
|
||||
paint.set_color(editor_state.theme.selection_color);
|
||||
paint.set_anti_alias(true);
|
||||
for rect in rects {
|
||||
canvas.draw_rect(rect, &paint);
|
||||
}
|
||||
}
|
||||
|
||||
fn vertical_align_offset(
|
||||
shape: &Shape,
|
||||
layout_paragraphs: &[&skia_safe::textlayout::Paragraph],
|
||||
) -> f32 {
|
||||
let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum();
|
||||
match shape.vertical_align() {
|
||||
VerticalAlign::Center => (shape.selrect().height() - total_height) / 2.0,
|
||||
VerticalAlign::Bottom => shape.selrect().height() - total_height,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn calculate_cursor_rect(
|
||||
editor_state: &TextEditorState,
|
||||
text_content: &TextContent,
|
||||
shape: &Shape,
|
||||
) -> Option<Rect> {
|
||||
let cursor = editor_state.selection.focus;
|
||||
let paragraphs = text_content.paragraphs();
|
||||
if cursor.paragraph >= paragraphs.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
|
||||
|
||||
if cursor.paragraph >= layout_paragraphs.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let selrect = shape.selrect();
|
||||
|
||||
let mut y_offset = vertical_align_offset(shape, &layout_paragraphs);
|
||||
for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
|
||||
if idx == cursor.paragraph {
|
||||
let char_pos = cursor.char_offset;
|
||||
// For cursor, we get a zero-width range at the position
|
||||
// We need to handle edge cases:
|
||||
// - At start of paragraph: use position 0
|
||||
// - At end of paragraph: use last position
|
||||
let para = ¶graphs[cursor.paragraph];
|
||||
let para_char_count: usize = para
|
||||
.children()
|
||||
.iter()
|
||||
.map(|span| span.text.chars().count())
|
||||
.sum();
|
||||
|
||||
let (cursor_x, cursor_height) = if para_char_count == 0 {
|
||||
// Empty paragraph - use default height
|
||||
(0.0, laid_out_para.height())
|
||||
} else if char_pos == 0 {
|
||||
let rects = laid_out_para.get_rects_for_range(
|
||||
0..1,
|
||||
RectHeightStyle::Max,
|
||||
RectWidthStyle::Tight,
|
||||
);
|
||||
if !rects.is_empty() {
|
||||
(rects[0].rect.left(), rects[0].rect.height())
|
||||
} else {
|
||||
(0.0, laid_out_para.height())
|
||||
}
|
||||
} else if char_pos >= para_char_count {
|
||||
let rects = laid_out_para.get_rects_for_range(
|
||||
para_char_count.saturating_sub(1)..para_char_count,
|
||||
RectHeightStyle::Max,
|
||||
RectWidthStyle::Tight,
|
||||
);
|
||||
if !rects.is_empty() {
|
||||
(rects[0].rect.right(), rects[0].rect.height())
|
||||
} else {
|
||||
(laid_out_para.longest_line(), laid_out_para.height())
|
||||
}
|
||||
} else {
|
||||
let rects = laid_out_para.get_rects_for_range(
|
||||
char_pos..char_pos + 1,
|
||||
RectHeightStyle::Max,
|
||||
RectWidthStyle::Tight,
|
||||
);
|
||||
if !rects.is_empty() {
|
||||
(rects[0].rect.left(), rects[0].rect.height())
|
||||
} else {
|
||||
// Fallback: use glyph position
|
||||
let pos = laid_out_para.get_glyph_position_at_coordinate((0.0, 0.0));
|
||||
(pos.position as f32, laid_out_para.height())
|
||||
}
|
||||
};
|
||||
|
||||
return Some(Rect::from_xywh(
|
||||
selrect.x() + cursor_x,
|
||||
selrect.y() + y_offset,
|
||||
editor_state.theme.cursor_width,
|
||||
cursor_height,
|
||||
));
|
||||
}
|
||||
y_offset += laid_out_para.height();
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn calculate_selection_rects(
|
||||
selection: &TextSelection,
|
||||
text_content: &TextContent,
|
||||
shape: &Shape,
|
||||
) -> Vec<Rect> {
|
||||
let mut rects = Vec::new();
|
||||
|
||||
let start = selection.start();
|
||||
let end = selection.end();
|
||||
|
||||
let paragraphs = text_content.paragraphs();
|
||||
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
|
||||
|
||||
let selrect = shape.selrect();
|
||||
let mut y_offset = vertical_align_offset(shape, &layout_paragraphs);
|
||||
|
||||
for (para_idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
|
||||
let para_height = laid_out_para.height();
|
||||
|
||||
// Check if this paragraph is in selection range
|
||||
if para_idx < start.paragraph || para_idx > end.paragraph {
|
||||
y_offset += para_height;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate character range for this paragraph
|
||||
let para = ¶graphs[para_idx];
|
||||
let para_char_count: usize = para
|
||||
.children()
|
||||
.iter()
|
||||
.map(|span| span.text.chars().count())
|
||||
.sum();
|
||||
|
||||
let range_start = if para_idx == start.paragraph {
|
||||
start.char_offset
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let range_end = if para_idx == end.paragraph {
|
||||
end.char_offset
|
||||
} else {
|
||||
para_char_count
|
||||
};
|
||||
|
||||
if range_start < range_end {
|
||||
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
|
||||
let text_boxes = laid_out_para.get_rects_for_range(
|
||||
range_start..range_end,
|
||||
RectHeightStyle::Max,
|
||||
RectWidthStyle::Tight,
|
||||
);
|
||||
|
||||
for text_box in text_boxes {
|
||||
let r = text_box.rect;
|
||||
rects.push(Rect::from_xywh(
|
||||
selrect.x() + r.left(),
|
||||
selrect.y() + y_offset + r.top(),
|
||||
r.width(),
|
||||
r.height(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
y_offset += para_height;
|
||||
}
|
||||
|
||||
rects
|
||||
}
|
||||
@@ -620,6 +620,7 @@ impl Shape {
|
||||
(added, removed)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn fills(&self) -> std::slice::Iter<'_, Fill> {
|
||||
self.fills.iter()
|
||||
}
|
||||
@@ -1119,6 +1120,28 @@ impl Shape {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns children in forward (non-reversed) order - useful for layout calculations
|
||||
pub fn children_ids_iter_forward(
|
||||
&self,
|
||||
include_hidden: bool,
|
||||
) -> Box<dyn Iterator<Item = &Uuid> + '_> {
|
||||
if include_hidden {
|
||||
return Box::new(self.children.iter());
|
||||
}
|
||||
|
||||
if let Type::Bool(_) = self.shape_type {
|
||||
Box::new([].iter())
|
||||
} else if let Type::Group(group) = self.shape_type {
|
||||
if group.masked {
|
||||
Box::new(self.children.iter().skip(1))
|
||||
} else {
|
||||
Box::new(self.children.iter())
|
||||
}
|
||||
} else {
|
||||
Box::new(self.children.iter())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_children(
|
||||
&self,
|
||||
shapes: ShapesPoolRef,
|
||||
|
||||
@@ -241,10 +241,14 @@ pub fn merge_fills(fills: &[Fill], bounding_box: Rect) -> skia::Paint {
|
||||
|
||||
if let Some(shader) = shader {
|
||||
combined_shader = match combined_shader {
|
||||
// Use SrcOver and treat the newly encountered fill as the source (top),
|
||||
// overlaying it over the previously composed shader (destination/bottom).
|
||||
// This avoids edge bleed from underlying fills when anti-aliasing causes
|
||||
// fractional coverage at shape boundaries.
|
||||
Some(existing_shader) => Some(skia::shaders::blend(
|
||||
skia::Blender::mode(skia::BlendMode::DstOver),
|
||||
existing_shader,
|
||||
skia::Blender::mode(skia::BlendMode::SrcOver),
|
||||
shader,
|
||||
existing_shader,
|
||||
)),
|
||||
None => Some(shader),
|
||||
};
|
||||
|
||||
@@ -300,7 +300,20 @@ fn propagate_reflow(
|
||||
Type::Frame(Frame {
|
||||
layout: Some(_), ..
|
||||
}) => {
|
||||
layout_reflows.insert(*id);
|
||||
let mut skip_reflow = false;
|
||||
if shape.is_layout_horizontal_fill() || shape.is_layout_vertical_fill() {
|
||||
if let Some(parent_id) = shape.parent_id {
|
||||
if parent_id != Uuid::nil() && !reflown.contains(&parent_id) {
|
||||
// If this is a fill layout but the parent has not been reflown yet
|
||||
// we wait for the next iteration for reflow
|
||||
skip_reflow = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !skip_reflow {
|
||||
layout_reflows.insert(*id);
|
||||
}
|
||||
}
|
||||
Type::Group(Group { masked: true }) => {
|
||||
let children_ids = shape.children_ids(true);
|
||||
@@ -417,28 +430,26 @@ pub fn propagate_modifiers(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut layout_reflows_vec: Vec<Uuid> = layout_reflows.into_iter().collect();
|
||||
|
||||
// We sort the reflows so they are process first the ones that are more
|
||||
// deep in the tree structure. This way we can be sure that the children layouts
|
||||
// are already reflowed.
|
||||
// We sort the reflows so they are processed deepest-first in the
|
||||
// tree structure. This way we can be sure that the children layouts
|
||||
// are already reflowed before their parents.
|
||||
let mut layout_reflows_vec: Vec<Uuid> =
|
||||
std::mem::take(&mut layout_reflows).into_iter().collect();
|
||||
layout_reflows_vec.sort_unstable_by(|id_a, id_b| {
|
||||
let da = shapes.get_depth(id_a);
|
||||
let db = shapes.get_depth(id_b);
|
||||
db.cmp(&da)
|
||||
});
|
||||
|
||||
let mut bounds_temp = bounds.clone();
|
||||
for id in layout_reflows_vec.iter() {
|
||||
for id in &layout_reflows_vec {
|
||||
if reflown.contains(id) {
|
||||
continue;
|
||||
}
|
||||
reflow_shape(id, state, &mut reflown, &mut entries, &mut bounds_temp);
|
||||
reflow_shape(id, state, &mut reflown, &mut entries, &mut bounds);
|
||||
}
|
||||
layout_reflows = HashSet::new();
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
modifiers
|
||||
.iter()
|
||||
.map(|(key, val)| TransformEntry::from_input(*key, *val))
|
||||
|
||||
@@ -184,15 +184,18 @@ fn initialize_tracks(
|
||||
) -> Vec<TrackData> {
|
||||
let mut tracks = Vec::<TrackData>::new();
|
||||
let mut current_track = TrackData::default();
|
||||
let mut children = shape.children_ids(true);
|
||||
let mut first = true;
|
||||
|
||||
if flex_data.is_reverse() {
|
||||
children.reverse();
|
||||
}
|
||||
// When is_reverse() is true, we need forward order (children_ids_iter_forward).
|
||||
// When is_reverse() is false, we need reversed order (children_ids_iter).
|
||||
let children_iter: Box<dyn Iterator<Item = Uuid>> = if flex_data.is_reverse() {
|
||||
Box::new(shape.children_ids_iter_forward(true).copied())
|
||||
} else {
|
||||
Box::new(shape.children_ids_iter(true).copied())
|
||||
};
|
||||
|
||||
for child_id in children.iter() {
|
||||
let Some(child) = shapes.get(child_id) else {
|
||||
for child_id in children_iter {
|
||||
let Some(child) = shapes.get(&child_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -293,7 +296,7 @@ fn distribute_fill_main_space(layout_axis: &LayoutAxis, tracks: &mut [TrackData]
|
||||
track.main_size += delta;
|
||||
|
||||
if (child.main_size - child.max_main_size).abs() < MIN_SIZE {
|
||||
to_resize_children.remove(i);
|
||||
to_resize_children.swap_remove(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,7 +333,7 @@ fn distribute_fill_across_space(layout_axis: &LayoutAxis, tracks: &mut [TrackDat
|
||||
left_space -= delta;
|
||||
|
||||
if (track.across_size - track.max_across_size).abs() < MIN_SIZE {
|
||||
to_resize_tracks.remove(i);
|
||||
to_resize_tracks.swap_remove(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::shapes::{
|
||||
};
|
||||
use crate::state::ShapesPoolRef;
|
||||
use crate::uuid::Uuid;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
|
||||
use super::common::GetBounds;
|
||||
|
||||
@@ -537,7 +537,7 @@ fn cell_bounds(
|
||||
|
||||
pub fn create_cell_data<'a>(
|
||||
layout_bounds: &Bounds,
|
||||
children: &[Uuid],
|
||||
children: &HashSet<Uuid>,
|
||||
shapes: ShapesPoolRef<'a>,
|
||||
cells: &Vec<GridCell>,
|
||||
column_tracks: &[TrackData],
|
||||
@@ -614,7 +614,7 @@ pub fn grid_cell_data<'a>(
|
||||
|
||||
let bounds = &mut HashMap::<Uuid, Bounds>::new();
|
||||
let layout_bounds = shape.bounds();
|
||||
let children = shape.children_ids(false);
|
||||
let children: HashSet<Uuid> = shape.children_ids_iter(false).copied().collect();
|
||||
|
||||
let column_tracks = calculate_tracks(
|
||||
true,
|
||||
@@ -707,7 +707,7 @@ pub fn reflow_grid_layout(
|
||||
) -> VecDeque<Modifier> {
|
||||
let mut result = VecDeque::new();
|
||||
let layout_bounds = bounds.find(shape);
|
||||
let children = shape.children_ids(true);
|
||||
let children: HashSet<Uuid> = shape.children_ids_iter(true).copied().collect();
|
||||
|
||||
let column_tracks = calculate_tracks(
|
||||
true,
|
||||
|
||||
@@ -119,6 +119,19 @@ impl Stroke {
|
||||
self.width *= value;
|
||||
}
|
||||
|
||||
/// Returns the clip operation for dotted inner/outer strokes.
|
||||
/// Returns `None` when no clipping is needed (center or non-dotted).
|
||||
pub fn clip_op(&self) -> Option<skia::ClipOp> {
|
||||
if self.style != StrokeStyle::Dotted || self.kind == StrokeKind::Center {
|
||||
return None;
|
||||
}
|
||||
match self.kind {
|
||||
StrokeKind::Inner => Some(skia::ClipOp::Intersect),
|
||||
StrokeKind::Outer => Some(skia::ClipOp::Difference),
|
||||
StrokeKind::Center => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delta(&self) -> f32 {
|
||||
match self.kind {
|
||||
StrokeKind::Inner => 0.,
|
||||
@@ -128,20 +141,28 @@ impl Stroke {
|
||||
}
|
||||
|
||||
pub fn outer_rect(&self, rect: &Rect) -> Rect {
|
||||
match self.kind {
|
||||
StrokeKind::Inner => Rect::from_xywh(
|
||||
rect.left + (self.width / 2.),
|
||||
rect.top + (self.width / 2.),
|
||||
rect.width() - self.width,
|
||||
rect.height() - self.width,
|
||||
),
|
||||
StrokeKind::Center => Rect::from_xywh(rect.left, rect.top, rect.width(), rect.height()),
|
||||
StrokeKind::Outer => Rect::from_xywh(
|
||||
rect.left - (self.width / 2.),
|
||||
rect.top - (self.width / 2.),
|
||||
rect.width() + self.width,
|
||||
rect.height() + self.width,
|
||||
),
|
||||
match (self.kind, self.style) {
|
||||
(StrokeKind::Inner, StrokeStyle::Dotted) | (StrokeKind::Outer, StrokeStyle::Dotted) => {
|
||||
// Boundary so circles center on it and semicircles match after clipping
|
||||
*rect
|
||||
}
|
||||
_ => match self.kind {
|
||||
StrokeKind::Inner => Rect::from_xywh(
|
||||
rect.left + (self.width / 2.),
|
||||
rect.top + (self.width / 2.),
|
||||
rect.width() - self.width,
|
||||
rect.height() - self.width,
|
||||
),
|
||||
StrokeKind::Center => {
|
||||
Rect::from_xywh(rect.left, rect.top, rect.width(), rect.height())
|
||||
}
|
||||
StrokeKind::Outer => Rect::from_xywh(
|
||||
rect.left - (self.width / 2.),
|
||||
rect.top - (self.width / 2.),
|
||||
rect.width() + self.width,
|
||||
rect.height() + self.width,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +176,11 @@ impl Stroke {
|
||||
}
|
||||
|
||||
pub fn outer_corners(&self, corners: &Corners) -> Corners {
|
||||
if matches!(self.style, StrokeStyle::Dotted | StrokeStyle::Dashed) {
|
||||
// Path at boundary so no corner offset
|
||||
return *corners;
|
||||
}
|
||||
|
||||
let offset = match self.kind {
|
||||
StrokeKind::Center => 0.0,
|
||||
StrokeKind::Inner => -self.width / 2.0,
|
||||
|
||||
@@ -116,6 +116,7 @@ impl TextContentSize {
|
||||
pub struct TextPositionWithAffinity {
|
||||
pub position_with_affinity: PositionWithAffinity,
|
||||
pub paragraph: i32,
|
||||
#[allow(dead_code)]
|
||||
pub span: i32,
|
||||
pub offset: i32,
|
||||
}
|
||||
@@ -316,6 +317,10 @@ impl TextContent {
|
||||
&self.paragraphs
|
||||
}
|
||||
|
||||
pub fn paragraphs_mut(&mut self) -> &mut Vec<Paragraph> {
|
||||
&mut self.paragraphs
|
||||
}
|
||||
|
||||
pub fn width(&self) -> f32 {
|
||||
self.size.width
|
||||
}
|
||||
@@ -428,8 +433,16 @@ impl TextContent {
|
||||
let end_y = offset_y + layout_paragraph.height();
|
||||
|
||||
// We only test against paragraphs that can contain the current y
|
||||
// coordinate.
|
||||
if point.y > start_y && point.y < end_y {
|
||||
// coordinate. Use >= for start and handle zero-height paragraphs.
|
||||
let paragraph_height = layout_paragraph.height();
|
||||
let matches = if paragraph_height > 0.0 {
|
||||
point.y >= start_y && point.y < end_y
|
||||
} else {
|
||||
// For zero-height paragraphs (empty lines), match if we're at the start position
|
||||
point.y >= start_y && point.y <= start_y + 1.0
|
||||
};
|
||||
|
||||
if matches {
|
||||
let position_with_affinity =
|
||||
layout_paragraph.get_glyph_position_at_coordinate(*point);
|
||||
if let Some(paragraph) = self.paragraphs().get(paragraph_index as usize) {
|
||||
@@ -438,18 +451,37 @@ impl TextContent {
|
||||
// in which span we are.
|
||||
let mut computed_position = 0;
|
||||
let mut span_offset = 0;
|
||||
for span in paragraph.children() {
|
||||
span_index += 1;
|
||||
let length = span.text.len();
|
||||
let start_position = computed_position;
|
||||
let end_position = computed_position + length;
|
||||
let current_position = position_with_affinity.position as usize;
|
||||
if start_position <= current_position && end_position >= current_position {
|
||||
span_offset = position_with_affinity.position - start_position as i32;
|
||||
break;
|
||||
|
||||
// If paragraph has no spans, default to span 0, offset 0
|
||||
if paragraph.children().is_empty() {
|
||||
span_index = 0;
|
||||
span_offset = 0;
|
||||
} else {
|
||||
for span in paragraph.children() {
|
||||
span_index += 1;
|
||||
let length = span.text.chars().count();
|
||||
let start_position = computed_position;
|
||||
let end_position = computed_position + length;
|
||||
let current_position = position_with_affinity.position as usize;
|
||||
|
||||
// Handle empty spans: if the span is empty and current position
|
||||
// matches the start, this is the right span
|
||||
if length == 0 && current_position == start_position {
|
||||
span_offset = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
if start_position <= current_position
|
||||
&& end_position >= current_position
|
||||
{
|
||||
span_offset =
|
||||
position_with_affinity.position - start_position as i32;
|
||||
break;
|
||||
}
|
||||
computed_position += length;
|
||||
}
|
||||
computed_position += length;
|
||||
}
|
||||
|
||||
return Some(TextPositionWithAffinity::new(
|
||||
position_with_affinity,
|
||||
paragraph_index,
|
||||
@@ -460,6 +492,26 @@ impl TextContent {
|
||||
}
|
||||
offset_y += layout_paragraph.height();
|
||||
}
|
||||
|
||||
// Handle completely empty text shapes: if there are no paragraphs or all paragraphs
|
||||
// are empty, and the click is within the text shape bounds, return a default position
|
||||
if (self.paragraphs().is_empty() || self.layout.paragraphs.is_empty())
|
||||
&& self.bounds.contains(*point)
|
||||
{
|
||||
// Create a default position at the start of the text
|
||||
use skia_safe::textlayout::Affinity;
|
||||
let default_position = PositionWithAffinity {
|
||||
position: 0,
|
||||
affinity: Affinity::Downstream,
|
||||
};
|
||||
return Some(TextPositionWithAffinity::new(
|
||||
default_position,
|
||||
0, // paragraph 0
|
||||
0, // span 0
|
||||
0, // offset 0
|
||||
));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
@@ -838,6 +890,10 @@ impl Paragraph {
|
||||
&self.children
|
||||
}
|
||||
|
||||
pub fn children_mut(&mut self) -> &mut Vec<TextSpan> {
|
||||
&mut self.children
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn add_span(&mut self, span: TextSpan) {
|
||||
self.children.push(span);
|
||||
@@ -847,6 +903,26 @@ impl Paragraph {
|
||||
self.line_height
|
||||
}
|
||||
|
||||
pub fn letter_spacing(&self) -> f32 {
|
||||
self.letter_spacing
|
||||
}
|
||||
|
||||
pub fn text_align(&self) -> TextAlign {
|
||||
self.text_align
|
||||
}
|
||||
|
||||
pub fn text_direction(&self) -> TextDirection {
|
||||
self.text_direction
|
||||
}
|
||||
|
||||
pub fn text_decoration(&self) -> Option<TextDecoration> {
|
||||
self.text_decoration
|
||||
}
|
||||
|
||||
pub fn text_transform(&self) -> Option<TextTransform> {
|
||||
self.text_transform
|
||||
}
|
||||
|
||||
pub fn paragraph_to_style(&self) -> ParagraphStyle {
|
||||
let mut style = ParagraphStyle::default();
|
||||
|
||||
@@ -1228,14 +1304,21 @@ pub fn calculate_text_layout_data(
|
||||
let current_y = para_layout.y;
|
||||
let text_paragraph = text_paragraphs.get(paragraph_index);
|
||||
if let Some(text_para) = text_paragraph {
|
||||
let mut span_ranges: Vec<(usize, usize, usize)> = vec![];
|
||||
let mut span_ranges: Vec<(usize, usize, usize, String, String)> = vec![];
|
||||
let mut cur = 0;
|
||||
for (span_index, span) in text_para.children().iter().enumerate() {
|
||||
let text: String = span.apply_text_transform();
|
||||
span_ranges.push((cur, cur + text.len(), span_index));
|
||||
cur += text.len();
|
||||
let transformed_text: String = span.apply_text_transform();
|
||||
let original_text = span.text.clone();
|
||||
let text = transformed_text.clone();
|
||||
let text_len = text.len();
|
||||
span_ranges.push((cur, cur + text_len, span_index, text, original_text));
|
||||
cur += text_len;
|
||||
}
|
||||
for (start, end, span_index) in span_ranges {
|
||||
for (start, end, span_index, transformed_text, original_text) in span_ranges {
|
||||
// Skip empty spans to avoid invalid rect calculations
|
||||
if start >= end {
|
||||
continue;
|
||||
}
|
||||
let rects = para_layout.paragraph.get_rects_for_range(
|
||||
start..end,
|
||||
RectHeightStyle::Tight,
|
||||
@@ -1245,22 +1328,43 @@ pub fn calculate_text_layout_data(
|
||||
let direction = textbox.direct;
|
||||
let mut rect = textbox.rect;
|
||||
let cy = rect.top + rect.height() / 2.0;
|
||||
let start_pos = para_layout
|
||||
|
||||
// Get byte positions from Skia's transformed text layout
|
||||
let glyph_start = para_layout
|
||||
.paragraph
|
||||
.get_glyph_position_at_coordinate((rect.left + 0.1, cy))
|
||||
.position as usize;
|
||||
let end_pos = para_layout
|
||||
let glyph_end = para_layout
|
||||
.paragraph
|
||||
.get_glyph_position_at_coordinate((rect.right - 0.1, cy))
|
||||
.position as usize;
|
||||
let start_pos = start_pos.saturating_sub(start);
|
||||
let end_pos = end_pos.saturating_sub(start);
|
||||
|
||||
// Convert to byte positions relative to this span
|
||||
let byte_start = glyph_start.saturating_sub(start);
|
||||
let byte_end = glyph_end.saturating_sub(start);
|
||||
|
||||
// Convert byte positions to character positions in ORIGINAL text
|
||||
// This handles multi-byte UTF-8 and text transform differences
|
||||
let char_start = transformed_text
|
||||
.char_indices()
|
||||
.position(|(i, _)| i >= byte_start)
|
||||
.unwrap_or(0);
|
||||
let char_end = transformed_text
|
||||
.char_indices()
|
||||
.position(|(i, _)| i >= byte_end)
|
||||
.unwrap_or_else(|| transformed_text.chars().count());
|
||||
|
||||
// Clamp to original text length for safety
|
||||
let original_char_count = original_text.chars().count();
|
||||
let final_start = char_start.min(original_char_count);
|
||||
let final_end = char_end.min(original_char_count);
|
||||
|
||||
rect.offset((x, current_y));
|
||||
position_data.push(PositionData {
|
||||
paragraph: paragraph_index as u32,
|
||||
span: span_index as u32,
|
||||
start_pos: start_pos as u32,
|
||||
end_pos: end_pos as u32,
|
||||
start_pos: final_start as u32,
|
||||
end_pos: final_end as u32,
|
||||
x: rect.x(),
|
||||
y: rect.y(),
|
||||
width: rect.width(),
|
||||
|
||||
@@ -1,9 +1,226 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::shapes::TextPositionWithAffinity;
|
||||
use crate::uuid::Uuid;
|
||||
use skia_safe::Color;
|
||||
|
||||
/// TODO: Now this is just a tuple with 2 i32 working
|
||||
/// as indices (paragraph and span).
|
||||
/// Cursor position within text content.
|
||||
/// Uses character offsets for precise positioning.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)]
|
||||
pub struct TextCursor {
|
||||
pub paragraph: usize,
|
||||
pub char_offset: usize,
|
||||
}
|
||||
|
||||
impl TextCursor {
|
||||
pub fn new(paragraph: usize, char_offset: usize) -> Self {
|
||||
Self {
|
||||
paragraph,
|
||||
char_offset,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn zero() -> Self {
|
||||
Self {
|
||||
paragraph: 0,
|
||||
char_offset: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct TextSelection {
|
||||
pub anchor: TextCursor,
|
||||
pub focus: TextCursor,
|
||||
}
|
||||
|
||||
impl TextSelection {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn from_cursor(cursor: TextCursor) -> Self {
|
||||
Self {
|
||||
anchor: cursor,
|
||||
focus: cursor,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_collapsed(&self) -> bool {
|
||||
self.anchor == self.focus
|
||||
}
|
||||
|
||||
pub fn is_selection(&self) -> bool {
|
||||
!self.is_collapsed()
|
||||
}
|
||||
|
||||
pub fn set_caret(&mut self, cursor: TextCursor) {
|
||||
self.anchor = cursor;
|
||||
self.focus = cursor;
|
||||
}
|
||||
|
||||
pub fn extend_to(&mut self, cursor: TextCursor) {
|
||||
self.focus = cursor;
|
||||
}
|
||||
|
||||
pub fn collapse_to_focus(&mut self) {
|
||||
self.anchor = self.focus;
|
||||
}
|
||||
|
||||
pub fn collapse_to_anchor(&mut self) {
|
||||
self.focus = self.anchor;
|
||||
}
|
||||
|
||||
pub fn start(&self) -> TextCursor {
|
||||
if self.anchor.paragraph < self.focus.paragraph {
|
||||
self.anchor
|
||||
} else if self.anchor.paragraph > self.focus.paragraph {
|
||||
self.focus
|
||||
} else if self.anchor.char_offset <= self.focus.char_offset {
|
||||
self.anchor
|
||||
} else {
|
||||
self.focus
|
||||
}
|
||||
}
|
||||
|
||||
pub fn end(&self) -> TextCursor {
|
||||
if self.anchor.paragraph > self.focus.paragraph {
|
||||
self.anchor
|
||||
} else if self.anchor.paragraph < self.focus.paragraph {
|
||||
self.focus
|
||||
} else if self.anchor.char_offset >= self.focus.char_offset {
|
||||
self.anchor
|
||||
} else {
|
||||
self.focus
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Events that the text editor can emit for frontend synchronization
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum EditorEvent {
|
||||
None = 0,
|
||||
ContentChanged = 1,
|
||||
SelectionChanged = 2,
|
||||
NeedsLayout = 3,
|
||||
}
|
||||
|
||||
/// FIXME: It should be better to get these constants from the frontend through the API.
|
||||
const SELECTION_COLOR: Color = Color::from_argb(255, 0, 209, 184);
|
||||
const CURSOR_WIDTH: f32 = 1.5;
|
||||
const CURSOR_COLOR: Color = Color::BLACK;
|
||||
const CURSOR_BLINK_INTERVAL_MS: f64 = 530.0;
|
||||
|
||||
pub struct TextEditorTheme {
|
||||
pub selection_color: Color,
|
||||
pub cursor_width: f32,
|
||||
pub cursor_color: Color,
|
||||
}
|
||||
|
||||
pub struct TextEditorState {
|
||||
pub theme: TextEditorTheme,
|
||||
pub selection: TextSelection,
|
||||
pub is_active: bool,
|
||||
pub active_shape_id: Option<Uuid>,
|
||||
pub cursor_visible: bool,
|
||||
pub last_blink_time: f64,
|
||||
pending_events: Vec<EditorEvent>,
|
||||
}
|
||||
|
||||
impl TextEditorState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
theme: TextEditorTheme {
|
||||
selection_color: SELECTION_COLOR,
|
||||
cursor_width: CURSOR_WIDTH,
|
||||
cursor_color: CURSOR_COLOR,
|
||||
},
|
||||
selection: TextSelection::new(),
|
||||
is_active: false,
|
||||
active_shape_id: None,
|
||||
cursor_visible: true,
|
||||
last_blink_time: 0.0,
|
||||
pending_events: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(&mut self, shape_id: Uuid) {
|
||||
self.is_active = true;
|
||||
self.active_shape_id = Some(shape_id);
|
||||
self.cursor_visible = true;
|
||||
self.last_blink_time = 0.0;
|
||||
self.selection = TextSelection::new();
|
||||
self.pending_events.clear();
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) {
|
||||
self.is_active = false;
|
||||
self.active_shape_id = None;
|
||||
self.cursor_visible = false;
|
||||
self.pending_events.clear();
|
||||
}
|
||||
|
||||
pub fn set_caret_from_position(&mut self, position: TextPositionWithAffinity) {
|
||||
let cursor = TextCursor::new(position.paragraph as usize, position.offset as usize);
|
||||
self.selection.set_caret(cursor);
|
||||
self.reset_blink();
|
||||
self.push_event(EditorEvent::SelectionChanged);
|
||||
}
|
||||
|
||||
pub fn extend_selection_from_position(&mut self, position: TextPositionWithAffinity) {
|
||||
let cursor = TextCursor::new(position.paragraph as usize, position.offset as usize);
|
||||
self.selection.extend_to(cursor);
|
||||
self.reset_blink();
|
||||
self.push_event(EditorEvent::SelectionChanged);
|
||||
}
|
||||
|
||||
pub fn update_blink(&mut self, timestamp_ms: f64) {
|
||||
if !self.is_active {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.last_blink_time == 0.0 {
|
||||
self.last_blink_time = timestamp_ms;
|
||||
self.cursor_visible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let elapsed = timestamp_ms - self.last_blink_time;
|
||||
if elapsed >= CURSOR_BLINK_INTERVAL_MS {
|
||||
self.cursor_visible = !self.cursor_visible;
|
||||
self.last_blink_time = timestamp_ms;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset_blink(&mut self) {
|
||||
self.cursor_visible = true;
|
||||
self.last_blink_time = 0.0;
|
||||
}
|
||||
|
||||
pub fn push_event(&mut self, event: EditorEvent) {
|
||||
if self.pending_events.last() != Some(&event) {
|
||||
self.pending_events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn poll_event(&mut self) -> EditorEvent {
|
||||
self.pending_events.pop().unwrap_or(EditorEvent::None)
|
||||
}
|
||||
|
||||
pub fn has_pending_events(&self) -> bool {
|
||||
!self.pending_events.is_empty()
|
||||
}
|
||||
|
||||
pub fn set_caret_position_from(
|
||||
&mut self,
|
||||
text_position_with_affinity: TextPositionWithAffinity,
|
||||
) {
|
||||
self.set_caret_from_position(text_position_with_affinity);
|
||||
}
|
||||
}
|
||||
|
||||
/// TODO: Remove legacy code
|
||||
#[derive(Debug, PartialEq, Clone, Copy)]
|
||||
pub struct TextNodePosition {
|
||||
pub paragraph: i32,
|
||||
@@ -15,89 +232,7 @@ impl TextNodePosition {
|
||||
Self { paragraph, span }
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_invalid(&self) -> bool {
|
||||
self.paragraph < 0 || self.span < 0
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TextPosition {
|
||||
node: Option<TextNodePosition>,
|
||||
offset: i32,
|
||||
}
|
||||
|
||||
impl TextPosition {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
node: None,
|
||||
offset: -1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set(&mut self, node: Option<TextNodePosition>, offset: i32) {
|
||||
self.node = node;
|
||||
self.offset = offset;
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TextSelection {
|
||||
focus: TextPosition,
|
||||
anchor: TextPosition,
|
||||
}
|
||||
|
||||
impl TextSelection {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
focus: TextPosition::new(),
|
||||
anchor: TextPosition::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_caret(&self) -> bool {
|
||||
self.focus.node == self.anchor.node && self.focus.offset == self.anchor.offset
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_selection(&self) -> bool {
|
||||
!self.is_caret()
|
||||
}
|
||||
|
||||
pub fn set_focus(&mut self, node: Option<TextNodePosition>, offset: i32) {
|
||||
self.focus.set(node, offset);
|
||||
}
|
||||
|
||||
pub fn set_anchor(&mut self, node: Option<TextNodePosition>, offset: i32) {
|
||||
self.anchor.set(node, offset);
|
||||
}
|
||||
|
||||
pub fn set(&mut self, node: Option<TextNodePosition>, offset: i32) {
|
||||
self.set_focus(node, offset);
|
||||
self.set_anchor(node, offset);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TextEditorState {
|
||||
selection: TextSelection,
|
||||
}
|
||||
|
||||
impl TextEditorState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
selection: TextSelection::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_caret_position_from(
|
||||
&mut self,
|
||||
text_position_with_affinity: TextPositionWithAffinity,
|
||||
) {
|
||||
self.selection.set(
|
||||
Some(TextNodePosition::new(
|
||||
text_position_with_affinity.paragraph,
|
||||
text_position_with_affinity.span,
|
||||
)),
|
||||
text_position_with_affinity.offset,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,16 +209,19 @@ impl PendingTiles {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces) {
|
||||
self.list.clear();
|
||||
|
||||
let columns = tile_viewbox.interest_rect.width();
|
||||
let rows = tile_viewbox.interest_rect.height();
|
||||
|
||||
// Generate tiles in spiral order from center
|
||||
fn generate_spiral(rect: &TileRect) -> Vec<Tile> {
|
||||
let columns = rect.width();
|
||||
let rows = rect.height();
|
||||
let total = columns * rows;
|
||||
|
||||
let mut cx = tile_viewbox.interest_rect.center_x();
|
||||
let mut cy = tile_viewbox.interest_rect.center_y();
|
||||
if total <= 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut result = Vec::with_capacity(total as usize);
|
||||
let mut cx = rect.center_x();
|
||||
let mut cy = rect.center_y();
|
||||
|
||||
let ratio = (columns as f32 / rows as f32).ceil() as i32;
|
||||
|
||||
@@ -228,7 +231,7 @@ impl PendingTiles {
|
||||
let mut direction = 0;
|
||||
let mut current = 0;
|
||||
|
||||
self.list.push(Tile(cx, cy));
|
||||
result.push(Tile(cx, cy));
|
||||
while current < total {
|
||||
match direction {
|
||||
0 => cx += 1,
|
||||
@@ -238,7 +241,7 @@ impl PendingTiles {
|
||||
_ => unreachable!("Invalid direction"),
|
||||
}
|
||||
|
||||
self.list.push(Tile(cx, cy));
|
||||
result.push(Tile(cx, cy));
|
||||
|
||||
direction_current += 1;
|
||||
let direction_total = if direction % 2 == 0 {
|
||||
@@ -258,18 +261,44 @@ impl PendingTiles {
|
||||
}
|
||||
current += 1;
|
||||
}
|
||||
self.list.reverse();
|
||||
result.reverse();
|
||||
result
|
||||
}
|
||||
|
||||
// Create a new list where the cached tiles go first
|
||||
let iter1 = self
|
||||
.list
|
||||
.iter()
|
||||
.filter(|t| surfaces.has_cached_tile_surface(**t));
|
||||
let iter2 = self
|
||||
.list
|
||||
.iter()
|
||||
.filter(|t| !surfaces.has_cached_tile_surface(**t));
|
||||
self.list = iter1.chain(iter2).copied().collect();
|
||||
pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces) {
|
||||
self.list.clear();
|
||||
|
||||
// Generate spiral for the interest area (viewport + margin)
|
||||
let spiral = Self::generate_spiral(&tile_viewbox.interest_rect);
|
||||
|
||||
// Partition tiles into 4 priority groups (highest priority = processed last due to pop()):
|
||||
// 1. visible + cached (fastest - just blit from cache)
|
||||
// 2. visible + uncached (user sees these, render next)
|
||||
// 3. interest + cached (pre-rendered area, blit from cache)
|
||||
// 4. interest + uncached (lowest priority - background pre-render)
|
||||
let mut visible_cached = Vec::new();
|
||||
let mut visible_uncached = Vec::new();
|
||||
let mut interest_cached = Vec::new();
|
||||
let mut interest_uncached = Vec::new();
|
||||
|
||||
for tile in spiral {
|
||||
let is_visible = tile_viewbox.visible_rect.contains(&tile);
|
||||
let is_cached = surfaces.has_cached_tile_surface(tile);
|
||||
|
||||
match (is_visible, is_cached) {
|
||||
(true, true) => visible_cached.push(tile),
|
||||
(true, false) => visible_uncached.push(tile),
|
||||
(false, true) => interest_cached.push(tile),
|
||||
(false, false) => interest_uncached.push(tile),
|
||||
}
|
||||
}
|
||||
|
||||
// Build final list with lowest priority first (they get popped last)
|
||||
// Order: interest_uncached, interest_cached, visible_uncached, visible_cached
|
||||
self.list.extend(interest_uncached);
|
||||
self.list.extend(interest_cached);
|
||||
self.list.extend(visible_uncached);
|
||||
self.list.extend(visible_cached);
|
||||
}
|
||||
|
||||
pub fn pop(&mut self) -> Option<Tile> {
|
||||
|
||||
@@ -9,3 +9,4 @@ pub mod shapes;
|
||||
pub mod strokes;
|
||||
pub mod svg_attrs;
|
||||
pub mod text;
|
||||
pub mod text_editor;
|
||||
|
||||