Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Andrey Antukh 2026-05-14 11:12:01 +02:00
commit 52588412c7
97 changed files with 2720 additions and 1537 deletions

View File

@ -63,7 +63,7 @@ jobs:
echo "$PUB_DOCKER_PASSWORD" | skopeo login --username "$PUB_DOCKER_USERNAME" --password-stdin docker.io
IMAGES=("frontend" "backend" "exporter" "storybook")
IMAGES=("frontend" "backend" "exporter" "mcp" "storybook")
SHORT_TAG=${TAG%.*}
for image in "${IMAGES[@]}"; do

2
.gitignore vendored
View File

@ -93,3 +93,5 @@
/.pnpm-store
/.vscode
/.idea
/.claude
/.playwright-mcp

View File

@ -90,6 +90,7 @@
### :bug: Bugs fixed
- Fix render-wasm atlas corruption when dragging large shapes after a zoom or pan change (stale multi-zoom-level pixels no longer appear at the old shape position).
- Fix Alt/Option to draw shapes from center point (by @offreal) [Github #8361](https://github.com/penpot/penpot/pull/8361)
- Add token name on broken token pill on sidebar [Taiga #13527](https://tree.taiga.io/project/penpot/issue/13527)
- Fix tooltip activated when tab change [Taiga #13627](https://tree.taiga.io/project/penpot/issue/13627)
@ -151,34 +152,72 @@
- Fix plugin parse-point returning plain map instead of Point record (by @FairyPigDev) [Github #9129](https://github.com/penpot/penpot/pull/9129)
- Fix `:heigth` typo in clipboard frame-same-size? (by @iot2edge) [Github #9250](https://github.com/penpot/penpot/pull/9250)
- Fix Settings and Notifications "Update Settings" button enabled state when form has no changes (by @moorsecopers99) [Github #9090](https://github.com/penpot/penpot/issues/9090)
- Fix library updates reappear after being applied and the file is reloaded [Taiga #14040](https://tree.taiga.io/project/penpot/issue/14040)
- Fix dependency libraries remaining visible in UI after unlinking main library [Taiga #14020](https://tree.taiga.io/project/penpot/issue/14020)
## 2.15.0 (Unreleased)
## 2.15.2
### :bug: Bugs fixed
- Fix mcp related internal config for docker images [Github #9565](https://github.com/penpot/penpot/pull/9565)
## 2.15.1
### :sparkles: New features & Enhancements
- Add support for chunked uploading of fonts [Github #9560](https://github.com/penpot/penpot/issues/9560)
## 2.15.0
### :sparkles: New features & Enhancements
- Add MCP server integration [Github #9174](https://github.com/penpot/penpot/issues/9174)
- Add chunked upload API for large media and binary files (removes previous upload size limits) [Github #8909](https://github.com/penpot/penpot/pull/8909)
- Improve team name validation [Github #9176](https://github.com/penpot/penpot/pull/9176)
(PR: [#9032](https://github.com/penpot/penpot/pull/9032), [#9321](https://github.com/penpot/penpot/pull/9321))
- Add chunked upload API for large media and binary files (removes previous upload size limits) [Github #9516](https://github.com/penpot/penpot/issues/9516)
(PR: [#8909](https://github.com/penpot/penpot/pull/8909))
- Add anonymous telemetry event collection [Github #9467](https://github.com/penpot/penpot/issues/9467)
(PR: [#9065](https://github.com/penpot/penpot/pull/9065), [#9483](https://github.com/penpot/penpot/pull/9483))
- Improve team name validation [Github #9517](https://github.com/penpot/penpot/issues/9517)
(PR: [#9176](https://github.com/penpot/penpot/pull/9176))
- Enhance readability of applied tokens in plugins API [Github #9175](https://github.com/penpot/penpot/issues/9175)
(PR: [#8607](https://github.com/penpot/penpot/pull/8607))
- Encourage use of flex/grid layouts in designs generated via MCP [Github #9081](https://github.com/penpot/penpot/issues/9081)
(PR: [#9084](https://github.com/penpot/penpot/pull/9084))
- Improve MCP server logging, adding Loki support [Github #9415](https://github.com/penpot/penpot/issues/9415)
(PR: [#9425](https://github.com/penpot/penpot/pull/9425))
- Add security headers to Nginx on Docker images [Github #9519](https://github.com/penpot/penpot/issues/9519)
(PR: [#9473](https://github.com/penpot/penpot/pull/9473))
### :bug: Bugs fixed
- Fix MCP integrations URL copy action to match the URL displayed in settings [Github #9238](https://github.com/penpot/penpot/issues/9238)
- Fix Plugin API token methods rejecting JS array of strings [Github #9162](https://github.com/penpot/penpot/issues/9162)
- Harden Nginx responses with standard security headers and hide upstream `X-Powered-By` headers
- Fix keep-alive interval leak in PluginBridge (by @opcode81) [Github #9435](https://github.com/penpot/penpot/pull/9435)
- Fix MCP "active in another tab" notification not clearing (by @Dexterity104) [Github #9321](https://github.com/penpot/penpot/pull/9321)
- Fix swapped analytics event names on MCP tab-switch dialog (by @Dexterity104) [Github #9322](https://github.com/penpot/penpot/pull/9322)
- Fix MCP ReplServer binding to all interfaces (0.0.0.0) instead of localhost, allowing unauthenticated RCE [Github #9400] (https://github.com/penpot/penpot/pull/9400)
- Fix incorrect handling of version restore operation [Github #9041](https://github.com/penpot/penpot/pull/9041)
- Fix SSRF in media URL import and restrict unauthenticated asset access to public buckets only [Github #9390](https://github.com/penpot/penpot/pull/9390)
- Fix text edition mode not exited when changing selection, blocking token application [Github #9346](https://github.com/penpot/penpot/issues/9346)
- Use base64 envelope for Uint8Array task results to avoid JSON expansion (by @opcode81) [Github #9431](https://github.com/penpot/penpot/pull/9431)
- Fix empty warning on login [Github #9056](https://github.com/penpot/penpot/pull/9056)
- Fix layer hierarchy to match old and new SCSS [Github #9126](https://github.com/penpot/penpot/pull/9126)
- Fix multiple selection on shapes with token applied to stroke color [Github #9110](https://github.com/penpot/penpot/pull/9110)
- Fix onboarding modals appearing behind libraries and templates panel [Github #9178](https://github.com/penpot/penpot/pull/9178)
(PR: [#9355](https://github.com/penpot/penpot/pull/9355))
- Reduce memory usage of MCP server when handling images (by @opcode81) [Github #9420](https://github.com/penpot/penpot/issues/9420)
(PR: [#9431](https://github.com/penpot/penpot/pull/9431))
- Fix Plugin API token methods rejecting JS array of strings (by @boskodev790) [Github #9162](https://github.com/penpot/penpot/issues/9162)
(PR: [#9166](https://github.com/penpot/penpot/pull/9166))
- Fix release notes modal appearing behind the dashboard sidebar (by @RenzoMXD) [Github #8296](https://github.com/penpot/penpot/issues/8296)
(PR: [#9126](https://github.com/penpot/penpot/pull/9126), [#9233](https://github.com/penpot/penpot/pull/9233))
- Fix empty warning on login [Github #9520](https://github.com/penpot/penpot/issues/9520)
(PR: [#9056](https://github.com/penpot/penpot/pull/9056))
- Fix maximum call stack size exceeded in SSE read-stream [Github #9470](https://github.com/penpot/penpot/issues/9470)
(PR: [#9484](https://github.com/penpot/penpot/pull/9484))
- Fix incorrect handling of version restore operation [Github #9515](https://github.com/penpot/penpot/issues/9515)
(PR: [#9041](https://github.com/penpot/penpot/pull/9041))
- Fix MCP ReplServer binding to all interfaces (0.0.0.0) instead of localhost, allowing unauthenticated RCE [Github #9518](https://github.com/penpot/penpot/issues/9518)
(PR: [#9400](https://github.com/penpot/penpot/pull/9400))
- Fix MCP integrations URL copy action to match the URL displayed in settings [Github #9238](https://github.com/penpot/penpot/issues/9238)
(PR: [#9239](https://github.com/penpot/penpot/pull/9239))
- Fix swapped analytics event names on MCP tab-switch dialog (by @Dexterity104) [Github #9496](https://github.com/penpot/penpot/issues/9496)
(PR: [#9322](https://github.com/penpot/penpot/pull/9322))
- Fix multiple selection on shapes with token applied to stroke color [Github #9522](https://github.com/penpot/penpot/issues/9522)
(PR: [#9110](https://github.com/penpot/penpot/pull/9110))
- Fix onboarding modals appearing behind libraries and templates panel [Github #9521](https://github.com/penpot/penpot/issues/9521)
(PR: [#9178](https://github.com/penpot/penpot/pull/9178))
- Fix keep-alive interval leak in PluginBridge (by @opcode81) [Github #9430](https://github.com/penpot/penpot/issues/9430)
(PR: [#9435](https://github.com/penpot/penpot/pull/9435))
## 2.14.5
@ -194,7 +233,6 @@
- Fix email blacklisting [Github #9122](https://github.com/penpot/penpot/pull/9122)
- Fix removeChild errors from unmount race conditions [Github #8927](https://github.com/penpot/penpot/pull/8927)
## 2.14.3
### :sparkles: New features & Enhancements
@ -224,7 +262,6 @@
- Fix typo `:podition` in swap-shapes grid cell
- Fix multiple selection on shapes with token applied to stroke color
## 2.14.2
### :sparkles: New features & Enhancements

View File

@ -3,7 +3,7 @@
:deps
{penpot/common {:local/root "../common"}
org.clojure/clojure {:mvn/version "1.12.4"}
org.clojure/clojure {:mvn/version "1.12.5"}
org.clojure/tools.namespace {:mvn/version "1.5.0"}
com.github.luben/zstd-jni {:mvn/version "1.5.7-4"}
@ -17,7 +17,7 @@
io.prometheus/simpleclient_httpserver {:mvn/version "0.16.0"}
io.lettuce/lettuce-core {:mvn/version "6.8.1.RELEASE"}
io.lettuce/lettuce-core {:mvn/version "7.5.1.RELEASE"}
;; Minimal dependencies required by lettuce, we need to include them
;; explicitly because clojure dependency management does not support
;; yet the BOM format.
@ -28,18 +28,18 @@
com.google.guava/guava {:mvn/version "33.4.8-jre"}
funcool/yetti
{:git/tag "v11.9"
:git/sha "5fad7a9"
{:git/tag "v11.10"
:git/sha "88701f4"
:git/url "https://github.com/funcool/yetti.git"
:exclusions [org.slf4j/slf4j-api]}
com.github.seancorfield/next.jdbc
{:mvn/version "1.3.1070"}
{:mvn/version "1.3.1093"}
metosin/reitit-core {:mvn/version "0.9.1"}
nrepl/nrepl {:mvn/version "1.4.0"}
nrepl/nrepl {:mvn/version "1.7.0"}
org.postgresql/postgresql {:mvn/version "42.7.9"}
org.postgresql/postgresql {:mvn/version "42.7.11"}
org.xerial/sqlite-jdbc {:mvn/version "3.50.3.0"}
com.zaxxer/HikariCP {:mvn/version "7.0.2"}
@ -49,7 +49,7 @@
buddy/buddy-hashers {:mvn/version "2.0.167"}
buddy/buddy-sign {:mvn/version "3.6.1-359"}
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.3"}
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"}
org.jsoup/jsoup {:mvn/version "1.21.2"}
org.im4java/im4java
@ -57,7 +57,8 @@
:git/sha "e2b3e16"
:git/url "https://github.com/penpot/im4java"}
org.lz4/lz4-java {:mvn/version "1.8.0"}
at.yawk.lz4/lz4-java
{:mvn/version "1.11.0"}
org.clojars.pntblnk/clj-ldap {:mvn/version "0.0.17"}
@ -66,7 +67,7 @@
;; Pretty Print specs
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
software.amazon.awssdk/s3 {:mvn/version "2.41.21"}}
software.amazon.awssdk/s3 {:mvn/version "2.44.4"}}
:paths ["src" "resources" "target/classes"]
:aliases

View File

@ -440,11 +440,28 @@
(db/run! cfg (fn [{:keys [::db/conn]}]
(let [ids (db/create-array conn "uuid" ids)
sql (str "SELECT flr.* FROM file_library_rel AS flr "
" JOIN file AS l ON (flr.library_file_id = l.id) "
" WHERE flr.file_id = ANY(?) AND l.deleted_at IS NULL")]
sql (str "SELECT flr.*,"
" fls.synced_at"
" FROM file_library_rel AS flr"
" JOIN file AS l"
" ON flr.library_file_id = l.id"
" LEFT JOIN file_library_sync AS fls"
" ON fls.file_id = flr.file_id"
" AND fls.library_file_id = flr.library_file_id"
" WHERE flr.file_id = ANY(?)"
" AND l.deleted_at IS NULL;")]
(db/exec! conn [sql ids])))))
(def ^:private sql:upsert-file-library-sync
"INSERT INTO file_library_sync (file_id, library_file_id, synced_at)
VALUES (?::uuid, ?::uuid, ?::timestamptz)
ON CONFLICT (file_id, library_file_id)
DO UPDATE SET synced_at = EXCLUDED.synced_at;")
(defn upsert-file-library-sync!
[conn {:keys [file-id library-file-id synced-at]}]
(db/exec-one! conn [sql:upsert-file-library-sync file-id library-file-id synced-at]))
(def ^:private sql:get-libraries
"WITH RECURSIVE libs AS (
SELECT fl.id
@ -799,32 +816,41 @@
(def ^:private sql:get-file-libraries
"WITH RECURSIVE libs AS (
SELECT fl.*, flr.synced_at
FROM file AS fl
JOIN file_library_rel AS flr ON (flr.library_file_id = fl.id)
WHERE flr.file_id = ?::uuid
UNION
SELECT fl.*, flr.synced_at
FROM file AS fl
JOIN file_library_rel AS flr ON (flr.library_file_id = fl.id)
JOIN libs AS l ON (flr.file_id = l.id)
)
SELECT l.id,
l.features,
l.project_id,
p.team_id,
l.created_at,
l.modified_at,
l.deleted_at,
l.name,
l.revn,
l.vern,
l.synced_at,
l.is_shared,
l.version
FROM libs AS l
INNER JOIN project AS p ON (p.id = l.project_id)
WHERE l.deleted_at IS NULL;")
SELECT fl.*
FROM file AS fl
JOIN file_library_rel AS flr
ON flr.library_file_id = fl.id
WHERE flr.file_id = ?::uuid
UNION
SELECT fl.*
FROM file AS fl
JOIN file_library_rel AS flr
ON flr.library_file_id = fl.id
JOIN libs AS l
ON flr.file_id = l.id
)
SELECT l.id,
l.features,
l.project_id,
p.team_id,
l.created_at,
l.modified_at,
l.deleted_at,
l.name,
l.revn,
l.vern,
l.is_shared,
l.version,
fls.synced_at
FROM libs AS l
JOIN project AS p
ON p.id = l.project_id
LEFT JOIN file_library_sync AS fls
ON fls.file_id = ?::uuid
AND fls.library_file_id = l.id
WHERE l.deleted_at IS NULL;")
(defn get-file-libraries
[conn file-id]
@ -834,7 +860,7 @@
;; completly useless
(map #(assoc % :is-indirect false))
(map decode-row-features))
(db/exec! conn [sql:get-file-libraries file-id])))
(db/exec! conn [sql:get-file-libraries file-id file-id])))
(defn get-resolved-file-libraries
"Get all file libraries including itself. Returns an instance of

View File

@ -573,7 +573,6 @@
;; Insert all file relations
(doseq [{:keys [library-file-id] :as rel} rels]
(let [rel (-> rel
(assoc :synced-at timestamp)
(update :file-id bfc/lookup-index)
(update :library-file-id bfc/lookup-index))]
@ -583,7 +582,12 @@
:file-id (:file-id rel)
:lib-id (:library-file-id rel)
::l/sync? true)
(db/insert! conn :file-library-rel rel))
(let [rel-params (dissoc rel :synced-at)]
(db/insert! conn :file-library-rel rel-params)
(bfc/upsert-file-library-sync! conn {:file-id (:file-id rel-params)
:library-file-id (:library-file-id rel-params)
:synced-at (or (:synced-at rel)
timestamp)})))
(l/warn :hint "ignoring file library link"
:file-id (:file-id rel)

View File

@ -314,10 +314,10 @@
(doseq [rel (read-obj cfg :file-rels file-id)]
(let [rel (-> rel
(update :file-id bfc/lookup-index)
(update :library-file-id bfc/lookup-index)
(assoc :synced-at timestamp))]
(update :library-file-id bfc/lookup-index))]
(db/insert! conn :file-library-rel rel
::db/return-keys false)))
::db/return-keys false)
(bfc/upsert-file-library-sync! conn (assoc rel :synced-at timestamp))))
(doseq [media (read-seq cfg :file-media-object file-id)]
(let [media (-> media

View File

@ -824,10 +824,10 @@
:file-id (str file-id)
:lib-id (str libr-id)
::l/sync? true)
(db/insert! conn :file-library-rel
{:synced-at timestamp
:file-id file-id
:library-file-id libr-id})))))
(let [rel-params {:file-id file-id
:library-file-id libr-id}]
(db/insert! conn :file-library-rel rel-params)
(bfc/upsert-file-library-sync! conn (assoc rel-params :synced-at timestamp)))))))
(defn- import-storage-objects
[{:keys [::bfc/input ::entries ::bfc/timestamp] :as cfg}]

View File

@ -72,6 +72,7 @@
:telemetry-uri "https://telemetry.penpot.app/"
:media-max-file-size (* 1024 1024 30) ; 30MiB
:font-max-file-size (* 1024 1024 30) ; 30MiB
:ldap-user-query "(|(uid=:username)(mail=:username))"
:ldap-attrs-username "uid"
@ -120,6 +121,7 @@
[:auto-file-snapshot-timeout {:optional true} ::ct/duration]
[:media-max-file-size {:optional true} ::sm/int]
[:font-max-file-size {:optional true} ::sm/int]
[:deletion-delay {:optional true} ::ct/duration]
[:file-clean-delay {:optional true} ::ct/duration]
[:telemetry-enabled {:optional true} ::sm/boolean]

View File

@ -61,21 +61,15 @@
::mdef/help "A total number of bytes processed by update-file."
::mdef/type :counter}
:rpc-mutation-timing
{::mdef/name "penpot_rpc_mutation_timing"
::mdef/help "RPC mutation method call timing."
:rpc-main-timing
{::mdef/name "penpot_rpc_main_timing"
::mdef/help "RPC command method call timing for main"
::mdef/labels ["name"]
::mdef/type :histogram}
:rpc-command-timing
{::mdef/name "penpot_rpc_command_timing"
::mdef/help "RPC command method call timing."
::mdef/labels ["name"]
::mdef/type :histogram}
:rpc-query-timing
{::mdef/name "penpot_rpc_query_timing"
::mdef/help "RPC query method call timing."
:rpc-management-timing
{::mdef/name "penpot_rpc_management_timing"
::mdef/help "RPC command method call timing for management."
::mdef/labels ["name"]
::mdef/type :histogram}

View File

@ -38,9 +38,6 @@
org.im4java.core.ConvertCmd
org.im4java.core.IMOperation))
(def default-max-file-size
(* 1024 1024 10)) ; 10 MiB
(def schema:upload
[:map {:title "Upload"}
[:filename :string]
@ -79,6 +76,20 @@
max-size)))
upload))
(defn validate-font-size!
"Validates that the font file `upload` does not exceed the configured
`:font-max-file-size` limit. Accepts the same map shape as
`validate-media-size!` requires a `:size` key in bytes."
[upload]
(let [max-size (cf/get :font-max-file-size)]
(when (> (:size upload) max-size)
(ex/raise :type :restriction
:code :font-max-file-size-reached
:hint (str/ffmt "the uploaded font size % is greater than the maximum %"
(:size upload)
max-size)))
upload))
(defmulti process :cmd)
(defmulti process-error class)
@ -296,9 +307,7 @@
[{:keys [::http/client]} uri]
(letfn [(parse-and-validate [{:keys [status headers] :as response}]
(let [size (some-> (get headers "content-length") d/parse-integer)
mtype (get headers "content-type")
format (cm/mtype->format mtype)
max-size (cf/get :media-max-file-size default-max-file-size)]
mtype (get headers "content-type")]
(when-not (<= 200 status 299)
(ex/raise :type :validation
@ -310,19 +319,9 @@
:code :unknown-size
:hint "seems like the url points to resource with unknown size"))
(when (> size max-size)
(ex/raise :type :validation
:code :file-too-large
:hint (str/ffmt "the file size % is greater than the maximum %"
size
default-max-file-size)))
(when (nil? format)
(ex/raise :type :validation
:code :media-type-not-allowed
:hint "seems like the url points to an invalid media object"))
{:size size :mtype mtype :format format}))]
(-> {:size size :mtype mtype}
(validate-media-type!)
(validate-media-size!))))]
(let [{:keys [body] :as response}
(try

View File

@ -481,7 +481,10 @@
:fn (mg/resource "app/migrations/sql/0147-add-upload-session-table.sql")}
{:name "0148-add-variant-name-team-font-variant"
:fn (mg/resource "app/migrations/sql/0148-add-variant-name-team-font-variant.sql")}])
:fn (mg/resource "app/migrations/sql/0148-add-variant-name-team-font-variant.sql")}
{:name "0149-mod-file-library-rel-synced-at"
:fn (mg/resource "app/migrations/sql/0149-mod-file-library-rel-synced-at.sql")}])
(defn apply-migrations!
[pool name migrations]

View File

@ -0,0 +1,19 @@
CREATE TABLE file_library_sync (
file_id uuid NOT NULL,
library_file_id uuid NOT NULL,
synced_at timestamptz NOT NULL DEFAULT clock_timestamp(),
PRIMARY KEY (file_id, library_file_id)
);
INSERT INTO file_library_sync (file_id, library_file_id, synced_at)
SELECT file_id, library_file_id, synced_at
FROM file_library_rel;
-- DEPRECATED: the `synced_at` column on `file_library_rel` is deprecated
-- and will be removed in a future migration. It's kept temporarily
-- for backward compatibility while data is migrated to `file_library_sync`.
COMMENT ON COLUMN file_library_rel.synced_at IS
'DEPRECATED: will be removed in a future migration; kept temporarily for backward compatibility';

View File

@ -1064,7 +1064,10 @@
(defn link-file-to-library
[conn {:keys [file-id library-id] :as params}]
(db/exec-one! conn [sql:link-file-to-library file-id library-id]))
(db/exec-one! conn [sql:link-file-to-library file-id library-id])
(bfc/upsert-file-library-sync! conn {:file-id file-id
:library-file-id library-id
:synced-at (ct/now)}))
(def ^:private
schema:link-file-to-library
@ -1118,11 +1121,9 @@
(defn update-sync
[conn {:keys [file-id library-id] :as params}]
(db/update! conn :file-library-rel
{:synced-at (ct/now)}
{:file-id file-id
:library-file-id library-id}
{::db/return-keys true}))
(bfc/upsert-file-library-sync! conn {:file-id file-id
:library-file-id library-id
:synced-at (ct/now)}))
(def ^:private schema:update-file-library-sync-status
[:map {:title "update-file-library-sync-status"}

View File

@ -9,7 +9,8 @@
[app.binfile.common :as bfc]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.media :as cmedia]
[app.common.logging :as l]
[app.common.media :as cm]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.uuid :as uuid]
@ -23,6 +24,7 @@
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.commands.files :as files]
[app.rpc.commands.media :refer [assemble-chunks]]
[app.rpc.commands.projects :as projects]
[app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc]
@ -31,6 +33,8 @@
[app.storage :as sto]
[app.storage.tmp :as tmp]
[app.util.services :as sv]
[cuerdas.core :as str]
[datoteka.fs :as fs]
[datoteka.io :as io])
(:import
java.io.InputStream
@ -91,33 +95,92 @@
(declare create-font-variant)
(def ^:private schema:create-font-variant
[:map {:title "create-font-variant"}
[:team-id ::sm/uuid]
[:data [:map-of ::sm/text [:or ::sm/bytes
[::sm/vec ::sm/bytes]]]]
[:font-id ::sm/uuid]
[:font-family ::sm/text]
[:font-weight [::sm/one-of {:format "number"} valid-weight]]
[:font-style [::sm/one-of {:format "string"} valid-style]]
[:variant-name {:optional true} [:maybe ::sm/text]]])
[:and
[:map {:title "create-font-variant"}
[:team-id ::sm/uuid]
[:font-id ::sm/uuid]
[:font-family ::sm/text]
[:font-weight [::sm/one-of {:format "number"} valid-weight]]
[:font-style [::sm/one-of {:format "string"} valid-style]]
[:data {:optional true} [:map-of ::sm/text [:or ::sm/bytes [::sm/vec ::sm/bytes]]]]
[:uploads {:optional true} [:map-of ::sm/text ::sm/uuid]]]
[:fn {:error/message "one of :data or :uploads is required"}
(fn [{:keys [data uploads]}]
(or (seq data) (seq uploads)))]])
;; FIXME: IMPORTANT: refactor this, we should not hold a whole db
;; connection around the font creation
(defn- prepare-font-data-from-uploads
"Assembles each chunked-upload session in `uploads` (a `{mtype
session-id}` map) into a temp file, validates the media type and
size of every entry, and returns a `{mtype path}` data map."
[cfg {:keys [uploads] :as params}]
(let [data (reduce-kv
(fn [acc mtype session-id]
(let [assembled (assemble-chunks cfg session-id)]
(-> {:mtype mtype :size (:size assembled)}
(media/validate-media-type! cm/font-types)
(media/validate-font-size!))
(assoc acc mtype (:path assembled))))
{}
uploads)]
(-> params
(assoc :data data)
(dissoc :uploads))))
(defn- prepare-font-data-from-legacy
"Validates the media type and size of every entry in the legacy
`:data` map (a `{mtype bytes | [bytes]}` map). Normalises every
entry to a tempfile. Returns params with a normalised
`{mtype path}` data map."
[{:keys [data] :as params}]
(let [data (reduce-kv
(fn [acc mtype content]
(let [tmp (tmp/tempfile :prefix "penpot.tempfont." :suffix "")
chunks (if (vector? content) content [content])
streams (map io/input-stream chunks)
streams (Collections/enumeration streams)]
;; Generate the tempfile from all chunks
(with-open [^OutputStream output (io/output-stream tmp)
^InputStream input (SequenceInputStream. streams)]
(io/copy input output))
;; Validate
(-> {:mtype mtype :size (fs/size tmp)}
(media/validate-media-type! cm/font-types)
(media/validate-font-size!))
(assoc acc mtype tmp)))
{}
data)]
(assoc params :data data)))
(sv/defmethod ::create-font-variant
"Upload a font variant. Font data may be provided either as a
Transit-encoded `:data` map (keyed by mime-type) for small fonts, or
as an `:uploads` map (keyed by mime-type, values are upload-session
UUIDs from the chunked-upload API) for large fonts. Exactly one of
the two must be present."
{::doc/added "1.18"
::doc/changes ["2.16" "Add :uploads param for chunked upload support"]
::climit/id [[:process-font/by-profile ::rpc/profile-id]
[:process-font/global]]
::webhooks/event? true
::sm/params schema:create-font-variant}
[cfg {:keys [::rpc/profile-id team-id] :as params}]
[cfg {:keys [::rpc/profile-id team-id uploads] :as params}]
(db/tx-run! cfg
(fn [{:keys [::db/conn] :as cfg}]
(teams/check-edition-permissions! conn profile-id team-id)
(quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team
::quotes/profile-id profile-id
::quotes/team-id team-id})
(create-font-variant cfg (assoc params :profile-id profile-id)))))
(let [params (if (some? uploads)
(prepare-font-data-from-uploads cfg params)
(prepare-font-data-from-legacy params))]
(create-font-variant cfg (assoc params :profile-id profile-id))))))
(defn create-font-variant
[{:keys [::sto/storage ::db/conn]} {:keys [data] :as params}]
@ -132,23 +195,6 @@
:hint "invalid font upload, unable to generate missing font assets"))
data))
(process-chunks [chunks]
(let [tmp (tmp/tempfile :prefix "penpot.tempfont." :suffix "")
streams (map io/input-stream chunks)
streams (Collections/enumeration streams)]
(with-open [^OutputStream output (io/output-stream tmp)
^InputStream input (SequenceInputStream. streams)]
(io/copy input output))
tmp))
(join-chunks [data]
(reduce-kv (fn [data mtype content]
(if (vector? content)
(assoc data mtype (process-chunks content))
data))
data
data))
(prepare-font [data mtype]
(when-let [resource (get data mtype)]
@ -191,11 +237,38 @@
:otf-file-id (:id otf)
:ttf-file-id (:id ttf)}))]
(let [data (join-chunks data)
data (generate-missing data)
assets (persist-fonts-files! data)
result (insert-font-variant! assets)]
(vary-meta result assoc ::audit/replace-props (update params :data (comp vec keys))))))
(let [tpoint (ct/tpoint)
mtypes (vec (keys data))
total-size (reduce-kv (fn [acc _ content]
(+ acc (if (bytes? content)
(alength ^bytes content)
(fs/size content))))
0
data)]
(l/dbg :hint "create-font-variant"
:step "init"
:font-family (:font-family params)
:font-weight (:font-weight params)
:font-style (:font-style params)
:mtypes (str/join mtypes ",")
:size total-size)
(let [data (generate-missing data)
assets (persist-fonts-files! data)
result (insert-font-variant! assets)
elapsed (tpoint)]
(l/dbg :hint "create-font-variant"
:step "end"
:font-family (:font-family params)
:font-weight (:font-weight params)
:font-style (:font-style params)
:mtypes (str/join mtypes ",")
:size total-size
:elapsed (ct/format-duration elapsed))
(vary-meta result assoc ::audit/replace-props (update params :data (comp vec keys)))))))
;; --- UPDATE FONT FAMILY
@ -326,7 +399,7 @@
[v mtype]
(str (:font-family v) "-" (:font-weight v)
(when-not (= "normal" (:font-style v)) (str "-" (:font-style v)))
(cmedia/mtype->extension mtype)))
(cm/mtype->extension mtype)))
(def ^:private schema:download-font
[:map {:title "download-font"}

View File

@ -72,10 +72,14 @@
(doseq [params (sequence (comp
(map #(bfc/remap-id % :file-id))
(map #(bfc/remap-id % :library-file-id))
(map #(assoc % :synced-at timestamp))
(map #(assoc % :created-at timestamp)))
flibs)]
(db/insert! conn :file-library-rel params ::db/return-keys false))
(let [rel-params (dissoc params :synced-at)]
(db/insert! conn :file-library-rel rel-params ::db/return-keys false)
(bfc/upsert-file-library-sync! conn {:file-id (:file-id rel-params)
:library-file-id (:library-file-id rel-params)
:synced-at (or (:synced-at params)
timestamp)})))
(doseq [params (sequence (comp
(map #(bfc/remap-id % :id))

View File

@ -8,6 +8,7 @@
(:require
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.uuid :as uuid]
@ -58,8 +59,8 @@
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
;; We get the minimal file for proper checking if
;; file is not already deleted
(let [_ (files/get-minimal-file conn file-id)
mobj (create-file-media-object cfg params)]
(let [_ (files/get-minimal-file conn file-id)
mobj (create-file-media-object cfg params)]
(db/update! conn :file
{:modified-at (ct/now)
@ -149,20 +150,49 @@
(defn- create-file-media-object
[{:keys [::sto/storage ::db/conn] :as cfg}
{:keys [id file-id is-local name content]}]
(let [result (process-image content)
image (sto/put-object! storage (::image result))
thumb (when-let [params (::thumb result)]
(sto/put-object! storage params))]
{:keys [id file-id is-local name content from-url? from-chunks?]}]
(db/exec-one! conn [sql:create-file-media-object
(or id (uuid/next))
file-id is-local name
(:id image)
(:id thumb)
(:width result)
(:height result)
(:mtype result)])))
(let [tpoint (ct/tpoint)
id (or id (uuid/next))
origin (cond
from-url?
"url"
from-chunks?
"chunks"
:else
"direct")]
(l/dbg :hint "create file-media-object"
:step "init"
:id (str id)
:mtype (:mtype content)
:size (:size content)
:path (str (:path content))
:origin origin)
(let [result (process-image content)
image (sto/put-object! storage (::image result))
thumb (when-let [params (::thumb result)]
(sto/put-object! storage params))
elapsed (tpoint)]
(l/dbg :hint "create file-media-object"
:step "end"
:id (str id)
:mtype (:mtype content)
:size (:size content)
:path (str (:path content))
:origin origin
:elapsed (ct/format-duration elapsed))
(db/exec-one! conn [sql:create-file-media-object
id
file-id is-local name
(:id image)
(:id thumb)
(:width result)
(:height result)
(:mtype result)]))))
;; --- Create File Media Object (from URL)
@ -198,6 +228,7 @@
[cfg {:keys [url name] :as params}]
(let [content (media/download-image cfg url)
params (-> params
(assoc :from-url? true)
(assoc :content content)
(assoc :name (d/nilv name "unknown")))]
@ -305,7 +336,14 @@
:hint "chunk index is out of range for this session"
:session-id session-id
:total-chunks (:total-chunks session)
:index index)))
:index index))
(l/trc :hint "upload-chunk"
:session-id session-id
:chunk (str index "/" (:total-chunks session))
:size (:size content)
:path (:path content)))
(let [storage (sto/resolve cfg)
data (sto/content (:path content))]
@ -399,14 +437,15 @@
(db/tx-run! cfg
(fn [{:keys [::db/conn] :as cfg}]
(let [{:keys [path size]} (assemble-chunks cfg session-id)
content {:filename "upload"
:size size
:path path
:mtype mtype}
_ (media/validate-media-type! content)
(let [content (assemble-chunks cfg session-id)
content (-> content
(assoc :filename (str "upload:" name))
(assoc :mtype mtype)
(media/validate-media-type!)
(media/validate-media-size!))
mobj (create-file-media-object cfg (assoc params
:id (or id (uuid/next))
:id id
:from-chunks? true
:content content))]
(db/update! conn :file

View File

@ -267,6 +267,7 @@
[cfg {:keys [::rpc/profile-id file] :as params}]
;; Validate incoming mime type
(media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
(media/validate-media-size! file)
(update-profile-photo cfg (assoc params :profile-id profile-id)))
(defn update-profile-photo

View File

@ -950,6 +950,7 @@
;; Validate incoming mime type
(media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
(media/validate-media-size! file)
(update-team-photo cfg (assoc params :profile-id profile-id)))
(defn update-team-photo

View File

@ -918,6 +918,72 @@
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found)))))
(t/deftest link-file-to-library-creates-sync-row
(let [profile (th/create-profile* 1)
file1 (th/create-file* 1 {:project-id (:default-project-id profile)
:profile-id (:id profile)
:is-shared true})
file2 (th/create-file* 2 {:project-id (:default-project-id profile)
:profile-id (:id profile)})
data {::th/type :link-file-to-library
::rpc/profile-id (:id profile)
:file-id (:id file2)
:library-id (:id file1)}
out (th/command! data)
rel (th/db-get :file-library-rel {:file-id (:id file2)
:library-file-id (:id file1)})
sync (th/db-get :file-library-sync {:file-id (:id file2)
:library-file-id (:id file1)})]
(t/is (nil? (:error out)))
(t/is (some? rel))
(t/is (some? sync))
(t/is (some? (:synced-at sync)))))
(t/deftest update-file-library-sync-status-updates-sync-row
(let [profile (th/create-profile* 1)
file1 (th/create-file* 1 {:project-id (:default-project-id profile)
:profile-id (:id profile)
:is-shared true})
file2 (th/create-file* 2 {:project-id (:default-project-id profile)
:profile-id (:id profile)})
_ (th/link-file-to-library* {:file-id (:id file2)
:library-id (:id file1)})
before (th/db-get :file-library-sync {:file-id (:id file2)
:library-file-id (:id file1)})
_ (th/sleep 10)
data {::th/type :update-file-library-sync-status
::rpc/profile-id (:id profile)
:file-id (:id file2)
:library-id (:id file1)}
out (th/command! data)
after (th/db-get :file-library-sync {:file-id (:id file2)
:library-file-id (:id file1)})]
(t/is (nil? (:error out)))
(t/is (some? before))
(t/is (some? after))
(t/is (pos? (compare (:synced-at after) (:synced-at before))))))
(t/deftest update-file-library-sync-status-without-link-creates-sync-row
(let [profile (th/create-profile* 1)
file1 (th/create-file* 1 {:project-id (:default-project-id profile)
:profile-id (:id profile)
:is-shared true})
file2 (th/create-file* 2 {:project-id (:default-project-id profile)
:profile-id (:id profile)})
data {::th/type :update-file-library-sync-status
::rpc/profile-id (:id profile)
:file-id (:id file2)
:library-id (:id file1)}
out (th/command! data)
sync (th/db-get :file-library-sync {:file-id (:id file2)
:library-file-id (:id file1)})]
(t/is (nil? (:error out)))
(t/is (some? sync))
(t/is (some? (:synced-at sync)))))
(t/deftest deletion
(let [profile1 (th/create-profile* 1)

View File

@ -17,7 +17,9 @@
[clojure.test :as t]
[datoteka.fs :as fs]
[datoteka.io :as io]
[mockery.core :refer [with-mocks]]))
[mockery.core :refer [with-mocks]])
(:import
java.io.RandomAccessFile))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
@ -327,3 +329,499 @@
(let [error (:error out)
error-data (ex-data error)]
(t/is (th/ex-info? error))))))
;; -----------------------------------------------------------------------
;; Helpers for chunked-upload font tests
;; -----------------------------------------------------------------------
(defn- split-bytes-into-chunks
"Splits `data` (byte array) into chunks of at most `chunk-size` bytes.
Returns a vector of byte arrays."
[^bytes data chunk-size]
(let [length (alength data)]
(loop [offset 0 chunks []]
(if (>= offset length)
chunks
(let [remaining (- length offset)
size (min chunk-size remaining)
buf (byte-array size)]
(System/arraycopy data offset buf 0 size)
(recur (+ offset size) (conj chunks buf)))))))
(defn- make-chunk-mfile
"Writes `data` (byte array) to a tempfile and returns a map
compatible with the upload-chunk :content parameter."
[^bytes data mtype]
(let [tmp (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-font-chunk-")]
(io/write* tmp data)
{:filename "chunk"
:path tmp
:mtype mtype
:size (alength data)}))
(defn- create-upload-session!
"Creates an upload session for `prof` with `total-chunks`. Returns the session-id UUID."
[prof total-chunks]
(let [out (th/command! {::th/type :create-upload-session
::rpc/profile-id (:id prof)
:total-chunks total-chunks})]
(t/is (nil? (:error out)))
(:session-id (:result out))))
(defn- upload-font-chunked!
"Splits `font-bytes` into chunks of `chunk-size` bytes, creates an upload
session, uploads all chunks, and returns the session-id UUID."
[prof ^bytes font-bytes mtype chunk-size]
(let [chunks (split-bytes-into-chunks font-bytes chunk-size)
session-id (create-upload-session! prof (count chunks))]
(doseq [[idx chunk-data] (map-indexed vector chunks)]
(let [mfile (make-chunk-mfile chunk-data mtype)
out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index idx
:content mfile})]
(t/is (nil? (:error out)))))
session-id))
(defn- assert-font-variant-result
"Checks that a successful create-font-variant result has valid UUIDs and
the expected scalar fields matching `params`."
[params result]
(t/is (uuid? (:id result)))
(t/is (uuid? (:ttf-file-id result)))
(t/is (uuid? (:otf-file-id result)))
(t/is (uuid? (:woff1-file-id result)))
(t/are [k] (= (get params k) (get result k))
:team-id
:font-id
:font-family
:font-weight
:font-style))
;; -----------------------------------------------------------------------
;; Path 1 Normal (direct :data bytes)
;; -----------------------------------------------------------------------
(t/deftest create-font-variant-normal-ttf
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 10)
data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "chunked-test"
:font-weight 400
:font-style "normal"
:data {"font/ttf" data}}
out (th/command! params)]
(t/is (= 1 (:call-count @mock)))
(t/is (nil? (:error out)))
(assert-font-variant-result params (:result out)))))
(t/deftest create-font-variant-normal-otf
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 11)
data (-> (io/resource "backend_tests/test_files/font-1.otf") (io/read*))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "chunked-test"
:font-weight 400
:font-style "normal"
:data {"font/otf" data}}
out (th/command! params)]
(t/is (= 1 (:call-count @mock)))
(t/is (nil? (:error out)))
(assert-font-variant-result params (:result out)))))
(t/deftest create-font-variant-normal-woff
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 12)
data (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "chunked-test"
:font-weight 400
:font-style "normal"
:data {"font/woff" data}}
out (th/command! params)]
(t/is (= 1 (:call-count @mock)))
(t/is (nil? (:error out)))
(assert-font-variant-result params (:result out)))))
;; -----------------------------------------------------------------------
;; Path 2 Legacy chunking (:data with vector of byte-arrays per mtype)
;; -----------------------------------------------------------------------
(t/deftest create-font-variant-legacy-chunked-ttf
"Upload a TTF via the legacy :data path where each mtype value is a
vector of byte-array chunks (4 MiB each) instead of a single byte-array."
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 20)
full-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
;; Simulate 4 MiB legacy chunks font is small so a single chunk suffices
chunks (split-bytes-into-chunks full-bytes (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "legacy-chunked"
:font-weight 700
:font-style "italic"
:data {"font/ttf" (vec chunks)}}
out (th/command! params)]
(t/is (= 1 (:call-count @mock)))
(t/is (nil? (:error out)))
(assert-font-variant-result params (:result out)))))
(t/deftest create-font-variant-legacy-chunked-woff
"Upload a WOFF via the legacy :data path with multiple sub-4 KiB chunks
to exercise the SequenceInputStream concatenation path."
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 21)
full-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*))
;; Split into small chunks to exercise the SequenceInputStream path
chunks (split-bytes-into-chunks full-bytes 512)
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "legacy-chunked-woff"
:font-weight 400
:font-style "normal"
:data {"font/woff" (vec chunks)}}
out (th/command! params)]
(t/is (= 1 (:call-count @mock)))
(t/is (nil? (:error out)))
(assert-font-variant-result params (:result out)))))
;; -----------------------------------------------------------------------
;; Path 3 New standardized chunked upload (:uploads map)
;; -----------------------------------------------------------------------
(t/deftest create-font-variant-chunked-upload-ttf
"Upload a TTF via the new :uploads path (chunked-upload API)."
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 30)
font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
session-id (upload-font-chunked! prof font-bytes "font/ttf" (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "new-chunked"
:font-weight 400
:font-style "normal"
:uploads {"font/ttf" session-id}}
out (th/command! params)]
;; quotes/check! is called at least once (for the font-variant quota) plus
;; once during session creation — assert it fired at least once.
(t/is (>= (:call-count @mock) 1))
(t/is (nil? (:error out)))
(assert-font-variant-result params (:result out)))))
(t/deftest create-font-variant-chunked-upload-otf
"Upload an OTF via the new :uploads path."
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 31)
font-bytes (-> (io/resource "backend_tests/test_files/font-1.otf") (io/read*))
session-id (upload-font-chunked! prof font-bytes "font/otf" (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "new-chunked-otf"
:font-weight 400
:font-style "normal"
:uploads {"font/otf" session-id}}
out (th/command! params)]
(t/is (>= (:call-count @mock) 1))
(t/is (nil? (:error out)))
(assert-font-variant-result params (:result out)))))
(t/deftest create-font-variant-chunked-upload-woff
"Upload a WOFF via the new :uploads path."
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 32)
font-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*))
session-id (upload-font-chunked! prof font-bytes "font/woff" (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "new-chunked-woff"
:font-weight 400
:font-style "normal"
:uploads {"font/woff" session-id}}
out (th/command! params)]
(t/is (>= (:call-count @mock) 1))
(t/is (nil? (:error out)))
(assert-font-variant-result params (:result out)))))
(t/deftest create-font-variant-chunked-upload-multi-chunk
"Upload a WOFF split into many small chunks to exercise multi-chunk assembly."
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 33)
font-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*))
;; Use a chunk-size smaller than 4 MiB to force multiple chunks while
;; staying within the 20-chunk-per-session quota limit (29836 / 2000 = ~15 chunks).
session-id (upload-font-chunked! prof font-bytes "font/woff" 2000)
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "multi-chunk-woff"
:font-weight 400
:font-style "normal"
:uploads {"font/woff" session-id}}
out (th/command! params)]
(t/is (>= (:call-count @mock) 1))
(t/is (nil? (:error out)))
(assert-font-variant-result params (:result out)))))
;; -----------------------------------------------------------------------
;; Error cases
;; -----------------------------------------------------------------------
(t/deftest create-font-variant-missing-data-and-uploads
"Neither :data nor :uploads is present — schema validation must reject it."
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 40)
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "bad"
:font-weight 400
:font-style "normal"}
out (th/command! params)]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))))
(t/deftest create-font-variant-chunked-upload-missing-chunks
"When only some chunks are uploaded the assembly step must fail."
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 41)
font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
;; 5000-byte chunks → 68640/5000 = 14 chunks; declare 15 but only upload 13
chunks (split-bytes-into-chunks font-bytes 5000)
;; Declare one extra chunk so assembly will fail (not all chunks present)
session-id (create-upload-session! prof (inc (count chunks)))]
;; Upload all real chunks except the last one (omit it so the session is incomplete)
(doseq [[idx chunk-data] (map-indexed vector (butlast chunks))]
(let [mfile (make-chunk-mfile chunk-data "font/ttf")
out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index idx
:content mfile})]
(t/is (nil? (:error out)))))
(let [out (th/command! {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "missing-chunks"
:font-weight 400
:font-style "normal"
:uploads {"font/ttf" session-id}})]
(t/is (some? (:error out)))))))
(t/deftest create-font-variant-chunked-upload-invalid-session
"Passing a non-existent session-id must fail at assembly time."
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 42)
out (th/command! {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "bad-session"
:font-weight 400
:font-style "normal"
:uploads {"font/ttf" (uuid/next)}})]
(t/is (some? (:error out))))))
;; -----------------------------------------------------------------------
;; Font size validation tests
;; -----------------------------------------------------------------------
(t/deftest create-font-variant-size-exceeded-normal
"Direct :data upload exceeding font-max-file-size must be rejected."
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
(with-redefs [app.config/config (assoc app.config/config :font-max-file-size 1)]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 50)
data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "size-exceeded"
:font-weight 400
:font-style "normal"
:data {"font/ttf" data}}
out (th/command! params)]
(t/is (some? (:error out)))
(t/is (= :restriction (-> out :error ex-data :type)))
(t/is (= :font-max-file-size-reached (-> out :error ex-data :code)))))))
(t/deftest create-font-variant-size-exceeded-legacy-chunked
"Legacy :data chunk-vector upload exceeding font-max-file-size must be rejected."
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
(with-redefs [app.config/config (assoc app.config/config :font-max-file-size 1)]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 51)
full-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*))
chunks (split-bytes-into-chunks full-bytes (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "size-exceeded-legacy"
:font-weight 400
:font-style "normal"
:data {"font/woff" (vec chunks)}}
out (th/command! params)]
(t/is (some? (:error out)))
(t/is (= :restriction (-> out :error ex-data :type)))
(t/is (= :font-max-file-size-reached (-> out :error ex-data :code)))))))
(t/deftest create-font-variant-size-exceeded-chunked-upload
"New :uploads path exceeding font-max-file-size must be rejected after assembly."
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 52)
font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
session-id (upload-font-chunked! prof font-bytes "font/ttf" (* 4 1024 1024))]
(with-redefs [app.config/config (assoc app.config/config :font-max-file-size 1)]
(let [out (th/command! {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "size-exceeded-chunked"
:font-weight 400
:font-style "normal"
:uploads {"font/ttf" session-id}})]
(t/is (some? (:error out)))
(t/is (= :restriction (-> out :error ex-data :type)))
(t/is (= :font-max-file-size-reached (-> out :error ex-data :code))))))))
(t/deftest create-font-variant-size-within-limit
"Upload exactly at the limit must succeed."
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 53)
font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
font-size (alength ^bytes font-bytes)]
(with-redefs [app.config/config (assoc app.config/config :font-max-file-size font-size)]
(let [params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "size-at-limit"
:font-weight 400
:font-style "normal"
:data {"font/ttf" font-bytes}}
out (th/command! params)]
(t/is (nil? (:error out)))
(assert-font-variant-result params (:result out)))))))
;; -----------------------------------------------------------------------
;; Font media-type validation tests
;; -----------------------------------------------------------------------
(t/deftest create-font-variant-invalid-type-normal
"Direct :data upload with a disallowed mtype must be rejected."
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 60)
data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "invalid-type"
:font-weight 400
:font-style "normal"
:data {"application/octet-stream" data}}
out (th/command! params)]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :media-type-not-allowed (-> out :error ex-data :code))))))
(t/deftest create-font-variant-invalid-type-legacy-chunked
"Legacy :data chunk-vector upload with a disallowed mtype must be rejected."
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 61)
full-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*))
chunks (split-bytes-into-chunks full-bytes (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "invalid-type-legacy"
:font-weight 400
:font-style "normal"
:data {"image/png" (vec chunks)}}
out (th/command! params)]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :media-type-not-allowed (-> out :error ex-data :code))))))
(t/deftest create-font-variant-invalid-type-chunked-upload
"New :uploads path with a disallowed mtype must be rejected after assembly."
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 62)
font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
;; Upload the bytes under a valid session but lie about the mtype
;; when calling create-font-variant.
session-id (upload-font-chunked! prof font-bytes "font/ttf" (* 4 1024 1024))
out (th/command! {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "invalid-type-chunked"
:font-weight 400
:font-style "normal"
:uploads {"image/jpeg" session-id}})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :media-type-not-allowed (-> out :error ex-data :code))))))

View File

@ -1,23 +1,23 @@
{:deps
{org.clojure/clojure {:mvn/version "1.12.4"}
org.clojure/data.json {:mvn/version "2.5.1"}
{org.clojure/clojure {:mvn/version "1.12.5"}
org.clojure/data.json {:mvn/version "2.5.2"}
org.clojure/tools.cli {:mvn/version "1.1.230"}
org.clojure/test.check {:mvn/version "1.1.1"}
org.clojure/data.fressian {:mvn/version "1.1.0"}
org.clojure/data.fressian {:mvn/version "1.1.1"}
org.clojure/clojurescript {:mvn/version "1.12.42"}
org.apache.commons/commons-pool2 {:mvn/version "2.12.1"}
;; Logging
org.apache.logging.log4j/log4j-api {:mvn/version "2.25.3"}
org.apache.logging.log4j/log4j-core {:mvn/version "2.25.3"}
org.apache.logging.log4j/log4j-web {:mvn/version "2.25.3"}
org.apache.logging.log4j/log4j-jul {:mvn/version "2.25.3"}
org.apache.logging.log4j/log4j-slf4j2-impl {:mvn/version "2.25.3"}
org.slf4j/slf4j-api {:mvn/version "2.0.17"}
org.apache.logging.log4j/log4j-api {:mvn/version "2.26.0"}
org.apache.logging.log4j/log4j-core {:mvn/version "2.26.0"}
org.apache.logging.log4j/log4j-web {:mvn/version "2.26.0"}
org.apache.logging.log4j/log4j-jul {:mvn/version "2.26.0"}
org.apache.logging.log4j/log4j-slf4j2-impl {:mvn/version "2.26.0"}
org.slf4j/slf4j-api {:mvn/version "2.0.18"}
pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.41"}
selmer/selmer {:mvn/version "1.12.70"}
selmer/selmer {:mvn/version "1.13.1"}
criterium/criterium {:mvn/version "0.4.6"}
metosin/jsonista {:mvn/version "0.3.13"}

View File

@ -13,8 +13,7 @@
#{"font/ttf"
"font/woff"
"font/woff2"
"font/otf"
"font/opentype"})
"font/otf"})
(def image-types
#{"image/jpeg"

View File

@ -5,7 +5,7 @@ ENV LANG='C.UTF-8' \
LC_ALL='C.UTF-8' \
JAVA_HOME="/opt/jdk" \
DEBIAN_FRONTEND=noninteractive \
NODE_VERSION=v22.22.0 \
NODE_VERSION=v24.15.0 \
TZ=Etc/UTC
RUN set -ex; \
@ -46,12 +46,12 @@ RUN set -eux; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
aarch64|arm64) \
ESUM='9903c6b19183a33725ca1dfdae5b72400c9d00995c76fafc4a0d31c5152f33f7'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_aarch64.tar.gz'; \
ESUM='cc1b459dc442d7422b46a3b5fe52acaea54879fa7913e29a05650cef54687f5f'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.30.11-ca-jdk26.0.1-linux_aarch64.tar.gz'; \
;; \
amd64|x86_64) \
ESUM='946ad9766d98fc6ab495a1a120072197db54997f6925fb96680f1ecd5591db4e'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_x64.tar.gz'; \
ESUM='7d6663ea8d4298df65de065e32f9f449745ff607d30ba5d13777cb92e9d4613d'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.30.11-ca-jdk26.0.1-linux_x64.tar.gz'; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; \
@ -68,7 +68,7 @@ RUN set -eux; \
--no-header-files \
--no-man-pages \
--strip-debug \
--add-modules java.base,jdk.management.agent,java.se,jdk.compiler,jdk.javadoc,jdk.attach,jdk.unsupported,jdk.jfr,jdk.jcmd \
--add-modules java.base,jdk.net,jdk.management.agent,java.se,jdk.compiler,jdk.javadoc,jdk.attach,jdk.unsupported,jdk.jfr,jdk.jcmd \
--output /opt/jre;
FROM ubuntu:24.04 AS image

View File

@ -3,7 +3,7 @@ LABEL maintainer="Penpot <docker@penpot.app>"
ENV LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
NODE_VERSION=v22.22.0 \
NODE_VERSION=v24.15.0 \
DEBIAN_FRONTEND=noninteractive \
PATH=/opt/node/bin:/opt/imagick/bin:$PATH \
PLAYWRIGHT_BROWSERS_PATH=/opt/penpot/browsers

View File

@ -1,4 +1,4 @@
FROM nginxinc/nginx-unprivileged:1.29.1
FROM nginxinc/nginx-unprivileged:1.30.0
LABEL maintainer="Penpot <docker@penpot.app>"
USER root

View File

@ -1,4 +1,4 @@
FROM nginxinc/nginx-unprivileged:1.29.1
FROM nginxinc/nginx-unprivileged:1.30.0
LABEL maintainer="Penpot <docker@penpot.app>"
USER root

View File

@ -24,7 +24,7 @@
# WARNING: if you're exposing Penpot to the internet, you should remove the flags
# 'disable-secure-session-cookies' and 'disable-email-verification'
x-flags: &penpot-flags
PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies
PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp
x-uri: &penpot-public-uri
PENPOT_PUBLIC_URI: http://localhost:9001
@ -78,7 +78,7 @@ services:
# - "443:443"
penpot-frontend:
image: "penpotapp/frontend:${PENPOT_VERSION:-latest}"
image: "penpotapp/frontend:${PENPOT_VERSION:-2.15}"
restart: always
ports:
- 9001:8080
@ -108,7 +108,7 @@ services:
<< : [*penpot-flags, *penpot-http-body-size, *penpot-public-uri]
penpot-backend:
image: "penpotapp/backend:${PENPOT_VERSION:-latest}"
image: "penpotapp/backend:${PENPOT_VERSION:-2.15}"
restart: always
volumes:
@ -176,8 +176,14 @@ services:
PENPOT_SMTP_TLS: false
PENPOT_SMTP_SSL: false
penpot-mcp:
image: "penpotapp/mcp:${PENPOT_VERSION:-2.15}"
restart: always
networks:
- penpot
penpot-exporter:
image: "penpotapp/exporter:${PENPOT_VERSION:-latest}"
image: "penpotapp/exporter:${PENPOT_VERSION:-2.15}"
restart: always
depends_on:

View File

@ -43,9 +43,10 @@ update_oidc_name /var/www/app/js/config.js
export PENPOT_BACKEND_URI=${PENPOT_BACKEND_URI:-http://penpot-backend:6060}
export PENPOT_EXPORTER_URI=${PENPOT_EXPORTER_URI:-http://penpot-exporter:6061}
export PENPOT_NITRATE_URI=${PENPOT_NITRATE_URI:-http://penpot-nitrate:3000}
export PENPOT_MCP_URI=${PENPOT_MCP_URI:-http://penpot-mcp}
export PENPOT_MCP_URI=${PENPOT_MCP_URI:-http://penpot-mcp:4401}
export PENPOT_MCP_URI_WS=${PENPOT_MCP_URI_WS:-http://penpot-mcp:4402}
export PENPOT_HTTP_SERVER_MAX_BODY_SIZE=${PENPOT_HTTP_SERVER_MAX_BODY_SIZE:-367001600} # Default to 350MiB
envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_NITRATE_URI,\$PENPOT_MCP_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE" \
envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_NITRATE_URI,\$PENPOT_MCP_URI,\$PENPOT_MCP_URI_WS,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE" \
< /tmp/nginx.conf.template > /etc/nginx/nginx.conf
PENPOT_DEFAULT_INTERNAL_RESOLVER="$(awk 'BEGIN{ORS=" "} $1=="nameserver" { sub(/%.*$/,"",$2); print ($2 ~ ":")? "["$2"]": $2}' /etc/resolv.conf)"

View File

@ -142,17 +142,17 @@ http {
location /mcp/ws {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_pass $PENPOT_MCP_URI:4402;
proxy_pass $PENPOT_MCP_URI_WS;
proxy_http_version 1.1;
}
location /mcp/stream {
proxy_pass $PENPOT_MCP_URI:4401/mcp;
proxy_pass $PENPOT_MCP_URI/mcp;
proxy_http_version 1.1;
}
location /mcp/sse {
proxy_pass $PENPOT_MCP_URI:4401/sse;
proxy_pass $PENPOT_MCP_URI/sse;
proxy_http_version 1.1;
}

View File

@ -425,6 +425,12 @@ In a high-availability (HA) scenario, managing the state outside of replicas is
- Valkey: Penpot only needs one Valkey instance to function correctly. Due to the nature of the data it manages, replication isn't even essential.
- User media storage: This should not be configured with local storage but rather with centralized storage, such as Kubernetes PVC or S3.
__Since version 2.15.0__
Starting with version 2.15, we have introduced the MCP server. Due to architectural constraints, using the MCP server requires running only a single instance of Penpot.
If the MCP server is not installed, then Penpot can scale normally and multiple application instances may be deployed without restrictions.
## Backend
This section enumerates the backend only configuration variables.

View File

@ -11,22 +11,22 @@
},
"type": "module",
"dependencies": {
"archiver": "^7.0.1",
"archiver": "^8.0.0",
"cookies": "^0.9.1",
"date-fns": "^4.1.0",
"generic-pool": "^3.9.0",
"inflation": "^2.1.0",
"ioredis": "^5.8.2",
"playwright": "^1.57.0",
"ioredis": "^5.10.1",
"playwright": "^1.60.0",
"raw-body": "^3.0.2",
"source-map-support": "^0.5.21",
"svgo": "penpot/svgo#v3.1",
"undici": "^7.16.0",
"undici": "^8.2.0",
"xml-js": "^1.6.11",
"xregexp": "^5.1.2"
},
"devDependencies": {
"ws": "^8.18.3"
"ws": "^8.20.1"
},
"scripts": {
"clear:shadow-cache": "rm -rf .shadow-cljs && rm -rf target",

532
exporter/pnpm-lock.yaml generated
View File

@ -9,8 +9,8 @@ importers:
.:
dependencies:
archiver:
specifier: ^7.0.1
version: 7.0.1
specifier: ^8.0.0
version: 8.0.0
cookies:
specifier: ^0.9.1
version: 0.9.1
@ -24,11 +24,11 @@ importers:
specifier: ^2.1.0
version: 2.1.0
ioredis:
specifier: ^5.8.2
version: 5.8.2
specifier: ^5.10.1
version: 5.10.1
playwright:
specifier: ^1.57.0
version: 1.57.0
specifier: ^1.60.0
version: 1.60.0
raw-body:
specifier: ^3.0.2
version: 3.0.2
@ -39,8 +39,8 @@ importers:
specifier: penpot/svgo#v3.1
version: https://codeload.github.com/penpot/svgo/tar.gz/a46262c12c0d967708395972c374eb2adead4180
undici:
specifier: ^7.16.0
version: 7.16.0
specifier: ^8.2.0
version: 8.2.0
xml-js:
specifier: ^1.6.11
version: 1.6.11
@ -49,8 +49,8 @@ importers:
version: 5.1.2
devDependencies:
ws:
specifier: ^8.18.3
version: 8.18.3
specifier: ^8.20.1
version: 8.20.1
packages:
@ -58,16 +58,8 @@ packages:
resolution: {integrity: sha512-h7iEYiW4HebClDEhtvFObtPmIvrd1SSfpI9EhOeKk4CtIK/ngBWFpuhCzhdmRKtg71ylcue+9I6dv54XYO1epQ==}
engines: {node: '>=6.9.0'}
'@ioredis/commands@1.4.0':
resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==}
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
'@ioredis/commands@1.5.1':
resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==}
'@trysound/sax@0.2.0':
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
@ -77,43 +69,24 @@ packages:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-regex@6.2.2:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
archiver-utils@5.0.2:
resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==}
engines: {node: '>= 14'}
archiver@7.0.1:
resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==}
engines: {node: '>= 14'}
archiver@8.0.0:
resolution: {integrity: sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==}
engines: {node: '>=18'}
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
b4a@1.7.3:
resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==}
b4a@1.8.1:
resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==}
peerDependencies:
react-native-b4a: '*'
peerDependenciesMeta:
react-native-b4a:
optional: true
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
balanced-match@4.0.4:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
bare-events@2.8.2:
resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==}
@ -123,14 +96,48 @@ packages:
bare-abort-controller:
optional: true
bare-fs@4.7.1:
resolution: {integrity: sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==}
engines: {bare: '>=1.16.0'}
peerDependencies:
bare-buffer: '*'
peerDependenciesMeta:
bare-buffer:
optional: true
bare-os@3.9.1:
resolution: {integrity: sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==}
engines: {bare: '>=1.14.0'}
bare-path@3.0.0:
resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==}
bare-stream@2.13.1:
resolution: {integrity: sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==}
peerDependencies:
bare-abort-controller: '*'
bare-buffer: '*'
bare-events: '*'
peerDependenciesMeta:
bare-abort-controller:
optional: true
bare-buffer:
optional: true
bare-events:
optional: true
bare-url@2.4.3:
resolution: {integrity: sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
brace-expansion@5.0.6:
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
engines: {node: 18 || 20 || >=22}
buffer-crc32@1.0.0:
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
@ -150,16 +157,9 @@ packages:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
compress-commons@6.0.2:
resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==}
engines: {node: '>= 14'}
compress-commons@7.0.1:
resolution: {integrity: sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==}
engines: {node: '>=18'}
cookies@0.9.1:
resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==}
@ -176,13 +176,9 @@ packages:
engines: {node: '>=0.8'}
hasBin: true
crc32-stream@6.0.0:
resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==}
engines: {node: '>= 14'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
crc32-stream@7.0.1:
resolution: {integrity: sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==}
engines: {node: '>=18'}
css-select@5.2.2:
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
@ -236,15 +232,6 @@ packages:
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
@ -263,10 +250,6 @@ packages:
fast-fifo@1.3.2:
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -276,13 +259,6 @@ packages:
resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==}
engines: {node: '>= 4'}
glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
hasBin: true
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
@ -301,27 +277,17 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ioredis@5.8.2:
resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==}
ioredis@5.10.1:
resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==}
engines: {node: '>=12.22.0'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
is-stream@2.0.1:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
is-stream@4.0.1:
resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==}
engines: {node: '>=18'}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
keygrip@1.1.0:
resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==}
engines: {node: '>= 0.6'}
@ -340,26 +306,15 @@ packages:
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
mdn-data@2.0.28:
resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==}
mdn-data@2.12.2:
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
minimatch@5.1.6:
resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==}
engines: {node: '>=10'}
minimatch@9.0.5:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
engines: {node: '>=16 || 14 >=14.17'}
minipass@7.1.2:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@ -371,24 +326,13 @@ packages:
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
path-scurry@1.11.1:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}
playwright-core@1.57.0:
resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==}
playwright-core@1.60.0:
resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
engines: {node: '>=18'}
hasBin: true
playwright@1.57.0:
resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==}
playwright@1.60.0:
resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==}
engines: {node: '>=18'}
hasBin: true
@ -410,8 +354,9 @@ packages:
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
readdir-glob@1.1.3:
resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==}
readdir-glob@3.0.0:
resolution: {integrity: sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==}
engines: {node: '>=18'}
redis-errors@1.2.0:
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
@ -436,18 +381,6 @@ packages:
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
shebang-regex@3.0.0:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
signal-exit@4.1.0:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@ -466,16 +399,8 @@ packages:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
streamx@2.23.0:
resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
string-width@5.1.2:
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
engines: {node: '>=12'}
streamx@2.25.0:
resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==}
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
@ -483,24 +408,19 @@ packages:
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
strip-ansi@7.1.2:
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
engines: {node: '>=12'}
svgo@https://codeload.github.com/penpot/svgo/tar.gz/a46262c12c0d967708395972c374eb2adead4180:
resolution: {tarball: https://codeload.github.com/penpot/svgo/tar.gz/a46262c12c0d967708395972c374eb2adead4180}
version: 4.0.0
engines: {node: '>=16.0.0'}
tar-stream@3.1.7:
resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==}
tar-stream@3.2.0:
resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==}
text-decoder@1.2.3:
resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==}
teex@1.0.1:
resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==}
text-decoder@1.2.7:
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
@ -510,9 +430,9 @@ packages:
resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==}
engines: {node: '>=0.6.x'}
undici@7.16.0:
resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==}
engines: {node: '>=20.18.1'}
undici@8.2.0:
resolution: {integrity: sha512-Z+4Hx9GE26Lh9Upwfnc8C7SsrpBPGaM/Gm6kMFtiG7c+5IvQKlXi/t+9x9DrrCh29cww5TSP9YdVaBcnLDs5fQ==}
engines: {node: '>=22.19.0'}
unpipe@1.0.0:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
@ -521,21 +441,8 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
wrap-ansi@8.1.0:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}
ws@8.18.3:
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
ws@8.20.1:
resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
@ -553,9 +460,9 @@ packages:
xregexp@5.1.2:
resolution: {integrity: sha512-6hGgEMCGhqCTFEJbqmWrNIPqfpdirdGWkqshu7fFZddmTSfgv5Sn9D2SaKloR79s5VUiUlpwzg3CM3G6D3VIlw==}
zip-stream@6.0.1:
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
engines: {node: '>= 14'}
zip-stream@7.0.5:
resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==}
engines: {node: '>=18'}
snapshots:
@ -563,19 +470,7 @@ snapshots:
dependencies:
core-js-pure: 3.47.0
'@ioredis/commands@1.4.0': {}
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
string-width-cjs: string-width@4.2.3
strip-ansi: 7.1.2
strip-ansi-cjs: strip-ansi@6.0.1
wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0
'@pkgjs/parseargs@0.11.0':
optional: true
'@ioredis/commands@1.5.1': {}
'@trysound/sax@0.2.0': {}
@ -583,54 +478,67 @@ snapshots:
dependencies:
event-target-shim: 5.0.1
ansi-regex@5.0.1: {}
ansi-regex@6.2.2: {}
ansi-styles@4.3.0:
archiver@8.0.0:
dependencies:
color-convert: 2.0.1
ansi-styles@6.2.3: {}
archiver-utils@5.0.2:
dependencies:
glob: 10.5.0
graceful-fs: 4.2.11
is-stream: 2.0.1
lazystream: 1.0.1
lodash: 4.17.21
normalize-path: 3.0.0
readable-stream: 4.7.0
archiver@7.0.1:
dependencies:
archiver-utils: 5.0.2
async: 3.2.6
buffer-crc32: 1.0.0
is-stream: 4.0.1
lazystream: 1.0.1
normalize-path: 3.0.0
readable-stream: 4.7.0
readdir-glob: 1.1.3
tar-stream: 3.1.7
zip-stream: 6.0.1
readdir-glob: 3.0.0
tar-stream: 3.2.0
zip-stream: 7.0.5
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
- react-native-b4a
async@3.2.6: {}
b4a@1.7.3: {}
b4a@1.8.1: {}
balanced-match@1.0.2: {}
balanced-match@4.0.4: {}
bare-events@2.8.2: {}
bare-fs@4.7.1:
dependencies:
bare-events: 2.8.2
bare-path: 3.0.0
bare-stream: 2.13.1(bare-events@2.8.2)
bare-url: 2.4.3
fast-fifo: 1.3.2
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
bare-os@3.9.1: {}
bare-path@3.0.0:
dependencies:
bare-os: 3.9.1
bare-stream@2.13.1(bare-events@2.8.2):
dependencies:
streamx: 2.25.0
teex: 1.0.1
optionalDependencies:
bare-events: 2.8.2
transitivePeerDependencies:
- react-native-b4a
bare-url@2.4.3:
dependencies:
bare-path: 3.0.0
base64-js@1.5.1: {}
boolbase@1.0.0: {}
brace-expansion@2.0.2:
brace-expansion@5.0.6:
dependencies:
balanced-match: 1.0.2
balanced-match: 4.0.4
buffer-crc32@1.0.0: {}
@ -645,17 +553,11 @@ snapshots:
cluster-key-slot@1.1.2: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
compress-commons@6.0.2:
compress-commons@7.0.1:
dependencies:
crc-32: 1.2.2
crc32-stream: 6.0.0
is-stream: 2.0.1
crc32-stream: 7.0.1
is-stream: 4.0.1
normalize-path: 3.0.0
readable-stream: 4.7.0
@ -670,17 +572,11 @@ snapshots:
crc-32@1.2.2: {}
crc32-stream@6.0.0:
crc32-stream@7.0.1:
dependencies:
crc-32: 1.2.2
readable-stream: 4.7.0
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
shebang-command: 2.0.0
which: 2.0.2
css-select@5.2.2:
dependencies:
boolbase: 1.0.0
@ -733,12 +629,6 @@ snapshots:
domelementtype: 2.3.0
domhandler: 5.0.3
eastasianwidth@0.2.0: {}
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
entities@4.5.0: {}
event-target-shim@5.0.1: {}
@ -753,27 +643,11 @@ snapshots:
fast-fifo@1.3.2: {}
foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
signal-exit: 4.1.0
fsevents@2.3.2:
optional: true
generic-pool@3.9.0: {}
glob@10.5.0:
dependencies:
foreground-child: 3.3.1
jackspeak: 3.4.3
minimatch: 9.0.5
minipass: 7.1.2
package-json-from-dist: 1.0.1
path-scurry: 1.11.1
graceful-fs@4.2.11: {}
http-errors@2.0.1:
dependencies:
depd: 2.0.0
@ -792,9 +666,9 @@ snapshots:
inherits@2.0.4: {}
ioredis@5.8.2:
ioredis@5.10.1:
dependencies:
'@ioredis/commands': 1.4.0
'@ioredis/commands': 1.5.1
cluster-key-slot: 1.1.2
debug: 4.4.3
denque: 2.1.0
@ -806,20 +680,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
is-fullwidth-code-point@3.0.0: {}
is-stream@2.0.1: {}
is-stream@4.0.1: {}
isarray@1.0.0: {}
isexe@2.0.0: {}
jackspeak@3.4.3:
dependencies:
'@isaacs/cliui': 8.0.2
optionalDependencies:
'@pkgjs/parseargs': 0.11.0
keygrip@1.1.0:
dependencies:
tsscmp: 1.0.6
@ -834,21 +698,13 @@ snapshots:
lodash@4.17.21: {}
lru-cache@10.4.3: {}
mdn-data@2.0.28: {}
mdn-data@2.12.2: {}
minimatch@5.1.6:
minimatch@10.2.5:
dependencies:
brace-expansion: 2.0.2
minimatch@9.0.5:
dependencies:
brace-expansion: 2.0.2
minipass@7.1.2: {}
brace-expansion: 5.0.6
ms@2.1.3: {}
@ -858,20 +714,11 @@ snapshots:
dependencies:
boolbase: 1.0.0
package-json-from-dist@1.0.1: {}
playwright-core@1.60.0: {}
path-key@3.1.1: {}
path-scurry@1.11.1:
playwright@1.60.0:
dependencies:
lru-cache: 10.4.3
minipass: 7.1.2
playwright-core@1.57.0: {}
playwright@1.57.0:
dependencies:
playwright-core: 1.57.0
playwright-core: 1.60.0
optionalDependencies:
fsevents: 2.3.2
@ -904,9 +751,9 @@ snapshots:
process: 0.11.10
string_decoder: 1.3.0
readdir-glob@1.1.3:
readdir-glob@3.0.0:
dependencies:
minimatch: 5.1.6
minimatch: 10.2.5
redis-errors@1.2.0: {}
@ -924,14 +771,6 @@ snapshots:
setprototypeof@1.2.0: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
shebang-regex@3.0.0: {}
signal-exit@4.1.0: {}
source-map-js@1.2.1: {}
source-map-support@0.5.21:
@ -945,27 +784,15 @@ snapshots:
statuses@2.0.2: {}
streamx@2.23.0:
streamx@2.25.0:
dependencies:
events-universal: 1.0.1
fast-fifo: 1.3.2
text-decoder: 1.2.3
text-decoder: 1.2.7
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
string-width@5.1.2:
dependencies:
eastasianwidth: 0.2.0
emoji-regex: 9.2.2
strip-ansi: 7.1.2
string_decoder@1.1.1:
dependencies:
safe-buffer: 5.1.2
@ -974,14 +801,6 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
strip-ansi@7.1.2:
dependencies:
ansi-regex: 6.2.2
svgo@https://codeload.github.com/penpot/svgo/tar.gz/a46262c12c0d967708395972c374eb2adead4180:
dependencies:
'@trysound/sax': 0.2.0
@ -990,18 +809,27 @@ snapshots:
csso: 5.0.5
lodash: 4.17.21
tar-stream@3.1.7:
tar-stream@3.2.0:
dependencies:
b4a: 1.7.3
b4a: 1.8.1
bare-fs: 4.7.1
fast-fifo: 1.3.2
streamx: 2.23.0
streamx: 2.25.0
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
- react-native-b4a
teex@1.0.1:
dependencies:
streamx: 2.25.0
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
text-decoder@1.2.3:
text-decoder@1.2.7:
dependencies:
b4a: 1.7.3
b4a: 1.8.1
transitivePeerDependencies:
- react-native-b4a
@ -1009,29 +837,13 @@ snapshots:
tsscmp@1.0.6: {}
undici@7.16.0: {}
undici@8.2.0: {}
unpipe@1.0.0: {}
util-deprecate@1.0.2: {}
which@2.0.2:
dependencies:
isexe: 2.0.0
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@8.1.0:
dependencies:
ansi-styles: 6.2.3
string-width: 5.1.2
strip-ansi: 7.1.2
ws@8.18.3: {}
ws@8.20.1: {}
xml-js@1.6.11:
dependencies:
@ -1041,8 +853,8 @@ snapshots:
dependencies:
'@babel/runtime-corejs3': 7.28.4
zip-stream@6.0.1:
zip-stream@7.0.5:
dependencies:
archiver-utils: 5.0.2
compress-commons: 6.0.2
compress-commons: 7.0.1
normalize-path: 3.0.0
readable-stream: 4.7.0

View File

@ -90,7 +90,7 @@
"npm-run-all": "^4.1.5",
"opentype.js": "^1.3.4",
"p-limit": "^7.3.0",
"playwright": "1.59.1",
"playwright": "1.60.0",
"postcss": "^8.5.8",
"postcss-clean": "^1.2.2",
"postcss-modules": "^6.0.1",

400
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -27,14 +27,6 @@ body {
width: 100vw;
height: 100vh;
overflow: hidden;
&.cursor-drag-scrub {
cursor: ew-resize !important;
* {
cursor: ew-resize !important;
}
}
}
#app {

View File

@ -378,14 +378,6 @@
border: $s-1 solid var(--input-border-color);
color: var(--input-foreground-color);
&:not(:focus-within) {
cursor: ew-resize;
input {
cursor: ew-resize;
}
}
span,
label {
@extend %input-label;

View File

@ -14,6 +14,7 @@
[app.common.uuid :as uuid]
[app.main.data.event :as ev]
[app.main.data.notifications :as ntf]
[app.main.data.uploads :as uploads]
[app.main.fonts :as fonts]
[app.main.repo :as rp]
[app.main.store :as st]
@ -24,24 +25,14 @@
[cuerdas.core :as str]
[potok.v2.core :as ptk]))
(def ^:const default-chunk-size
(* 1024 1024 4)) ;; 4MiB
(defn- chunk-array
[data chunk-size]
(let [total-size (alength data)]
(loop [offset 0
chunks []]
(if (< offset total-size)
(let [end (min (+ offset chunk-size) total-size)
chunk (.subarray ^js data offset end)]
(recur end (conj chunks chunk)))
chunks))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; General purpose events & IMPL
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private font-upload-chunk-size
"Size in bytes of each chunk when uploading font files (10 MiB)."
(* 1024 1024 10))
(defn fonts-fetched
[fonts]
(letfn [;; Prepare font to the internal font database format.
@ -94,9 +85,44 @@
(->> (rp/cmd! :get-font-variants {:team-id team-id})
(rx/map fonts-fetched)))))
(defn upload-font-variant
"Uploads a single font variant item using the chunked upload API.
For each mime-type in `data`, creates a Blob and uploads it via the
session-based chunked upload. Once all sessions are created, calls
`create-font-variant` with the resulting `:uploads` map so the server
can assemble the chunks and materialise the final font-variant record.
Returns an observable that emits the created font-variant."
[{:keys [data team-id font-id font-family font-weight font-style] :as _item}]
;; Upload each mtype as a separate chunked session in parallel, collect
;; all [mtype session-id] pairs, then call create-font-variant with :uploads.
(->> (rx/from (seq data))
(rx/mapcat (fn [[mtype buffer]]
(let [blob (js/Blob. #js [buffer] #js {:type mtype})]
(->> (uploads/upload-blob-chunked blob :chunk-size font-upload-chunk-size)
(rx/map (fn [{:keys [session-id]}]
[mtype session-id]))))))
(rx/reduce (fn [acc [mtype session-id]]
(assoc acc mtype session-id))
{})
(rx/mapcat (fn [uploads]
(rp/cmd! :create-font-variant
{:team-id team-id
:font-id font-id
:font-family font-family
:font-weight font-weight
:font-style font-style
:uploads uploads})))))
(defn process-upload
"Given a seq of blobs and the team id, creates a ready-to-use fonts
map with temporal ID's associated to each font entry."
map with temporal ID's associated to each font entry.
Each font entry's `:data` is a map of `{mtype -> ArrayBuffer}`. The
raw `ArrayBuffer` is kept as-is so that `upload-font-variant` can
wrap it in a `Blob` and hand it directly to `upload-blob-chunked`
without any intermediate client-side chunking."
[blobs team-id]
(letfn [(prepare [{:keys [font type name data] :as params}]
(if font
@ -134,7 +160,7 @@
(not= hhea-ascender os2-ascent)
(not= hhea-descender os2-descent))))
data (js/Uint8Array. data)]
{:content {:data (chunk-array data default-chunk-size)
{:content {:data data
:name name
:type type}
:font-family (or family "")
@ -152,7 +178,7 @@
(str/trim))
family-name (if (str/blank? raw-family-name) base-name raw-family-name)
data (js/Uint8Array. data)]
{:content {:data (chunk-array data default-chunk-size)
{:content {:data data
:name name
:type type}
:font-family family-name

View File

@ -24,10 +24,6 @@
[app.main.repo :as rp]
[beicon.v2.core :as rx]))
;; Size of each upload chunk in bytes. Reads the penpotUploadChunkSize global
;; variable at startup; defaults to 25 MiB (overridden in production).
(def ^:private chunk-size cf/upload-chunk-size)
(def ^:private max-parallel-chunk-uploads
"Maximum number of chunk upload requests that may be in-flight at the
same time within a single chunked upload session."
@ -44,8 +40,11 @@
Returns an observable that emits exactly one map:
`{:session-id <uuid>}`
The caller is responsible for the final step (assemble / import)."
[blob]
The caller is responsible for the final step (assemble / import).
The optional `opts` map accepts:
`:chunk-size` size in bytes of each chunk (default: `cf/upload-chunk-size`, 25 MiB)."
[blob & {:keys [chunk-size] :or {chunk-size cf/upload-chunk-size}}]
(let [total-size (.-size blob)
total-chunks (js/Math.ceil (/ total-size chunk-size))]
(->> (rp/cmd! :create-upload-session

View File

@ -1578,6 +1578,7 @@
(dm/export dwv/initialize-viewport)
(dm/export dwv/update-viewport-position)
(dm/export dwv/update-viewport-size)
(dm/export dwv/sync-wasm-workspace-viewport)
(dm/export dwv/start-panning)
(dm/export dwv/finish-panning)

View File

@ -1558,6 +1558,27 @@
:variants-count variants-count
:library-used-in (:used-in library-usage)}))))))))))
(defn cleanup-unlinked-libraries
"Remove libraries from state that are no longer linked to the given file.
This is used after unlinking a library to clean up transitive dependencies."
[file-id libraries]
(ptk/reify ::cleanup-unlinked-libraries
ptk/UpdateEvent
(update [_ state]
(let [linked-ids (into #{} (map :id) libraries)]
(update state :files
(fn [files]
(reduce-kv
(fn [acc id file]
(if (and (= (:library-of file) file-id)
(not (contains? linked-ids id))
(not= id file-id))
(dissoc acc id)
acc))
files
files)))))))
(defn unlink-file-from-library
[file-id library-id]
(ptk/reify ::detach-library
@ -1573,7 +1594,11 @@
ptk/WatchEvent
(watch [_ _ _]
(let [params {:file-id file-id
:library-id library-id}]
(->> (rp/cmd! :unlink-file-from-library params)
(rx/ignore))))))
;; Unlink the library, then fetch the current list of linked libraries
;; and remove any that are no longer linked (e.g., transitive dependencies)
(->> (rp/cmd! :unlink-file-from-library {:file-id file-id :library-id library-id})
(rx/mapcat (fn [_]
(rp/cmd! :get-file-libraries {:file-id file-id})))
(rx/map (partial cleanup-unlinked-libraries file-id))))))

View File

@ -27,6 +27,7 @@
[app.main.data.workspace.pages :as-alias dwpg]
[app.main.data.workspace.specialized-panel :as-alias dwsp]
[app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.viewport-wasm :as dwvw]
[app.main.data.workspace.zoom :as dwz]
[app.main.refs :as refs]
[app.main.router :as rt]
@ -602,6 +603,10 @@
(assoc :workspace-pre-focus (:workspace-local state)))
state))))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-sync-workspace-local-viewport! state))
ptk/WatchEvent
(watch [_ state stream]
(let [stopper (rx/filter #(or (= ::toggle-focus-mode (ptk/type %))

View File

@ -36,6 +36,7 @@
[app.main.fonts :as fonts]
[app.main.router :as rt]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.text-editor :as wasm.text-editor]
[app.util.text-editor :as ted]
[app.util.text.content.styles :as styles]
[app.util.timers :as ts]
@ -465,13 +466,23 @@
(when-not (some? (get-in state [:workspace-editor-state id]))
(let [objects (dsh/lookup-page-objects state)
shape (get objects id)
wasm? (features/active-feature? state "render-wasm/v1")
update-node? (fn [node]
(or (txt/is-text-node? node)
(txt/is-paragraph-node? node)))
shape-ids (cond
(cfh/text-shape? shape) [id]
(cfh/group-shape? shape) (cfh/get-children-ids objects id))]
(rx/of (dwsh/update-shapes shape-ids #(txt/update-text-content % update-node? d/txt-merge attrs))))))))
(cfh/group-shape? shape) (cfh/get-children-ids objects id))
;; Keep WASM editor cache in sync with merged :content so a following
;; `apply-styles-to-selection` in `update-attrs` does not read stale
;; `shape-text-contents` and overwrite per-run fills (e.g. line-height).
merge-shape
(fn [sh]
(let [updated-shape (txt/update-text-content sh update-node? d/txt-merge attrs)]
(when wasm?
(wasm.text-editor/cache-shape-text-content! (:id updated-shape) (:content updated-shape)))
updated-shape))]
(rx/of (dwsh/update-shapes shape-ids merge-shape)))))))
(defn migrate-node
[node]
@ -851,11 +862,13 @@
(effect [_ state _]
(when (features/active-feature? state "text-editor/v2")
(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)))))))
(when (seq attrs)
;; DOM `getCurrentStyle` reflects one resolved style (e.g. caret color). Merging
;; it with sidebar `attrs` and applying to the whole selection collapses mixed
;; fills/fonts when the user only changes one property (e.g. line-height).
;; Apply only the explicit attributes from this action.
(let [styles (styles/attrs->styles attrs)]
(editor.v2/applyStylesToSelection instance styles))))))))
(defn update-all-attrs
[ids attrs]

View File

@ -96,12 +96,14 @@
ptk/UpdateEvent
(update [_ state]
(update state :thumbnails
(fn [thumbs]
(if-let [uri (get thumbs object-id)]
(do (vreset! pending uri)
(dissoc thumbs object-id))
thumbs))))
(-> state
(update :thumbnails
(fn [thumbs]
(if-let [uri (get thumbs object-id)]
(do (vreset! pending uri)
(dissoc thumbs object-id))
thumbs)))
(update :thumbnails-meta dissoc object-id)))
ptk/WatchEvent
(watch [_ _ _]
@ -124,10 +126,13 @@
(ptk/reify ::assoc-thumbnail
ptk/UpdateEvent
(update [_ state]
(let [prev-uri (dm/get-in state [:thumbnails object-id])]
(let [prev-uri (dm/get-in state [:thumbnails object-id])
now (.now js/Date)]
(some->> prev-uri (vreset! prev-uri*))
(l/trc :hint "assoc thumbnail" :object-id object-id :uri uri)
(update state :thumbnails assoc object-id uri)))
(-> state
(update :thumbnails assoc object-id uri)
(update :thumbnails-meta assoc object-id {:rendered-at now}))))
ptk/EffectEvent
(effect [_ _ _]

View File

@ -63,8 +63,9 @@
(try
(let [objects (dsh/lookup-page-objects @st/state file-id page-id)]
(if-let [frame (get objects frame-id)]
(let [{:keys [width height]} (:selrect frame)
max-size (mth/max width height)
(let [{ext-w :width ext-h :height} (wasm.api/get-shape-extrect frame-id)
{sel-w :width sel-h :height} (:selrect frame)
max-size (mth/max (or ext-w sel-w) (or ext-h sel-h))
scale (mth/max 1 (/ target-size max-size))
png-bytes (wasm.api/render-shape-pixels frame-id scale)]
(if (or (nil? png-bytes) (zero? (.-length png-bytes)))

View File

@ -16,13 +16,19 @@
[app.common.math :as mth]
[app.main.data.event :as ev]
[app.main.data.helpers :as dsh]
[app.main.data.workspace.viewport-wasm :as dwvw]
[app.util.mouse :as mse]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
(defn- render-context-lost?
[state]
(true? (get-in state [:render-state :lost])))
(defn sync-wasm-workspace-viewport
"Effect-only: pushes the current workspace zoom/view box to WASM after other
events (e.g. `update-viewport-size`) have updated the store."
[]
(ptk/reify ::sync-wasm-workspace-viewport
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-sync-workspace-local-viewport! state))))
(defn initialize-viewport
[{:keys [width height] :as size}]
@ -86,7 +92,11 @@
(update [_ state]
(update state :workspace-local
(fn [local]
(setup state local)))))))
(setup state local))))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-sync-workspace-local-viewport! state)))))
(defn calculate-centered-viewbox
"Updates the viewbox coordinates for a given center position"
@ -105,9 +115,13 @@
(ptk/reify ::update-viewport-position-center
ptk/UpdateEvent
(update [_ state]
(if (render-context-lost? state)
(if (dwvw/render-context-lost? state)
state
(update state :workspace-local calculate-centered-viewbox position)))))
(update state :workspace-local calculate-centered-viewbox position)))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-sync-workspace-local-viewport! state))))
(defn update-viewport-position
[{:keys [x y] :or {x identity y identity}}]
@ -124,13 +138,17 @@
ptk/UpdateEvent
(update [_ state]
(if (render-context-lost? state)
(if (dwvw/render-context-lost? state)
state
(update-in state [:workspace-local :vbox]
(fn [vbox]
(-> vbox
(update :x x)
(update :y y))))))))
(update :y y))))))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-sync-workspace-local-viewport! state))))
(defn update-viewport-size
[resize-type {:keys [width height] :as size}]
@ -174,16 +192,27 @@
(assoc-in [:vbox :width] vbox-width')
(assoc-in [:vbox :height] vbox-height')))))))))
(defn- activate-panning []
(ptk/reify ::activate-panning
ptk/UpdateEvent
(update [_ state]
(-> state
(assoc-in [:workspace-local :panning] true)))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-view-interaction-start! state))))
(defn start-panning []
(ptk/reify ::start-panning
ptk/WatchEvent
(watch [_ state stream]
(let [stopper (->> stream (rx/filter (ptk/type? ::finish-panning)))
zoom (get-in state [:workspace-local :zoom])]
(when (and (not (render-context-lost? state))
(when (and (not (dwvw/render-context-lost? state))
(not (get-in state [:workspace-local :panning])))
(rx/concat
(rx/of #(-> % (assoc-in [:workspace-local :panning] true)))
(rx/of (activate-panning))
(->> stream
(rx/filter mse/pointer-event?)
(rx/filter #(some? (mse/get-pointer-movement %)))
@ -200,4 +229,8 @@
ptk/UpdateEvent
(update [_ state]
(-> state
(update :workspace-local dissoc :panning)))))
(update :workspace-local dissoc :panning)))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-view-interaction-end! state))))

View File

@ -0,0 +1,30 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.data.workspace.viewport-wasm
(:require
[app.main.features :as features]
[app.render-wasm.api :as wasm.api]))
(defn render-context-lost?
[state]
(true? (get-in state [:render-state :lost])))
(defn maybe-sync-workspace-local-viewport!
"When `render-wasm/v1` is active, pushes workspace zoom and vbox into WASM."
[state]
(when (and (features/active-feature? state "render-wasm/v1") (not (render-context-lost? state)))
(wasm.api/sync-workspace-local-viewport! state)))
(defn maybe-view-interaction-start!
[state]
(when (and (features/active-feature? state "render-wasm/v1") (not (render-context-lost? state)))
(wasm.api/view-interaction-start!)))
(defn maybe-view-interaction-end!
[state]
(when (and (features/active-feature? state "render-wasm/v1") (not (render-context-lost? state)))
(wasm.api/view-interaction-end!)))

View File

@ -16,15 +16,12 @@
[app.common.geom.shapes :as gsh]
[app.main.data.event :as ev]
[app.main.data.helpers :as dsh]
[app.main.data.workspace.viewport-wasm :as dwvw]
[app.main.streams :as ms]
[app.util.mouse :as mse]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
(defn- render-context-lost?
[state]
(true? (get-in state [:render-state :lost])))
(defn impl-update-zoom
[{:keys [vbox] :as local} center zoom]
(let [new-zoom (if (fn? zoom) (zoom (:zoom local)) zoom)
@ -47,11 +44,15 @@
ptk/UpdateEvent
(update [_ state]
(if (render-context-lost? state)
(if (dwvw/render-context-lost? state)
state
(let [center (if (= center ::auto) @ms/mouse-position center)]
(update state :workspace-local
#(impl-update-zoom % center (fn [z] (min (* z 1.3) 200))))))))))
#(impl-update-zoom % center (fn [z] (min (* z 1.3) 200)))))))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-sync-workspace-local-viewport! state)))))
(defn decrease-zoom
([]
@ -62,11 +63,15 @@
ptk/UpdateEvent
(update [_ state]
(if (render-context-lost? state)
(if (dwvw/render-context-lost? state)
state
(let [center (if (= center ::auto) @ms/mouse-position center)]
(update state :workspace-local
#(impl-update-zoom % center (fn [z] (max (/ z 1.3) 0.01))))))))))
#(impl-update-zoom % center (fn [z] (max (/ z 1.3) 0.01)))))))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-sync-workspace-local-viewport! state)))))
(defn set-zoom
([scale]
@ -77,7 +82,7 @@
ptk/UpdateEvent
(update [_ state]
(if (render-context-lost? state)
(if (dwvw/render-context-lost? state)
state
(let [vp (dm/get-in state [:workspace-local :vbox])
x (+ (:x vp) (/ (:width vp) 2))
@ -86,22 +91,30 @@
(update state :workspace-local
#(impl-update-zoom % center (fn [z] (-> (* z scale)
(max 0.01)
(min 200)))))))))))
(min 200))))))))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-sync-workspace-local-viewport! state)))))
(def reset-zoom
(ptk/reify ::reset-zoom
ptk/UpdateEvent
(update [_ state]
(if (render-context-lost? state)
(if (dwvw/render-context-lost? state)
state
(update state :workspace-local
#(impl-update-zoom % nil 1))))))
#(impl-update-zoom % nil 1))))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-sync-workspace-local-viewport! state))))
(def zoom-to-fit-all
(ptk/reify ::zoom-to-fit-all
ptk/UpdateEvent
(update [_ state]
(if (render-context-lost? state)
(if (dwvw/render-context-lost? state)
state
(let [page-id (:current-page-id state)
objects (dsh/lookup-page-objects state page-id)
@ -116,13 +129,17 @@
(-> local
(assoc :zoom zoom)
(assoc :zoom-inverse (/ 1 zoom))
(update :vbox merge srect)))))))))))
(update :vbox merge srect)))))))))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-sync-workspace-local-viewport! state))))
(def zoom-to-selected-shape
(ptk/reify ::zoom-to-selected-shape
ptk/UpdateEvent
(update [_ state]
(if (render-context-lost? state)
(if (dwvw/render-context-lost? state)
state
(let [selected (dsh/lookup-selected state)]
(if (empty? selected)
@ -139,14 +156,18 @@
(-> local
(assoc :zoom zoom)
(assoc :zoom-inverse (/ 1 zoom))
(update :vbox merge srect))))))))))))
(update :vbox merge srect))))))))))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-sync-workspace-local-viewport! state))))
(defn fit-to-shapes
[ids]
(ptk/reify ::fit-to-shapes
ptk/UpdateEvent
(update [_ state]
(if (or (render-context-lost? state) (empty? ids))
(if (or (dwvw/render-context-lost? state) (empty? ids))
state
(let [page-id (:current-page-id state)
objects (dsh/lookup-page-objects state page-id)
@ -164,16 +185,21 @@
(-> local
(assoc :zoom zoom)
(assoc :zoom-inverse (/ 1 zoom))
(update :vbox merge srect))))))))))
(update :vbox merge srect))))))))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-sync-workspace-local-viewport! state))))
(defn start-zooming [pt]
(ptk/reify ::start-zooming
ptk/WatchEvent
(watch [_ state stream]
(let [stopper (->> stream (rx/filter (ptk/type? ::finish-zooming)))]
(when (and (not (render-context-lost? state))
(when (and (not (dwvw/render-context-lost? state))
(not (get-in state [:workspace-local :zooming])))
(rx/concat
(rx/of (fn [s] (dwvw/maybe-view-interaction-start! s) s))
(rx/of #(-> % (assoc-in [:workspace-local :zooming] true)))
(->> stream
(rx/filter mse/pointer-event?)
@ -189,4 +215,7 @@
ptk/UpdateEvent
(update [_ state]
(-> state
(update :workspace-local dissoc :zooming)))))
(update :workspace-local dissoc :zooming)))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-view-interaction-end! state))))

View File

@ -588,6 +588,12 @@
(cf/resolve-media)))
st/state))
(defn workspace-thumbnail-rendered-at
[object-id]
(l/derived
#(dm/get-in % [:thumbnails-meta object-id :rendered-at])
st/state))
(def workspace-text-modifier
(l/derived :workspace-text-modifier st/state))

View File

@ -63,11 +63,6 @@
;; Last value input by the user we need to store to save on unmount
last-value* (mf/use-var value)
;; Drag scrubbing state
drag-state* (mf/use-ref :idle)
drag-start-x* (mf/use-ref 0)
drag-start-val* (mf/use-ref 0)
parse-value
(mf/use-fn
(mf/deps min-value max-value value nillable? default integer?)
@ -219,83 +214,18 @@
(dom/blur! node)))))
handle-focus
(mf/use-callback
(mf/use-fn
(mf/deps on-focus select-on-focus?)
(fn [event]
(when-not (= :dragging (mf/ref-val drag-state*))
(reset! last-value* (parse-value))
(let [target (dom/get-target event)]
(when on-focus
(mf/set-ref-val! dirty-ref true)
(on-focus event))
(when select-on-focus?
(dom/select-text! target)
;; In webkit browsers the mouseup event will be called after the on-focus causing and unselect
(.addEventListener target "mouseup" dom/prevent-default #js {:once true}))))))
on-scrub-pointer-down
(mf/use-fn
(mf/deps value value-str min-value max-value default)
(fn [event]
(let [disabled? (unchecked-get props "disabled")
node (mf/ref-val ref)
is-focused (and (some? node) (dom/active? node))]
(when-not (or disabled? is-focused (= :multiple value-str))
(let [client-x (.-clientX event)
start-val (or value default 0)]
(mf/set-ref-val! drag-state* :maybe-dragging)
(mf/set-ref-val! drag-start-x* client-x)
(mf/set-ref-val! drag-start-val* start-val)
(dom/capture-pointer event))))))
on-scrub-pointer-move
(mf/use-fn
(mf/deps apply-value update-input step-value min-value max-value)
(fn [event]
(let [state (mf/ref-val drag-state*)]
(when (or (= state :maybe-dragging) (= state :dragging))
(let [client-x (.-clientX event)
start-x (mf/ref-val drag-start-x*)
delta-x (- client-x start-x)]
(when (and (= state :maybe-dragging)
(>= (js/Math.abs delta-x) 3))
(mf/set-ref-val! drag-state* :dragging)
(dom/add-class! (dom/get-body) "cursor-drag-scrub"))
(when (= (mf/ref-val drag-state*) :dragging)
(let [effective-step (cond
(.-shiftKey event) (* step-value 10)
(.-ctrlKey event) (* step-value 0.1)
:else step-value)
steps (js/Math.round (/ delta-x 1))
new-val (+ (mf/ref-val drag-start-val*)
(* steps effective-step))
new-val (cond-> new-val
(d/num? min-value) (mth/max min-value)
(d/num? max-value) (mth/min max-value))]
(update-input new-val)
(apply-value event new-val))))))))
on-scrub-pointer-up
(mf/use-fn
(mf/deps ref)
(fn [event]
(let [state (mf/ref-val drag-state*)]
(when (= state :maybe-dragging)
(mf/set-ref-val! drag-state* :idle)
(dom/release-pointer event)
(when-let [node (mf/ref-val ref)]
(dom/focus! node)))
(when (= state :dragging)
(mf/set-ref-val! drag-state* :idle)
(dom/remove-class! (dom/get-body) "cursor-drag-scrub")
(dom/release-pointer event)))))
on-scrub-lost-pointer-capture
(mf/use-fn
(fn [_event]
(mf/set-ref-val! drag-state* :idle)
(dom/remove-class! (dom/get-body) "cursor-drag-scrub")))
(reset! last-value* (parse-value))
(let [target (dom/get-target event)]
(when on-focus
(mf/set-ref-val! dirty-ref true)
(on-focus event))
(when select-on-focus?
(dom/select-text! target)
;; In webkit browsers the mouseup event will be called after the on-focus causing and unselect
(.addEventListener target "mouseup" dom/prevent-default #js {:once true})))))
props (-> (obj/clone props)
(obj/unset! "selectOnFocus")
@ -310,11 +240,7 @@
(obj/set! "title" title)
(obj/set! "onKeyDown" handle-key-down)
(obj/set! "onBlur" handle-blur)
(obj/set! "onFocus" handle-focus)
(obj/set! "onPointerDown" on-scrub-pointer-down)
(obj/set! "onPointerMove" on-scrub-pointer-move)
(obj/set! "onPointerUp" on-scrub-pointer-up)
(obj/set! "onLostPointerCapture" on-scrub-lost-pointer-capture))]
(obj/set! "onFocus" handle-focus))]
(mf/with-effect [value]
(when-let [input-node (mf/ref-val ref)]

View File

@ -109,7 +109,7 @@
(mf/use-fn
(fn [{:keys [id] :as item}]
(swap! uploading* conj id)
(->> (rp/cmd! :create-font-variant item)
(->> (df/upload-font-variant item)
(rx/delay-at-least 2000)
(rx/subs! (fn [font]
(swap! fonts* dissoc id)

View File

@ -531,7 +531,6 @@
(when (and (= state :maybe-dragging)
(>= (js/Math.abs delta-x) 3))
(mf/set-ref-val! drag-state* :dragging)
(dom/add-class! (dom/get-body) "cursor-drag-scrub")
(when (fn? on-change-start)
(on-change-start)))
(when (= (mf/ref-val drag-state*) :dragging)
@ -559,7 +558,6 @@
(dom/focus! node)))
(when (= state :dragging)
(mf/set-ref-val! drag-state* :idle)
(dom/remove-class! (dom/get-body) "cursor-drag-scrub")
(dom/release-pointer event)
(when (fn? on-change-end)
(on-change-end)))))))
@ -571,7 +569,6 @@
(when-not is-token-applied?
(let [was-dragging (= :dragging (mf/ref-val drag-state*))]
(mf/set-ref-val! drag-state* :idle)
(dom/remove-class! (dom/get-body) "cursor-drag-scrub")
(when (and was-dragging (fn? on-change-end))
(on-change-end))))))

View File

@ -77,7 +77,8 @@
(mf/deps vport)
(fn [resize-type size]
(when (and vport (not= size vport))
(st/emit! (dw/update-viewport-size resize-type size)))))
(st/emit! (dw/update-viewport-size resize-type size)
(dw/sync-wasm-workspace-viewport)))))
on-resize-palette
(mf/use-fn

View File

@ -376,7 +376,12 @@
;; Initialize colorpicker state
(mf/with-effect []
(st/emit! (dc/initialize-colorpicker on-change active-fill-tab))
(partial st/emit! (dc/finalize-colorpicker)))
;; Always deactivate picking mode on unmount so that :picking-color? never
;; stays true if the modal closes for any reason other than the normal
;; pointer-up path (e.g. ESC, navigation, programmatic hide).
(fn []
(st/emit! (dc/stop-picker)
(dc/finalize-colorpicker))))
;; Update colorpicker with external color changes
(mf/with-effect [data]

View File

@ -290,8 +290,8 @@
:data-testid "text-editor-container"
:style {:width "var(--editor-container-width)"
:height "var(--editor-container-height)"
:min-width "1px"
:min-height "1px"}}
:min-width "var(--editor-container-min-width, 1px)"
:min-height "var(--editor-container-min-height, 1px)"}}
;; We hide the editor when is blurred because otherwise the
;; selection won't let us see the underlying text. Use opacity
;; because display or visibility won't allow to recover focus
@ -381,7 +381,7 @@
render-wasm? (mf/use-memo #(features/active-feature? @st/state "render-wasm/v1"))
[{:keys [x y width height]} transform]
[{:keys [x y width height selrect-width selrect-height]} transform]
(if render-wasm?
(let [{:keys [width height]} (wasm.api/get-text-dimensions shape-id)
selrect-transform (mf/deref refs/workspace-selrect)
@ -403,7 +403,8 @@
"bottom" (+ y (- selrect-height height))
"center" (+ y (/ (- selrect-height height) 2))
y)]
[(assoc selrect :y y :width overlay-width :height max-height) transform])
[(assoc selrect :y y :width overlay-width :height max-height
:selrect-width selrect-width :selrect-height selrect-height) transform])
(let [bounds (gst/shape->rect shape)
x (mth/min (dm/get-prop bounds :x)
@ -422,6 +423,8 @@
(obj/merge!
#js {"--editor-container-width" "auto"
"--editor-container-height" "auto"
"--editor-container-min-width" (dm/str selrect-width "px")
"--editor-container-min-height" (dm/str selrect-height "px")
"--fallback-families" (if (seq fallback-families) (dm/str (str/join ", " fallback-families)) "sourcesanspro")
:display "flex"})

View File

@ -324,14 +324,35 @@
current-page-id (mf/deref refs/current-page-id)
thumbnail-requested? (mf/use-ref false)
thumbnail-uri*
object-id
(mf/with-memo [file-id page-id root-id]
(let [object-id (thc/fmt-object-id file-id page-id root-id "component")]
(refs/workspace-thumbnail-by-id object-id)))
(thc/fmt-object-id file-id page-id root-id "component"))
thumbnail-uri*
(mf/with-memo [object-id]
(refs/workspace-thumbnail-by-id object-id))
thumbnail-uri
(mf/deref thumbnail-uri*)
rendered-at*
(mf/with-memo [object-id]
(refs/workspace-thumbnail-rendered-at object-id))
rendered-at
(mf/deref rendered-at*)
modified-at
(some-> (:modified-at component) (.getTime))
;; Stale if there's no in-session render record
;; or the component was modified after the last render
stale?
(and (some? thumbnail-uri)
(or (nil? rendered-at)
(and (some? modified-at)
(> modified-at rendered-at))))
on-error
(mf/use-fn
(mf/deps @retry)
@ -340,20 +361,21 @@
(inc retry))))]
;; Lazy WASM thumbnail rendering: when the component becomes
;; visible, has no cached thumbnail, and lives on the current page
;; trigger a render. Ref is used to avoid triggering multiple renders
;; while the component is still not rendered and the thumbnail URI
;; is not available.
;; visible and either has no cached thumbnail or the cached one is
;; stale relative to the last recorded edit, trigger a render. Ref
;; is used to avoid triggering multiple renders while the previous
;; render is in flight.
(mf/use-effect
(mf/deps is-hidden thumbnail-uri wasm? current-page-id file-id page-id)
(mf/deps is-hidden thumbnail-uri stale? wasm? current-page-id file-id page-id)
(fn []
(if (some? thumbnail-uri)
(if (and (some? thumbnail-uri) (not stale?))
(mf/set-ref-val! thumbnail-requested? false)
(when (and wasm? (not is-hidden) (not (mf/ref-val thumbnail-requested?)) (= page-id current-page-id))
(mf/set-ref-val! thumbnail-requested? true)
(st/emit! (dwt.wasm/render-thumbnail file-id page-id root-id))))))
(if (and (some? thumbnail-uri)
(not stale?)
(or (contains? cf/flags :component-thumbnails)
wasm?))
[:& component-svg-thumbnail

View File

@ -90,7 +90,7 @@
(d/without-nils))
prev-color (d/seek (partial get groups) prev-colors)
color-operations-old (get groups old-color)
color-operations-prev (get groups prev-colors)
color-operations-prev (get groups prev-color)
color-operations (or color-operations-prev color-operations-old)
old-color (or prev-color old-color)]
[color-operations old-color]))
@ -115,11 +115,17 @@
;; TODO: Review if this is still necessary.
prev-colors-ref (mf/use-ref nil)
;; Always keep this ref pointing to the latest groups so that on-change
;; (which may be captured stale by the colorpicker's rx subscription) can
;; still read the current groups and find the correct color operations.
groups-ref (mf/use-ref nil)
_ (mf/set-ref-val! groups-ref groups)
on-change
(mf/use-fn
(mf/deps groups)
(fn [old-color new-color from-picker?]
(let [prev-colors (mf/ref-val prev-colors-ref)
(let [groups (mf/ref-val groups-ref)
prev-colors (mf/ref-val prev-colors-ref)
[color-operations old-color] (retrieve-color-operations groups old-color prev-colors)]
;; TODO: Review if this is still necessary.

View File

@ -9,8 +9,6 @@
.element-set {
@include sidebar.option-grid-structure;
position: relative;
}
.element-title {

View File

@ -196,11 +196,6 @@
display: flex;
align-items: center;
&:not(:focus-within) {
cursor: ew-resize;
}
block-size: $sz-32;
inline-size: px2rem(60);
padding-inline-start: var(--sp-xs);
@ -260,11 +255,6 @@
margin: var(--sp-xxs) 0;
padding: 0 0 0 px2rem(6);
color: var(--color-foreground-primary);
cursor: ew-resize;
&:focus {
cursor: text;
}
&[disabled] {
opacity: 0.5;

View File

@ -84,15 +84,17 @@
;; - If the user clicks again during the transition, keep showing the original (A) snapshot
(if (and (features/active-feature? @st/state "render-wasm/v1")
(not= id current-page-id))
(do
(-> (wasm.api/apply-canvas-blur)
(p/finally
(fn []
;; NOTE: it seems we need two RAF so the blur is actually applied and visible
;; in the canvas :(
(timers/raf
(fn []
(timers/raf navigate-fn)))))))
(-> (if @wasm.api/page-transition?
(p/resolved nil)
(wasm.api/capture-canvas-snapshot-url))
(p/finally
(fn []
(wasm.api/apply-canvas-blur)
;; NOTE: it seems we need two RAF so the blur is actually applied and visible
;; in the canvas :(
(timers/raf
(fn []
(timers/raf navigate-fn))))))
(navigate-fn)))))
on-delete

View File

@ -53,26 +53,22 @@
new-canvas))))))))
(defn process-pointer-move
[viewport-node canvas canvas-image-data zoom-view-context client-x client-y]
[viewport-node canvas canvas-image-data zoom-view-context last-picked-color client-x client-y]
(when-let [image-data (mf/ref-val canvas-image-data)]
(when-let [zoom-view-node (dom/get-element "picker-detail")]
(when-not (mf/ref-val zoom-view-context)
(mf/set-ref-val! zoom-view-context (.getContext zoom-view-node "2d")))
(let [canvas-width 260
(let [canvas-width 260
canvas-height 140
{brx :left bry :top} (dom/get-bounding-rect viewport-node)
x (mth/floor (- client-x brx))
y (mth/floor (- client-y bry))
zoom-context (mf/ref-val zoom-view-context)
offset (* (+ (* y (unchecked-get image-data "width")) x) 4)
rgba (unchecked-get image-data "data")
img-width (unchecked-get image-data "width")
img-height (unchecked-get image-data "height")
r (obj/get rgba (+ 0 offset))
g (obj/get rgba (+ 1 offset))
b (obj/get rgba (+ 2 offset))
a (obj/get rgba (+ 3 offset))
zoom-context (mf/ref-val zoom-view-context)
sx (- x 32)
sy (if (cfg/check-browser? :safari) y (- y 17))
@ -82,13 +78,27 @@
dy 0
dw canvas-width
dh canvas-height]
(when (obj/get zoom-context "imageSmoothingEnabled")
(obj/set! zoom-context "imageSmoothingEnabled" false))
(.clearRect zoom-context 0 0 canvas-width canvas-height)
(.drawImage zoom-context canvas sx sy sw sh dx dy dw dh)
(js/requestAnimationFrame
(fn []
(st/emit! (dwc/pick-color [r g b a]))))))))
;; Only pick color when cursor is within canvas bounds to avoid garbage pixels
(when (and (>= x 0) (< x img-width) (>= y 0) (< y img-height))
(let [offset (* (+ (* y img-width) x) 4)
rgba (unchecked-get image-data "data")
r (obj/get rgba (+ 0 offset))
g (obj/get rgba (+ 1 offset))
b (obj/get rgba (+ 2 offset))
a (obj/get rgba (+ 3 offset))
color [r g b a]]
;; Store latest color synchronously so the click handler always reads
;; the correct pixel even before the rAF fires (fixes race condition)
(mf/set-ref-val! last-picked-color color)
(js/requestAnimationFrame
(fn []
(st/emit! (dwc/pick-color color))))))))))
(mf/defc pixel-overlay
@ -103,8 +113,14 @@
canvas-context (.getContext canvas "2d" #js {:willReadFrequently true})
canvas-image-data (mf/use-ref nil)
zoom-view-context (mf/use-ref nil)
canvas-ready (mf/use-state false)
initial-mouse-pos (mf/use-state {:x 0 :y 0})
;; Holds the last successfully picked [r g b a] synchronously so that
;; the pointer-down handler always has the current pixel, regardless of
;; whether the rAF-deferred store update has fired yet.
last-picked-color (mf/use-ref nil)
;; Use a ref (not state) so tracking the cursor doesn't cause re-renders.
;; Updated by both on-mouse-enter and a document-level pointermove listener
;; so that the position is always current when the canvas first becomes ready.
initial-mouse-pos (mf/use-ref {:x 0 :y 0})
update-str (rx/subject)
handle-keydown
@ -121,8 +137,15 @@
(fn [event]
(dom/prevent-default event)
(dom/stop-propagation event)
(st/emit! (dwu/start-undo-transaction :mouse-down-picker)
(dwc/pick-color-select true (kbd/shift? event)))))
;; Emit pick-color synchronously with the latest pixel colour before
;; pick-color-select, so the colorpicker effect never sees a stale value.
(let [color (mf/ref-val last-picked-color)]
(if (some? color)
(st/emit! (dwu/start-undo-transaction :mouse-down-picker)
(dwc/pick-color color)
(dwc/pick-color-select true (kbd/shift? event)))
(st/emit! (dwu/start-undo-transaction :mouse-down-picker)
(dwc/pick-color-select true (kbd/shift? event)))))))
handle-pointer-up-picker
(mf/use-callback
@ -143,16 +166,20 @@
:result "image-bitmap"}]
(->> (fonts/render-font-styles-cached fonts)
(rx/map (fn [styles]
(assoc result
:styles styles)))
(assoc result :styles styles)))
(rx/mapcat thr/render-node)
(rx/subs! (fn [image-bitmap]
(.drawImage canvas-context image-bitmap 0 0)
(let [width (unchecked-get canvas "width")
height (unchecked-get canvas "height")
image-data (.getImageData canvas-context 0 0 width height)]
image-data (.getImageData canvas-context 0 0 width height)
;; Read current mouse position from ref so the zoom
;; is populated immediately even without a mouse-move.
{mx :x my :y} (mf/ref-val initial-mouse-pos)]
(mf/set-ref-val! canvas-image-data image-data)
(reset! canvas-ready true))))))))
(process-pointer-move viewport-node canvas canvas-image-data
zoom-view-context last-picked-color
mx my))))))))
handle-svg-change
(mf/use-callback
@ -163,19 +190,28 @@
(mf/use-callback
(mf/deps viewport-node)
(fn [event]
(let [x (.-clientX event)
y (.-clientY event)]
(reset! initial-mouse-pos {:x x
:y y}))))
(mf/set-ref-val! initial-mouse-pos
{:x (.-clientX event)
:y (.-clientY event)})))
handle-pointer-move-picker
(mf/use-callback
(mf/deps viewport-node)
(fn [event]
(process-pointer-move viewport-node canvas canvas-image-data zoom-view-context (.-clientX event) (.-clientY event))))]
(process-pointer-move viewport-node canvas canvas-image-data zoom-view-context
last-picked-color (.-clientX event) (.-clientY event))))]
(when (obj/get canvas-context "imageSmoothingEnabled")
(obj/set! canvas-context "imageSmoothingEnabled" false))
;; Move focus to the overlay div on mount so the eyedropper button loses
;; :focus styling immediately. Without this, prevent-default on pointer-down
;; keeps focus on the button and it looks "selected" even after picking.
(mf/use-effect
(fn []
(when-let [node (dom/get-element "pixel-overlay")]
(.focus node))))
(mf/use-effect
(fn []
(let [listener (events/listen ug/document "keydown" handle-keydown)]
@ -202,12 +238,17 @@
;; Disconnect on unmount
#(.disconnect observer))))
;; Track the cursor position at document level so initial-mouse-pos is always
;; current when the canvas first becomes ready — even when the picker is opened
;; via the "i" shortcut and the cursor hasn't entered/moved over the overlay yet.
(mf/use-effect
(mf/deps viewport-node @canvas-ready)
(fn []
(when canvas-ready
(let [{:keys [x y]} @initial-mouse-pos]
(process-pointer-move viewport-node canvas canvas-image-data zoom-view-context x y)))))
(let [listener (events/listen ug/document "pointermove"
(fn [e]
(mf/set-ref-val! initial-mouse-pos
{:x (.-clientX e)
:y (.-clientY e)})))]
#(events/unlistenByKey listener))))
[:div {:id "pixel-overlay"
:tab-index 0
@ -218,12 +259,13 @@
:on-mouse-enter handle-mouse-enter}]))
(defn process-pointer-move-wasm [viewport-node canvas canvas-image-data zoom-view-context client-x client-y]
(defn process-pointer-move-wasm
[viewport-node canvas canvas-image-data zoom-view-context last-picked-color client-x client-y]
(when-let [image-data (mf/ref-val canvas-image-data)]
(when-let [zoom-view-node (dom/get-element "picker-detail")]
(when-not (mf/ref-val zoom-view-context)
(mf/set-ref-val! zoom-view-context (.getContext zoom-view-node "2d")))
(let [zoom-view-width 260
(let [zoom-view-width 260
zoom-view-height 140
{brx :left bry :top} (dom/get-bounding-rect viewport-node)
x (mth/floor (- client-x brx))
@ -232,31 +274,39 @@
canvas-x (* x wasm.api/dpr)
canvas-y (* y wasm.api/dpr)
zoom-context (mf/ref-val zoom-view-context)
;; the image-data we have is an array of pixels, starting from the
;; bottom-left corner; so we need to calculate the offset accordingly
inverted-y (- (.-height image-data) canvas-y)
offset (* (+ (* inverted-y (.-width image-data)) canvas-x) 4)
rgba (.-data image-data)
img-width (.-width image-data)
img-height (.-height image-data)
r (obj/get rgba (+ 0 offset))
g (obj/get rgba (+ 1 offset))
b (obj/get rgba (+ 2 offset))
a (obj/get rgba (+ 3 offset))
zoom-context (mf/ref-val zoom-view-context)
sx (- canvas-x 32)
sy (if (cfg/check-browser? :safari) canvas-y (- canvas-y 17))
sw 65
sh 35]
(when (obj/get zoom-context "imageSmoothingEnabled")
(obj/set! zoom-context "imageSmoothingEnabled" false))
(.clearRect zoom-context 0 0 zoom-view-width zoom-view-height)
(.drawImage zoom-context canvas sx sy sw sh 0 0 zoom-view-width zoom-view-height)
;; FIXME: this is throttled to avoid getting stuck in an inifinite react
;; update loop. We should fix the global state instead.
(js/requestAnimationFrame
(fn []
(st/emit! (dwc/pick-color [r g b a]))))))))
;; Only pick color when cursor is within canvas bounds to avoid garbage pixels
(when (and (>= canvas-x 0) (< canvas-x img-width) (>= canvas-y 0) (< canvas-y img-height))
(let [;; image-data pixels start from the bottom-left corner; invert y accordingly
inverted-y (- img-height canvas-y)
offset (* (+ (* inverted-y img-width) canvas-x) 4)
rgba (.-data image-data)
r (obj/get rgba (+ 0 offset))
g (obj/get rgba (+ 1 offset))
b (obj/get rgba (+ 2 offset))
a (obj/get rgba (+ 3 offset))
color [r g b a]]
;; Store latest color synchronously so the click handler always reads
;; the correct pixel even before the rAF fires (fixes race condition)
(mf/set-ref-val! last-picked-color color)
;; rAF throttles state updates to avoid an infinite React re-render loop
(js/requestAnimationFrame
(fn []
(st/emit! (dwc/pick-color color))))))))))
(mf/defc pixel-overlay-wasm*
{::mf/wrap-props false}
@ -266,7 +316,14 @@
canvas-context (mf/use-ref nil)
canvas-image-data (mf/use-ref nil)
zoom-view-context (mf/use-ref nil)
initial-mouse-pos (mf/use-state {:x 0 :y 0})
;; Holds the last successfully picked [r g b a] synchronously so that
;; the pointer-down handler always has the current pixel, regardless of
;; whether the rAF-deferred store update has fired yet.
last-picked-color (mf/use-ref nil)
;; Use a ref (not state) so tracking the cursor doesn't cause re-renders.
;; Updated by both on-mouse-enter and a document-level pointermove listener
;; so that the position is always current when the canvas first becomes ready.
initial-mouse-pos (mf/use-ref {:x 0 :y 0})
update-str (rx/subject)
handle-keydown
@ -283,8 +340,15 @@
(fn [event]
(dom/prevent-default event)
(dom/stop-propagation event)
(st/emit! (dwu/start-undo-transaction :mouse-down-picker)
(dwc/pick-color-select true (kbd/shift? event)))))
;; Emit pick-color synchronously with the latest pixel colour before
;; pick-color-select, so the colorpicker effect never sees a stale value.
(let [color (mf/ref-val last-picked-color)]
(if (some? color)
(st/emit! (dwu/start-undo-transaction :mouse-down-picker)
(dwc/pick-color color)
(dwc/pick-color-select true (kbd/shift? event)))
(st/emit! (dwu/start-undo-transaction :mouse-down-picker)
(dwc/pick-color-select true (kbd/shift? event)))))))
handle-pointer-up-picker
(mf/use-callback
@ -300,12 +364,18 @@
(mf/deps canvas-context)
(fn []
(when-let [canvas-context (mf/ref-val canvas-context)]
(let [width (.-width canvas)
(let [width (.-width canvas)
height (.-height canvas)
buffer (js/Uint8ClampedArray. (* width height 4))
_ (.readPixels canvas-context 0 0 width height (.-RGBA canvas-context) (.-UNSIGNED_BYTE canvas-context) buffer)
image-data (js/ImageData. buffer width height)]
(mf/set-ref-val! canvas-image-data image-data)))))
buffer (js/Uint8ClampedArray. (* width height 4))
_ (.readPixels canvas-context 0 0 width height (.-RGBA canvas-context) (.-UNSIGNED_BYTE canvas-context) buffer)
image-data (js/ImageData. buffer width height)
;; Read current mouse position from ref so the zoom
;; is populated immediately even without a mouse-move.
{mx :x my :y} (mf/ref-val initial-mouse-pos)]
(mf/set-ref-val! canvas-image-data image-data)
(process-pointer-move-wasm viewport-node canvas canvas-image-data
zoom-view-context last-picked-color
mx my)))))
handle-canvas-changed
(mf/use-callback
@ -316,25 +386,33 @@
(mf/use-callback
(mf/deps viewport-node)
(fn [event]
(let [x (.-clientX event)
y (.-clientY event)]
(reset! initial-mouse-pos {:x x
:y y}))))
(mf/set-ref-val! initial-mouse-pos
{:x (.-clientX event)
:y (.-clientY event)})))
handle-pointer-move-picker
(mf/use-callback
(mf/deps viewport-node)
(fn [event]
(process-pointer-move-wasm viewport-node canvas canvas-image-data zoom-view-context (.-clientX event) (.-clientY event))))]
(process-pointer-move-wasm viewport-node canvas canvas-image-data zoom-view-context last-picked-color (.-clientX event) (.-clientY event))))]
(mf/use-effect
(mf/deps canvas)
(fn []
(let [context (.getContext canvas "webgl2" #js {:willReadFrequently true, :preserveDrawingBuffer true})]
(let [context (.getContext canvas "webgl2" #js {:willReadFrequently true :preserveDrawingBuffer true})]
(mf/set-ref-val! canvas-context context))))
;; Move focus to the overlay div on mount so the eyedropper button loses
;; :focus styling immediately. Without this, prevent-default on pointer-down
;; keeps focus on the button and it looks "selected" even after picking.
(mf/use-effect
(fn []
(when-let [node (dom/get-element "pixel-overlay")]
(.focus node))))
(mf/use-effect
(fn []
(let [listener (events/listen ug/document "keydown" handle-keydown)]
(let [listener (events/listen ug/document "keydown" handle-keydown)]
#(events/unlistenByKey listener))))
(mf/use-effect
@ -350,12 +428,17 @@
(fn []
(.removeEventListener ug/document "penpot:wasm:render" handle-canvas-changed)))
;; Track the cursor position at document level so initial-mouse-pos is always
;; current when the canvas first becomes ready — even when the picker is opened
;; via the "i" shortcut and the cursor hasn't entered/moved over the overlay yet.
(mf/use-effect
(mf/deps viewport-node canvas canvas-image-data zoom-view-context)
(fn []
(when (some? canvas)
(let [{:keys [x y]} @initial-mouse-pos]
(process-pointer-move-wasm viewport-node canvas canvas-image-data zoom-view-context x y)))))
(let [listener (events/listen ug/document "pointermove"
(fn [e]
(mf/set-ref-val! initial-mouse-pos
{:x (.-clientX e)
:y (.-clientY e)})))]
#(events/unlistenByKey listener))))
[:div {:id "pixel-overlay"
:tab-index 0

View File

@ -420,7 +420,6 @@
(vreset! unmounted? true)
(when-let [timeout-id @timeout-id-ref]
(js/clearTimeout timeout-id))
(wasm.api/end-page-transition!)
(wasm.api/clear-canvas)))))
(mf/with-effect [show-text-editor? workspace-editor-state edition]
@ -437,7 +436,8 @@
(mf/with-effect [vport]
(when (and @canvas-init? @initialized?)
(wasm.api/resize-viewbox (:width vport) (:height vport))))
(wasm.api/resize-viewbox (:width vport) (:height vport))
(wasm.api/set-view-box zoom vbox)))
(mf/with-effect [@canvas-init? preview-blend]
(when (and @canvas-init? preview-blend)
@ -468,10 +468,6 @@
(wasm.api/clear-focus-mode)
(wasm.api/set-focus-mode focus)))))
(mf/with-effect [vbox zoom]
(when (and @canvas-init? @initialized?)
(wasm.api/set-view-box zoom vbox)))
(mf/with-effect [background]
(when (and @canvas-init? @initialized?)
(wasm.api/set-canvas-background background)))

View File

@ -81,25 +81,8 @@
(defonce transition-tiles-handler* (atom nil))
(defonce snapshot-tiles-handler* (atom nil))
(def ^:private transition-blur-css "blur(4px)")
(def ^:private snapshot-capture-debounce-ms 250)
(defn- set-transition-blur!
[]
(when-let [canvas ^js wasm/canvas]
(dom/set-style! canvas "filter" transition-blur-css))
(when-let [nodes (.querySelectorAll ^js ug/document ".blurrable")]
(doseq [^js node (array-seq nodes)]
(dom/set-style! node "filter" transition-blur-css))))
(defn- clear-transition-blur!
[]
(when-let [canvas ^js wasm/canvas]
(dom/set-style! canvas "filter" ""))
(when-let [nodes (.querySelectorAll ^js ug/document ".blurrable")]
(doseq [^js node (array-seq nodes)]
(dom/set-style! node "filter" ""))))
(defn set-transition-image-from-background!
"Sets `transition-image-url*` to a data URL representing a solid background color."
[background]
@ -121,8 +104,7 @@
(when-let [prev @transition-tiles-handler*]
(.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" prev))
(reset! transition-tiles-handler* nil)
(reset! transition-image-url* nil)
(clear-transition-blur!))
(reset! transition-image-url* nil))
(defn- set-transition-tiles-complete-handler!
"Installs a tiles-complete handler bound to the current transition epoch.
@ -226,6 +208,8 @@
(def ^:const DEBOUNCE_DELAY_MS 100)
(defonce ^:private view-interaction-active? (atom false))
;; Time budget (ms) per chunk of shape processing before yielding to browser
(def ^:private ^:const CHUNK_TIME_BUDGET_MS 8)
;; Threshold below which we use synchronous processing (no chunking overhead)
@ -1164,14 +1148,26 @@
(= result 1))
false))
(defn view-interaction-start!
[]
(when-not @view-interaction-active?
(h/call wasm/internal-module "_set_view_start")
(reset! view-interaction-active? true)))
(defn view-interaction-end!
[]
(when @view-interaction-active?
(perf/begin-measure "render-finish")
(h/call wasm/internal-module "_set_view_end")
(perf/end-measure "render-finish")
(reset! view-interaction-active? false)))
(def render-finish
(letfn [(do-render []
;; Check if context is still initialized before executing
;; to prevent errors when navigating quickly
(when (and wasm/context-initialized? (not @wasm/context-lost?))
(perf/begin-measure "render-finish")
(h/call wasm/internal-module "_set_view_end")
(perf/end-measure "render-finish")
(view-interaction-end!)
;; Use async _render: visible tiles render synchronously
;; (no yield), interest-area tiles render progressively
;; via rAF. _set_view_end already rebuilt the tile
@ -1185,7 +1181,7 @@
(defn set-view-box
[zoom vbox]
(perf/begin-measure "set-view-box")
(h/call wasm/internal-module "_set_view_start")
(view-interaction-start!)
(h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox)))
(perf/end-measure "set-view-box")
@ -1194,6 +1190,16 @@
(render-finish)
(perf/end-measure "render-from-cache"))
(defn sync-workspace-local-viewport!
"Pushes `[:workspace-local :zoom]` and `:vbox` into WASM."
[state]
(when (and wasm/context-initialized?
(not @wasm/context-lost?))
(let [zoom (get-in state [:workspace-local :zoom])
vbox (get-in state [:workspace-local :vbox])]
(when (and zoom vbox)
(set-view-box zoom vbox)))))
(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
@ -1362,6 +1368,7 @@
;; Rebuild the tile index so _render knows which shapes
;; map to which tiles after a page switch.
(h/call wasm/internal-module "_set_view_end")
(reset! view-interaction-active? false)
;; Text layouts must run after _end_loading (they
;; depend on state that is only correct when loading
@ -1420,6 +1427,7 @@
;; Rebuild the tile index so _render knows which shapes
;; map to which tiles after a page switch.
(h/call wasm/internal-module "_set_view_end")
(reset! view-interaction-active? false)
(process-pending shapes thumbnails full
(fn []
(if render-callback
@ -2161,33 +2169,15 @@
(let [already? @page-transition?
epoch (begin-page-transition!)]
(set-transition-tiles-complete-handler! epoch end-page-transition!)
;; Two-phase transition:
;; - Apply CSS blur to the live canvas immediately (no async wait), so the user
;; sees the transition right away.
;; - In parallel, capture a `blob:` snapshot URL; once ready, switch the overlay
;; to that fixed image (and guard with `epoch` to avoid stale async updates).
(set-transition-blur!)
;; Lock the snapshot for the whole transition: if the user clicks to another page
;; while the transition is active, keep showing the original page snapshot until
;; the final target page finishes rendering.
(if already?
(p/resolved nil)
(do
;; If we already have a snapshot URL, use it immediately.
(when-let [url wasm/canvas-snapshot-url]
(when (string? url)
(reset! transition-image-url* url)))
;; Capture a fresh snapshot asynchronously and update the overlay as soon
;; as it is ready (guarded by `epoch` to avoid stale async updates).
(-> (capture-canvas-snapshot-url)
(p/then (fn [url]
(when (and (string? url)
@page-transition?
(= epoch @transition-epoch*))
(reset! transition-image-url* url))
url))
(p/catch (fn [_] nil)))))))
;; the final target page finishes rendering. The caller (sitemap on-click) is
;; responsible for ensuring `wasm/canvas-snapshot-url` was freshly captured
;; before invoking us.
(when-not already?
(when-let [url wasm/canvas-snapshot-url]
(when (string? url)
(reset! transition-image-url* url))))))
(defn render-shape-pixels
[shape-id scale]
@ -2208,6 +2198,24 @@
(mem/free)
result))
(defn get-shape-extrect
[shape-id]
(let [buffer (uuid/get-u32 shape-id)
offset (h/call wasm/internal-module "_get_shape_extrect"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3))]
(when (and (number? offset) (pos? offset))
(let [heapf32 (mem/get-heap-f32)
base (mem/->offset-32 offset)
x (aget heapf32 base)
y (aget heapf32 (+ base 1))
w (aget heapf32 (+ base 2))
h (aget heapf32 (+ base 3))]
(mem/free)
{:x x :y y :width w :height h}))))
(defn init-wasm-module
[module]
(let [default-fn (unchecked-get module "default")

View File

@ -165,6 +165,15 @@
(js/console.warn "[debug] render-wasm module not ready or missing _debug_atlas_base64")
""))))
(defn ^:export wasmSurfaceConsole
"Logs the render-wasm surface id as an image in the JS console."
[id]
(let [module wasm/internal-module
f (when module (unchecked-get module "_debug_surface_console"))]
(if (fn? f)
(wasm.h/call module "_debug_surface_console" id)
(js/console.warn "[debug] render-wasm module not ready or missing _debug_surface_console"))))
(defn ^:export wasmCacheConsole
"Logs the current render-wasm cache surface as an image in the JS console."
[]

138
render-wasm/src/globals.rs Normal file
View File

@ -0,0 +1,138 @@
use macros::wasm_error;
use crate::mem;
use crate::render::{gpu_state::GpuState, RenderState};
use crate::state::{State, TextEditorState};
static mut DESIGN_STATE: *mut State = std::ptr::null_mut();
/// Design State.
pub(crate) fn get_design_state() -> &'static mut State {
unsafe {
debug_assert!(!DESIGN_STATE.is_null(), "Design State is null");
&mut *DESIGN_STATE
}
}
/// GPU State.
static mut GPU_STATE: *mut GpuState = std::ptr::null_mut();
#[inline(always)]
pub(crate) fn get_gpu_state() -> &'static mut GpuState {
unsafe {
debug_assert!(!GPU_STATE.is_null(), "GPU State is null");
&mut *GPU_STATE
}
}
/// Render State.
static mut RENDER_STATE: *mut RenderState = std::ptr::null_mut();
#[inline(always)]
pub(crate) fn get_render_state() -> &'static mut RenderState {
unsafe {
debug_assert!(!RENDER_STATE.is_null(), "Render State is null");
&mut *RENDER_STATE
}
}
/// Text Editor State
static mut TEXT_EDITOR_STATE: *mut TextEditorState = std::ptr::null_mut();
#[inline(always)]
pub(crate) fn get_text_editor_state() -> &'static mut TextEditorState {
unsafe {
debug_assert!(!TEXT_EDITOR_STATE.is_null(), "Text Editor state is null");
&mut *TEXT_EDITOR_STATE
}
}
// FIXME: These with_state* macros should be using our CriticalError instead of expect.
// But to do that, we need to not use them at domain-level (i.e. in business logic), just
// in the context of the wasm call.
#[macro_export]
macro_rules! with_state {
($state:ident, $block:block) => {{
use $crate::globals::get_design_state;
let $state = get_design_state();
$block
}};
}
#[macro_export]
macro_rules! with_current_shape_mut {
($state:ident, |$shape:ident: &mut Shape| $block:block) => {
use $crate::globals::get_design_state;
let $state = get_design_state();
$state.touch_current();
if let Some($shape) = $state.current_shape_mut() {
$block
}
};
}
#[macro_export]
macro_rules! with_current_shape {
($state:ident, |$shape:ident: &Shape| $block:block) => {
use $crate::globals::get_design_state;
let $state = get_design_state();
if let Some($shape) = $state.current_shape() {
$block
}
};
}
/// Initializes GPUState.
fn gpu_init() {
unsafe {
let gpu_state = GpuState::try_new().expect("Cannot initialize GPU State");
GPU_STATE = Box::into_raw(Box::new(gpu_state));
}
}
/// Initializes RenderState.
fn render_init(width: i32, height: i32) {
unsafe {
let render_state =
RenderState::try_new(width, height).expect("Cannot intialize RenderState");
RENDER_STATE = Box::into_raw(Box::new(render_state));
}
}
/// Initializes DesignState.
fn design_init() {
unsafe {
let design_state = State::new();
DESIGN_STATE = Box::into_raw(Box::new(design_state));
}
}
fn text_editor_init() {
unsafe {
let text_editor_state = TextEditorState::new();
TEXT_EDITOR_STATE = Box::into_raw(Box::new(text_editor_state));
}
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn init(width: i32, height: i32) -> Result<()> {
gpu_init();
render_init(width, height);
text_editor_init();
design_init();
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn clean_up() -> Result<()> {
// Cancel the current animation frame if it exists so
// it won't try to render without context
let render_state = get_render_state();
render_state.cancel_animation_frame();
render_state.prepare_context_loss_cleanup();
unsafe { DESIGN_STATE = std::ptr::null_mut() }
mem::free_bytes()?;
Ok(())
}

View File

@ -1,6 +1,7 @@
#[cfg(target_arch = "wasm32")]
mod emscripten;
mod error;
mod globals;
mod math;
mod mem;
mod performance;
@ -18,181 +19,26 @@ use std::collections::HashMap;
#[allow(unused_imports)]
use crate::error::{Error, Result};
use crate::state::TextEditorState;
use globals::{get_design_state, get_gpu_state, get_render_state};
use macros::wasm_error;
use math::{Bounds, Matrix};
use mem::SerializableResult;
use render::{gpu_state::GpuState, RenderState};
use shapes::{StructureEntry, StructureEntryType, TransformEntry};
use skia_safe as skia;
use state::State;
use utils::uuid_from_u32_quartet;
use uuid::Uuid;
pub(crate) static mut STATE: Option<Box<State>> = None;
pub(crate) static mut TEXT_EDITOR_STATE: *mut TextEditorState = std::ptr::null_mut();
#[inline(always)]
pub fn get_text_editor_state() -> &'static mut TextEditorState {
unsafe {
debug_assert!(!TEXT_EDITOR_STATE.is_null(), "Text Editor state is null");
&mut *TEXT_EDITOR_STATE
}
}
/// GPU State.
static mut GPU_STATE: *mut GpuState = std::ptr::null_mut();
#[inline(always)]
pub(crate) fn get_gpu_state() -> &'static mut GpuState {
unsafe {
debug_assert!(!GPU_STATE.is_null(), "GPU State is null");
&mut *GPU_STATE
}
}
/// Render State.
static mut RENDER_STATE: *mut RenderState = std::ptr::null_mut();
#[inline(always)]
pub(crate) fn get_render_state() -> &'static mut RenderState {
unsafe {
debug_assert!(!RENDER_STATE.is_null(), "Render State is null");
&mut *RENDER_STATE
}
}
// FIXME: These with_state* macros should be using our CriticalError instead of expect.
// But to do that, we need to not use them at domain-level (i.e. in business logic), just
// in the context of the wasm call.
#[macro_export]
macro_rules! with_state_mut {
($state:ident, $block:block) => {{
let $state = unsafe {
#[allow(static_mut_refs)]
STATE.as_mut()
}
.expect("Got an invalid state pointer");
$block
}};
}
#[macro_export]
macro_rules! with_state {
($state:ident, $block:block) => {{
let $state = unsafe {
#[allow(static_mut_refs)]
STATE.as_ref()
}
.expect("Got an invalid state pointer");
$block
}};
}
#[macro_export]
macro_rules! with_current_shape_mut {
($state:ident, |$shape:ident: &mut Shape| $block:block) => {
let $state = unsafe {
#[allow(static_mut_refs)]
STATE.as_mut()
}
.expect("Got an invalid state pointer");
$state.touch_current();
if let Some($shape) = $state.current_shape_mut() {
$block
}
};
}
#[macro_export]
macro_rules! with_current_shape {
($state:ident, |$shape:ident: &Shape| $block:block) => {
let $state = unsafe {
#[allow(static_mut_refs)]
STATE.as_ref()
}
.expect("Got an invalid state pointer");
if let Some($shape) = $state.current_shape() {
$block
}
};
}
#[macro_export]
macro_rules! with_state_mut_current_shape {
($state:ident, |$shape:ident: &Shape| $block:block) => {
let $state = unsafe {
#[allow(static_mut_refs)]
STATE.as_mut()
}
.expect("Got an invalid state pointer");
if let Some($shape) = $state.current_shape() {
$block
}
};
}
/// Initializes GPU.
fn gpu_init() {
unsafe {
let gpu_state = GpuState::try_new().expect("Cannot initialize GPU State");
GPU_STATE = Box::into_raw(Box::new(gpu_state));
}
}
/// Initializes RenderState.
fn render_init(width: i32, height: i32) {
unsafe {
let render_state =
RenderState::try_new(width, height).expect("Cannot intialize RenderState");
RENDER_STATE = Box::into_raw(Box::new(render_state));
}
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn init(width: i32, height: i32) -> Result<()> {
gpu_init();
render_init(width, height);
unsafe {
let state_box = Box::new(State::new());
STATE = Some(state_box);
TEXT_EDITOR_STATE = Box::into_raw(Box::new(TextEditorState::new()));
}
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_browser(browser: u8) -> Result<()> {
with_state_mut!(state, {
with_state!(state, {
state.set_browser(browser);
});
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn clean_up() -> Result<()> {
// Cancel the current animation frame if it exists so
// it won't try to render without context
unsafe {
#[allow(static_mut_refs)]
if STATE.is_some() {
// Cancel the current animation frame if it exists so
// it won't try to render without context.
let render_state = get_render_state();
render_state.cancel_animation_frame();
render_state.prepare_context_loss_cleanup();
}
STATE = None;
}
mem::free_bytes()?;
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_render_options(debug: u32, dpr: f32) -> Result<()> {
@ -255,7 +101,7 @@ pub extern "C" fn set_max_atlas_texture_size(max_px: i32) -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> {
with_state_mut!(state, {
with_state!(state, {
let color = skia::Color::new(raw_color);
state.set_background_color(color);
state.rebuild_tiles_shallow();
@ -267,7 +113,7 @@ pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn render(timestamp: i32) -> Result<()> {
with_state_mut!(state, {
with_state!(state, {
state.rebuild_touched_tiles();
// Drain the throttled modifier-tile invalidation accumulated
// since the previous rAF. set_modifiers skips this work during
@ -290,7 +136,7 @@ pub extern "C" fn render(timestamp: i32) -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn render_sync() -> Result<()> {
with_state_mut!(state, {
with_state!(state, {
state.rebuild_tiles();
state
.render_sync(0)
@ -302,7 +148,7 @@ pub extern "C" fn render_sync() -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn render_sync_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> {
with_state_mut!(state, {
with_state!(state, {
let id = uuid_from_u32_quartet(a, b, c, d);
state.use_shape(id);
@ -328,7 +174,7 @@ pub extern "C" fn render_sync_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()
#[no_mangle]
#[wasm_error]
pub extern "C" fn render_from_cache(_: i32) -> Result<()> {
with_state_mut!(state, {
with_state!(state, {
// Don't cancel the animation frame — let the async render
// continue populating the tile HashMap in the background.
// process_animation_frame skips flush_and_submit in fast
@ -351,7 +197,7 @@ pub extern "C" fn set_preview_mode(enabled: bool) -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn render_preview() -> Result<()> {
with_state_mut!(state, {
with_state!(state, {
state.render_preview(performance::get_time());
});
Ok(())
@ -361,7 +207,7 @@ pub extern "C" fn render_preview() -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn begin_loading() -> Result<()> {
with_state_mut!(state, {
with_state!(state, {
state.loading = true;
});
Ok(())
@ -372,7 +218,7 @@ pub extern "C" fn begin_loading() -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn end_loading() -> Result<()> {
with_state_mut!(state, {
with_state!(state, {
state.loading = false;
});
Ok(())
@ -394,7 +240,7 @@ pub extern "C" fn render_loading_overlay() -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn process_animation_frame(timestamp: i32) -> Result<()> {
let result = with_state_mut!(state, { state.process_animation_frame(timestamp) });
let result = with_state!(state, { state.process_animation_frame(timestamp) });
if let Err(err) = result {
eprintln!("process_animation_frame error: {}", err);
}
@ -449,7 +295,7 @@ pub extern "C" fn set_view_start() -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_view_end() -> Result<()> {
with_state_mut!(state, {
with_state!(state, {
performance::begin_measure!("set_view_end");
let render_state = get_render_state();
render_state.options.set_fast_mode(false);
@ -518,7 +364,7 @@ pub extern "C" fn set_modifiers_end() -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn clear_focus_mode() -> Result<()> {
with_state_mut!(state, {
with_state!(state, {
state.clear_focus_mode();
});
Ok(())
@ -534,7 +380,7 @@ pub extern "C" fn set_focus_mode() -> Result<()> {
.map(|data| Uuid::try_from(data).map_err(|e| Error::RecoverableError(e.to_string())))
.collect::<Result<Vec<Uuid>>>()?;
with_state_mut!(state, {
with_state!(state, {
state.set_focus_mode(entries);
});
Ok(())
@ -543,7 +389,7 @@ pub extern "C" fn set_focus_mode() -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn init_shapes_pool(capacity: usize) -> Result<()> {
with_state_mut!(state, {
with_state!(state, {
state.init_shapes_pool(capacity);
});
Ok(())
@ -552,7 +398,7 @@ pub extern "C" fn init_shapes_pool(capacity: usize) -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn use_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> {
with_state_mut!(state, {
with_state!(state, {
let id = uuid_from_u32_quartet(a, b, c, d);
state.use_shape(id);
});
@ -562,7 +408,7 @@ pub extern "C" fn use_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn touch_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> {
with_state_mut!(state, {
with_state!(state, {
let shape_id = uuid_from_u32_quartet(a, b, c, d);
state.touch_shape(shape_id);
});
@ -572,7 +418,7 @@ pub extern "C" fn touch_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_parent(a: u32, b: u32, c: u32, d: u32) -> Result<()> {
with_state_mut!(state, {
with_state!(state, {
let id = uuid_from_u32_quartet(a, b, c, d);
state.set_parent_for_current_shape(id);
});
@ -658,7 +504,7 @@ fn set_children_set(entries: Vec<Uuid>) -> Result<()> {
}
});
with_state_mut!(state, {
with_state!(state, {
let Some(parent_id) = parent_id else {
return Err(Error::RecoverableError(
"set_children_set: Parent ID not found".to_string(),
@ -891,7 +737,7 @@ pub extern "C" fn get_selection_rect() -> Result<*mut u8> {
})
.collect();
let result_bound = with_state_mut!(state, {
let result_bound = with_state!(state, {
let bbs: Vec<_> = entries
.iter()
.flat_map(|id| state.shapes.get(id).map(|b| b.bounds()))
@ -938,7 +784,7 @@ pub extern "C" fn set_structure_modifiers() -> Result<()> {
})
.collect::<Result<Vec<_>>>()?;
with_state_mut!(state, {
with_state!(state, {
let mut structure = HashMap::new();
let mut scale_content = HashMap::new();
for entry in entries {
@ -977,7 +823,7 @@ pub extern "C" fn set_structure_modifiers() -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn clean_modifiers() -> Result<()> {
with_state_mut!(state, {
with_state!(state, {
let render_state = get_render_state();
let prev_modifier_ids = state.shapes.clean_all();
// Skip the tile-cache cleanup during interactive transform: the
@ -1008,7 +854,7 @@ pub extern "C" fn set_modifiers() -> Result<()> {
ids.push(entry.id);
}
with_state_mut!(state, {
with_state!(state, {
state.set_modifiers(modifiers);
// TO CHECK
if !get_render_state().options.is_interactive_transform() {
@ -1021,31 +867,36 @@ pub extern "C" fn set_modifiers() -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn start_temp_objects() -> Result<()> {
unsafe {
#[allow(static_mut_refs)]
let mut state = STATE.take().ok_or(Error::CriticalError(
"Got an invalid state pointer".to_string(),
))?;
state = Box::new(state.start_temp_objects()?);
STATE = Some(state);
}
get_design_state().start_temp_objects()?;
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn end_temp_objects() -> Result<()> {
unsafe {
#[allow(static_mut_refs)]
let mut state = STATE.take().ok_or(Error::CriticalError(
"Got an invalid state pointer".to_string(),
))?;
state = Box::new(state.end_temp_objects()?);
STATE = Some(state);
}
get_design_state().end_temp_objects()?;
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn get_shape_extrect(a: u32, b: u32, c: u32, d: u32) -> Result<*mut u8> {
let id = uuid_from_u32_quartet(a, b, c, d);
with_state!(state, {
let Some(shape) = state.shapes.get(&id) else {
return Err(Error::CriticalError("Shape not found".to_string()));
};
let extrect = get_render_state().get_cached_extrect(shape, &state.shapes, 1.0);
let mut buf = Vec::with_capacity(16);
buf.extend_from_slice(&extrect.x().to_le_bytes());
buf.extend_from_slice(&extrect.y().to_le_bytes());
buf.extend_from_slice(&extrect.width().to_le_bytes());
buf.extend_from_slice(&extrect.height().to_le_bytes());
Ok(mem::write_bytes(buf))
})
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn render_shape_pixels(
@ -1061,7 +912,7 @@ pub extern "C" fn render_shape_pixels(
return Err(Error::CriticalError("Scale is not finite".to_string()));
}
with_state_mut!(state, {
with_state!(state, {
let (data, width, height) =
state.render_shape_pixels(&id, scale, performance::get_time())?;

View File

@ -21,6 +21,7 @@ use options::RenderOptions;
pub use surfaces::{SurfaceId, Surfaces};
use crate::error::{Error, Result};
use crate::math;
use crate::shapes::{
all_with_ancestors, radius_to_sigma, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor,
Stroke, StrokeKind, TextContent, Type,
@ -452,7 +453,8 @@ impl RenderState {
};
// Only allow using the cached pixels for pure translations.
// For non-translation transforms (scale/rotate/skew), cached pixels won't match.
if !crate::math::is_move_only_matrix(m) {
// If the transform is the identity means a reflow, we need to redraw as well.
if math::identitish(m) || !math::is_move_only_matrix(m) {
return false;
}
@ -802,10 +804,27 @@ impl RenderState {
Ok(())
}
pub fn flush(&mut self) {
self.surfaces.flush(SurfaceId::Backbuffer);
}
pub fn flush_and_submit(&mut self) {
self.surfaces.flush_and_submit(SurfaceId::Target);
}
/// Copy the clean (no UI overlay) Backbuffer to Target, draw UI/debug overlays
/// on top of Target, then present. Backbuffer is left clean so it can be reused
/// as-is across interactive-transform frames without stale overlay pixels.
pub fn present_frame(&mut self, tree: ShapesPoolRef) {
self.surfaces.copy_backbuffer_to_target();
if self.options.is_debug_visible() {
debug::render(self);
}
ui::render(self, tree);
debug::render_wasm_label(self);
self.surfaces.flush_and_submit(SurfaceId::Target);
}
pub fn reset_canvas(&mut self) {
self.surfaces.reset(self.background_color);
}
@ -814,7 +833,7 @@ impl RenderState {
/// This is currently not being used, but it's set there for testing purposes on
/// upcoming tasks
pub fn render_loading_overlay(&mut self) {
let canvas = self.surfaces.canvas(SurfaceId::Target);
let canvas = self.surfaces.canvas(SurfaceId::Backbuffer);
let skia::ISize { width, height } = canvas.base_layer_size();
canvas.save();
@ -861,8 +880,11 @@ impl RenderState {
// the interaction ends.
if self.options.is_interactive_transform() {
let tile_rect = self.get_current_aligned_tile_bounds()?;
self.surfaces
.draw_current_tile_direct_target_only(&tile_rect, self.background_color);
self.surfaces.draw_current_tile_direct(
&tile_rect,
self.background_color,
surfaces::DrawOnCache::No,
);
return Ok(());
}
@ -877,10 +899,12 @@ impl RenderState {
// In fast mode the viewport is moving (pan/zoom) so Cache surface
// positions would be wrong — only save to the tile HashMap.
let tile_rect = self.get_current_aligned_tile_bounds()?;
let current_tile = *self
.current_tile
.as_ref()
.ok_or(Error::CriticalError("Current tile not found".to_string()))?;
self.surfaces.cache_current_tile_texture(
&self.tile_viewbox,
&current_tile,
@ -1719,6 +1743,16 @@ impl RenderState {
&& doc_bounds.top >= viewport.top
&& doc_bounds.right <= viewport.right
&& doc_bounds.bottom <= viewport.bottom;
// When the shape extends beyond the viewport to the left or top,
// `left`/`top` are negative. Skia clamps `makeImageSnapshot` to the
// surface bounds, so the returned image actually starts at pixel 0 —
// not at the negative coordinate. Store the clamped value so that
// `doc_left`/`doc_top` computed during the drag reflects the true
// image origin in the backbuffer.
let capture_src_left = left.max(0);
let capture_src_top = top.max(0);
self.backbuffer_crop_cache.insert(
id,
InteractiveDragCrop {
@ -1727,8 +1761,8 @@ impl RenderState {
fits_viewport_at_capture,
capture_vb_left: vb_left,
capture_vb_top: vb_top,
capture_src_left: src_irect.left,
capture_src_top: src_irect.top,
capture_src_left,
capture_src_top,
image,
},
);
@ -1747,16 +1781,9 @@ impl RenderState {
// and drawing from it avoids mixing a partially-updated Cache surface with missing tiles.
if self.options.is_fast_mode() && self.render_in_progress && self.surfaces.has_atlas() {
self.surfaces
.draw_atlas_to_target(self.viewbox, self.options.dpr, bg_color);
.draw_atlas_to_backbuffer(self.viewbox, self.options.dpr, bg_color);
if self.options.is_debug_visible() {
debug::render(self);
}
ui::render(self, shapes);
debug::render_wasm_label(self);
self.flush_and_submit();
self.present_frame(shapes);
performance::end_measure!("render_from_cache");
performance::end_timed_log!("render_from_cache", _start);
return;
@ -1815,19 +1842,13 @@ impl RenderState {
if !cache_covers {
// Early return only if atlas exists; otherwise keep cache path.
if self.surfaces.has_atlas() {
self.surfaces.draw_atlas_to_target(
self.surfaces.draw_atlas_to_backbuffer(
self.viewbox,
self.options.dpr,
bg_color,
);
if self.options.is_debug_visible() {
debug::render(self);
}
ui::render(self, shapes);
debug::render_wasm_label(self);
self.flush_and_submit();
self.present_frame(shapes);
performance::end_measure!("render_from_cache");
performance::end_timed_log!("render_from_cache", _start);
return;
@ -1837,7 +1858,7 @@ impl RenderState {
// Setup canvas transform
{
let canvas = self.surfaces.canvas(SurfaceId::Target);
let canvas = self.surfaces.canvas(SurfaceId::Backbuffer);
canvas.save();
canvas.scale((navigate_zoom, navigate_zoom));
canvas.translate((translate_x, translate_y));
@ -1845,10 +1866,10 @@ impl RenderState {
}
// Draw directly from cache surface, avoiding snapshot overhead
self.surfaces.draw_cache_to_target();
self.surfaces.draw_cache_to_backbuffer();
// Restore canvas state
self.surfaces.canvas(SurfaceId::Target).restore();
self.surfaces.canvas(SurfaceId::Backbuffer).restore();
// During pure pan (same zoom), draw tiles from the HashMap
// on top of the scaled Cache surface. Cached tile textures
@ -1880,14 +1901,7 @@ impl RenderState {
}
}
if self.options.is_debug_visible() {
debug::render(self);
}
ui::render(self, shapes);
debug::render_wasm_label(self);
self.flush_and_submit();
self.present_frame(shapes);
}
performance::end_measure!("render_from_cache");
@ -1955,7 +1969,6 @@ impl RenderState {
if !self.interactive_target_seeded {
// Seed from the last presented frame; this is stable even when
// fast_mode skips cache updates and regardless of atlas coverage.
self.surfaces.seed_target_from_backbuffer();
self.interactive_target_seeded = true;
}
} else {
@ -2015,6 +2028,7 @@ impl RenderState {
self.nested_shadows.clear();
// reorder by distance to the center.
self.current_tile = None;
self.render_in_progress = true;
self.apply_drawing_to_render_canvas(None, SurfaceId::Current);
@ -2078,38 +2092,28 @@ impl RenderState {
timestamp: i32,
) -> Result<()> {
performance::begin_measure!("process_animation_frame");
self.render_shape_tree_partial(base_object, tree, timestamp, true)?;
if self.render_in_progress {
if tree.len() != 0 {
self.render_shape_tree_partial(base_object, tree, timestamp, true)?;
}
// In a pure viewport interaction (pan/zoom), render_from_cache
// owns the Target surface — skip flush so we don't present
// stale tile positions. The rAF still populates the Cache
// surface and tile HashMap so render_from_cache progressively
// shows more complete content.
//
// During interactive shape transforms (drag/resize/rotate) we
// still need to flush every rAF so the user sees the updated
// shape position — render_from_cache is not in the loop here.
if !self.options.is_viewport_interaction() {
self.flush_and_submit();
}
if self.render_in_progress {
self.cancel_animation_frame();
self.render_request_id = Some(wapi::request_animation_frame!());
} else {
// A full-quality frame is now complete. Refresh Backbuffer and regenerate
// the per-shape crop cache so interactive drags can reuse pixels.
if !self.options.is_fast_mode() && !self.options.is_interactive_transform() {
self.surfaces.copy_target_to_backbuffer();
self.rebuild_backbuffer_crop_cache(tree);
}
wapi::notify_tiles_render_complete!();
performance::end_measure!("render");
// Partial frame: just flush GPU work. The display shows the last
// fully submitted frame; no need to copy or draw UI overlays here.
self.flush();
self.cancel_animation_frame();
self.render_request_id = Some(wapi::request_animation_frame!());
} else {
// A full-quality frame is now complete. Rebuild the per-shape crop
// cache from the clean Backbuffer (no UI overlay yet) so that
// interactive drag backgrounds don't include the grid overlay.
if !self.options.is_fast_mode() && !self.options.is_interactive_transform() {
self.rebuild_backbuffer_crop_cache(tree);
}
// present_frame: copy clean Backbuffer → Target, draw UI/debug
// overlays on Target only, then flush. Backbuffer stays overlay-free.
self.present_frame(tree);
wapi::notify_tiles_render_complete!();
performance::end_measure!("render");
}
performance::end_measure!("process_animation_frame");
Ok(())
}
@ -2120,10 +2124,8 @@ impl RenderState {
tree: ShapesPoolRef,
timestamp: i32,
) -> Result<()> {
if tree.len() != 0 {
self.render_shape_tree_partial(base_object, tree, timestamp, false)?;
}
self.flush_and_submit();
self.render_shape_tree_partial(base_object, tree, timestamp, false)?;
self.present_frame(tree);
Ok(())
}
@ -3008,6 +3010,7 @@ impl RenderState {
&tree.modifier_ids(),
moved_bounds,
);
if use_cached {
if let Some(crop) = self.backbuffer_crop_cache.get(&node_id) {
let crop_image = &crop.image;
@ -3295,6 +3298,7 @@ impl RenderState {
return Ok(());
}
performance::end_measure!("render_shape_tree::uncached");
let tile_rect = self.get_current_tile_bounds()?;
// Composite if the walker did work in this PAF (`!is_empty`) OR
// the tile has unfinished work from a previous PAF
@ -3304,8 +3308,11 @@ impl RenderState {
if self.options.is_interactive_transform() {
// During drag, avoid snapshot-based caching. Draw Current directly
// into Target (and Cache) to reduce stalls.
self.surfaces
.draw_current_tile_direct(&tile_rect, self.background_color);
self.surfaces.draw_current_tile_direct(
&tile_rect,
self.background_color,
surfaces::DrawOnCache::Yes,
);
} else {
self.apply_render_to_final_canvas(tile_rect)?;
}
@ -3318,25 +3325,6 @@ impl RenderState {
tile_rect,
);
}
} else {
self.surfaces.apply_mut(SurfaceId::Target as u32, |s| {
let mut paint = skia::Paint::default();
paint.set_color(self.background_color);
s.canvas().draw_rect(tile_rect, &paint);
});
// Keep Cache surface coherent for render_from_cache.
if !self.options.is_fast_mode() {
if !self.cache_cleared_this_render {
self.surfaces.clear_cache(self.background_color);
self.cache_cleared_this_render = true;
}
let aligned_rect = self.get_aligned_tile_bounds(current_tile);
self.surfaces.apply_mut(SurfaceId::Cache as u32, |s| {
let mut paint = skia::Paint::default();
paint.set_color(self.background_color);
s.canvas().draw_rect(aligned_rect, &paint);
});
}
}
}
}
@ -3409,7 +3397,6 @@ impl RenderState {
}
self.render_in_progress = false;
self.surfaces.gc();
// Mark cache as valid for render_from_cache.
@ -3424,13 +3411,6 @@ impl RenderState {
self.cached_viewbox = self.viewbox;
}
if self.options.is_debug_visible() {
debug::render(self);
}
ui::render(self, tree);
debug::render_wasm_label(self);
Ok(())
}
@ -3494,6 +3474,25 @@ impl RenderState {
let mut result = HashSet::<tiles::Tile>::with_capacity(old_tiles.len());
// When the shape has an active modifier (i.e. is being moved/resized),
// clear its OLD doc-space extent from the atlas using the raw
// (pre-modifier) shape. The per-tile clearing done later via
// `clear_tile_in_atlas` only covers tiles tracked in `atlas_tile_doc_rects`
// at the current zoom level. However, the atlas may also contain stale
// pixels from previous zoom levels (tiles are larger / smaller in doc
// space at different zoom scales) that were never re-tracked after a zoom
// change. Clearing the full raw extrect here removes all such residual
// content without growing the atlas.
//
// We intentionally skip this when there is NO modifier so that plain
// zoom / pan tile-index rebuilds do NOT invalidate valid atlas content.
if tree.get_modifier(&shape.id).is_some() {
if let Some(raw_shape) = tree.get_raw(&shape.id) {
let old_extrect = raw_shape.extrect(tree, 1.0);
self.surfaces.clear_doc_rect_in_atlas_clipped(old_extrect);
}
}
// First, remove the shape from all tiles where it was previously located
for tile in old_tiles {
self.tiles.remove_shape_at(tile, shape.id);

View File

@ -187,8 +187,52 @@ pub fn render_debug_shape(
}
}
#[cfg(target_arch = "wasm32")]
#[allow(dead_code)]
#[cfg(target_arch = "wasm32")]
pub fn trap() {
run_script!("debugger");
}
#[allow(dead_code)]
#[cfg(target_arch = "wasm32")]
#[derive(Debug, PartialEq)]
pub enum SurfaceBackendKind {
BackendTexture, // GPU Framebuffer (Texture)
BackendRenderTarget, // GPU Framebuffer (Renderbuffer)
Raster, // CPU
Unknown,
}
#[allow(dead_code)]
#[cfg(target_arch = "wasm32")]
pub fn classify_surface_backend(surface: &mut skia::Surface) -> SurfaceBackendKind {
if skia::gpu::surfaces::get_backend_texture(
surface,
skia_safe::surface::BackendHandleAccess::FlushRead,
)
.is_some()
{
return SurfaceBackendKind::BackendTexture;
}
if skia::gpu::surfaces::get_backend_render_target(
surface,
skia_safe::surface::BackendHandleAccess::FlushRead,
)
.is_some()
{
return SurfaceBackendKind::BackendRenderTarget;
}
if surface.peek_pixels().is_some() {
return SurfaceBackendKind::Raster;
}
SurfaceBackendKind::Unknown
}
#[allow(dead_code)]
#[cfg(target_arch = "wasm32")]
pub fn console_debug_surface(render_state: &mut RenderState, id: SurfaceId) {
let base64_image = render_state
.surfaces
@ -198,6 +242,8 @@ pub fn console_debug_surface(render_state: &mut RenderState, id: SurfaceId) {
run_script!(format!("console.log('%c ', 'font-size: 1px; background: url(data:image/png;base64,{base64_image}) no-repeat; padding: 100px; background-size: contain;')"));
}
#[allow(dead_code)]
#[cfg(target_arch = "wasm32")]
pub fn console_debug_surface_base64(render_state: &mut RenderState, id: SurfaceId) {
let base64_image = render_state
.surfaces
@ -258,3 +304,11 @@ pub extern "C" fn debug_atlas_base64() -> Result<()> {
console_debug_surface_base64(get_render_state(), SurfaceId::Atlas);
Ok(())
}
#[no_mangle]
#[wasm_error]
#[cfg(target_arch = "wasm32")]
pub extern "C" fn debug_surface_console(id: SurfaceId) -> Result<()> {
console_debug_surface(get_render_state(), id);
Ok(())
}

View File

@ -96,14 +96,6 @@ impl RenderOptions {
self.interactive_transform = enabled;
}
/// True only when the viewport is the one being moved (pan/zoom)
/// and the dedicated `render_from_cache` path owns Target
/// presentation. In this mode `process_animation_frame` must not
/// flush to avoid presenting stale tile positions.
pub fn is_viewport_interaction(&self) -> bool {
self.fast_mode && !self.interactive_transform
}
pub fn is_text_editor_v3(&self) -> bool {
self.flags & TEXT_EDITOR_V3 == TEXT_EDITOR_V3
}

View File

@ -26,6 +26,12 @@ const TILE_SIZE_MULTIPLIER: i32 = 2;
const MAX_ATLAS_TEXTURE_SIZE: i32 = 4096;
const DEFAULT_MAX_ATLAS_TEXTURE_SIZE: i32 = 1024;
#[derive(Debug, PartialEq)]
pub enum DrawOnCache {
Yes,
No,
}
#[repr(u32)]
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum SurfaceId {
@ -396,6 +402,58 @@ impl Surfaces {
Ok(())
}
/// Clears a doc-space rect from the atlas **without** growing it.
///
/// Unlike [`clear_doc_rect_in_atlas`], this method clips `doc_rect` to the
/// current atlas bounds and skips silently if there is no overlap. Use this
/// when evicting stale shape content (e.g. before a drag re-render) where
/// growing the atlas to accommodate an out-of-range rect would be wasteful.
pub fn clear_doc_rect_in_atlas_clipped(&mut self, doc_rect: skia::Rect) {
if !self.has_atlas() || doc_rect.is_empty() {
return;
}
let atlas_scale = self.atlas_scale.max(0.01);
let atlas_doc_right = self.atlas_origin.x + (self.atlas_size.width as f32) / atlas_scale;
let atlas_doc_bottom = self.atlas_origin.y + (self.atlas_size.height as f32) / atlas_scale;
// Intersect with current atlas bounds in doc space.
let mut clipped = doc_rect;
let atlas_bounds = skia::Rect::from_ltrb(
self.atlas_origin.x,
self.atlas_origin.y,
atlas_doc_right,
atlas_doc_bottom,
);
if !clipped.intersect(atlas_bounds) {
return;
}
// Apply atlas_doc_bounds clamping.
if let Some(bounds) = self.atlas_doc_bounds {
if !clipped.intersect(bounds) {
return;
}
}
if clipped.is_empty() {
return;
}
let dst = skia::Rect::from_xywh(
(clipped.left - self.atlas_origin.x) * atlas_scale,
(clipped.top - self.atlas_origin.y) * atlas_scale,
clipped.width() * atlas_scale,
clipped.height() * atlas_scale,
);
let canvas = self.atlas.canvas();
canvas.save();
canvas.clip_rect(dst, None, true);
canvas.clear(skia::Color::TRANSPARENT);
canvas.restore();
}
pub fn clear_tiles(&mut self) {
self.tiles.clear();
}
@ -404,16 +462,21 @@ impl Surfaces {
self.atlas_size.width > 0 && self.atlas_size.height > 0
}
/// Draw the persistent atlas onto the target using the current viewbox transform.
/// Draw the persistent atlas onto the backbuffer using the current viewbox transform.
/// Intended for fast pan/zoom-out previews (avoids per-tile composition).
/// Clears Target to `background` first so atlas-uncovered regions don't
/// Clears Backbuffer to `background` first so atlas-uncovered regions don't
/// show stale content when the atlas only partially covers the viewport.
pub fn draw_atlas_to_target(&mut self, viewbox: Viewbox, dpr: f32, background: skia::Color) {
pub fn draw_atlas_to_backbuffer(
&mut self,
viewbox: Viewbox,
dpr: f32,
background: skia::Color,
) {
if !self.has_atlas() {
return;
}
let canvas = self.target.canvas();
let canvas = self.backbuffer.canvas();
canvas.save();
canvas.reset_matrix();
let size = canvas.base_layer_size();
@ -545,6 +608,12 @@ impl Surfaces {
self.dirty_surfaces = 0;
}
pub fn flush(&mut self, id: SurfaceId) {
let gpu_state = get_gpu_state();
let surface = self.get_mut(id);
gpu_state.context.flush_surface(surface);
}
pub fn flush_and_submit(&mut self, id: SurfaceId) {
let gpu_state = get_gpu_state();
let surface = self.get_mut(id);
@ -562,12 +631,12 @@ impl Surfaces {
);
}
/// Draws the cache surface directly to the target canvas.
/// Draws the cache surface directly to the backbuffer canvas.
/// This avoids creating an intermediate snapshot, reducing GPU stalls.
pub fn draw_cache_to_target(&mut self) {
pub fn draw_cache_to_backbuffer(&mut self) {
let sampling_options = self.sampling_options;
self.cache.clone().draw(
self.target.canvas(),
self.cache.draw(
self.backbuffer.canvas(),
(0.0, 0.0),
sampling_options,
Some(&skia::Paint::default()),
@ -663,7 +732,7 @@ impl Surfaces {
});
}
#[inline]
#[inline(always)]
pub fn get_mut(&mut self, id: SurfaceId) -> &mut skia::Surface {
match id {
SurfaceId::Target => &mut self.target,
@ -683,6 +752,7 @@ impl Surfaces {
}
}
#[inline(always)]
fn get(&self, id: SurfaceId) -> &skia::Surface {
match id {
SurfaceId::Target => &self.target,
@ -707,23 +777,23 @@ impl Surfaces {
(s.width(), s.height())
}
/// Copy the current `Target` contents into the persistent `Backbuffer`.
/// Copy the current `Backbuffer` contents into `Target`.
/// This is a GPU→GPU copy via Skia (no ReadPixels).
pub fn copy_target_to_backbuffer(&mut self) {
pub fn copy_backbuffer_to_target(&mut self) {
let sampling_options = self.sampling_options;
self.target.clone().draw(
self.backbuffer.canvas(),
self.backbuffer.draw(
self.target.canvas(),
(0.0, 0.0),
sampling_options,
Some(&skia::Paint::default()),
);
}
/// Seed `Target` from `Backbuffer` (last presented frame).
pub fn seed_target_from_backbuffer(&mut self) {
/// Seed `Backbuffer` from `Target` (last presented frame).
pub fn seed_backbuffer_from_target(&mut self) {
let sampling_options = self.sampling_options;
self.backbuffer.clone().draw(
self.target.canvas(),
self.target.draw(
self.backbuffer.canvas(),
(0.0, 0.0),
sampling_options,
Some(&skia::Paint::default()),
@ -749,7 +819,6 @@ impl Surfaces {
.target
.new_surface_with_dimensions(dim)
.ok_or(Error::CriticalError("Failed to create surface".to_string()))?;
// The rest are tile size surfaces
Ok(())
}
@ -1000,17 +1069,17 @@ impl Surfaces {
let mut paint = skia::Paint::default();
paint.set_color(color);
self.target.canvas().draw_rect(rect, &paint);
self.backbuffer.canvas().draw_rect(rect, &paint);
self.target
self.backbuffer
.canvas()
.draw_image_rect(&image, None, rect, &skia::Paint::default());
}
}
/// Draws a cached tile texture to the Cache surface at the given
/// Draws a cached tile texture to the Cache self.backbuffer at the given
/// cache-aligned rect. This keeps the Cache surface in sync with
/// Target so that `render_from_cache` (used during pan) has the
/// Backbuffer so that `render_from_cache` (used during pan) has the
/// full scene including tiles served from the texture cache.
pub fn draw_cached_tile_to_cache(
&mut self,
@ -1031,53 +1100,14 @@ impl Surfaces {
}
}
/// Draws the current tile directly to the target and cache surfaces without
/// Draws the current tile directly to the backbuffer and cache surfaces without
/// creating a snapshot. This avoids GPU stalls from ReadPixels but doesn't
/// populate the tile texture cache (suitable for one-shot renders like tests).
pub fn draw_current_tile_direct(&mut self, tile_rect: &skia::Rect, color: skia::Color) {
let sampling_options = self.sampling_options;
let src_rect = IRect::from_xywh(
self.margins.width,
self.margins.height,
self.current.width() - TILE_SIZE_MULTIPLIER * self.margins.width,
self.current.height() - TILE_SIZE_MULTIPLIER * self.margins.height,
);
let src_rect_f = skia::Rect::from(src_rect);
// Draw background
let mut paint = skia::Paint::default();
paint.set_color(color);
self.target.canvas().draw_rect(tile_rect, &paint);
// Draw current surface directly to target (no snapshot)
self.current.clone().draw(
self.target.canvas(),
(
tile_rect.left - src_rect_f.left,
tile_rect.top - src_rect_f.top,
),
sampling_options,
None,
);
// Also draw to cache for render_from_cache
self.current.clone().draw(
self.cache.canvas(),
(
tile_rect.left - src_rect_f.left,
tile_rect.top - src_rect_f.top,
),
sampling_options,
None,
);
}
/// Same as `draw_current_tile_direct` but draws only into Target.
/// Useful during interactive transforms to reduce extra GPU work.
pub fn draw_current_tile_direct_target_only(
pub fn draw_current_tile_direct(
&mut self,
tile_rect: &skia::Rect,
color: skia::Color,
draw_on_cache: DrawOnCache,
) {
let sampling_options = self.sampling_options;
let src_rect = IRect::from_xywh(
@ -1088,12 +1118,15 @@ impl Surfaces {
);
let src_rect_f = skia::Rect::from(src_rect);
let backbuffer_canvas = self.backbuffer.canvas();
// Draw background
let mut paint = skia::Paint::default();
paint.set_color(color);
self.target.canvas().draw_rect(tile_rect, &paint);
backbuffer_canvas.draw_rect(tile_rect, &paint);
self.current.clone().draw(
self.target.canvas(),
// Draw current surface directly to target (no snapshot)
self.current.draw(
backbuffer_canvas,
(
tile_rect.left - src_rect_f.left,
tile_rect.top - src_rect_f.top,
@ -1101,6 +1134,19 @@ impl Surfaces {
sampling_options,
None,
);
// Also draw to cache for render_from_cache
if draw_on_cache == DrawOnCache::Yes {
self.current.draw(
self.cache.canvas(),
(
tile_rect.left - src_rect_f.left,
tile_rect.top - src_rect_f.top,
),
sampling_options,
None,
);
}
}
/// Full cache reset: clears both the tile texture cache and the cache canvas.

View File

@ -6,21 +6,15 @@ use crate::shapes::{Layout, Type};
pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) {
let canvas = render_state.surfaces.canvas(SurfaceId::UI);
let viewbox = render_state.viewbox;
let zoom = viewbox.zoom * render_state.options.dpr;
let show_grid_id = render_state.show_grid;
canvas.clear(Color4f::new(0.0, 0.0, 0.0, 0.0));
canvas.save();
let viewbox = render_state.viewbox;
let zoom = viewbox.zoom * render_state.options.dpr;
canvas.scale((zoom, zoom));
canvas.translate((-viewbox.area.left, -viewbox.area.top));
let canvas = render_state.surfaces.canvas(SurfaceId::UI);
let show_grid_id = render_state.show_grid;
if let Some(id) = show_grid_id {
if let Some(shape) = shapes.get(&id) {
grid_layout::render_overlay(
@ -67,6 +61,7 @@ pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) {
}
canvas.restore();
render_state.surfaces.draw_into(
SurfaceId::UI,
SurfaceId::Target,

View File

@ -1495,6 +1495,8 @@ impl Shape {
// Outsets (strokes, shadows, blur, children) are translation-invariant,
// so the cached extrect can be shifted instead of invalidated.
// The bounds cache must always be invalidated so that callers such as
// grid_cell_data get the updated position after a drag.
if math::is_move_only_matrix(transform) {
let tx = transform.translate_x();
let ty = transform.translate_y();
@ -1506,6 +1508,7 @@ impl Shape {
rect.height(),
);
}
self.invalidate_bounds();
} else {
self.invalidate_extrect();
self.invalidate_bounds();

View File

@ -167,6 +167,7 @@ fn set_pixel_precision(transform: &mut Matrix, bounds: &mut Bounds) {
}
}
#[allow(clippy::too_many_arguments)]
fn propagate_transform(
entry: TransformEntry,
pixel_precision: bool,
@ -175,6 +176,7 @@ fn propagate_transform(
bounds: &mut HashMap<Uuid, Bounds>,
modifiers: &mut HashMap<Uuid, Matrix>,
reflown: &mut HashSet<Uuid>,
reflowed_shapes: &mut HashSet<Uuid>,
) -> Result<()> {
let Some(shape) = state.shapes.get(&entry.id) else {
return Ok(());
@ -190,7 +192,11 @@ fn propagate_transform(
if !is_close_to(shape_bounds_before.width(), shape_bounds_after.width())
|| !is_close_to(shape_bounds_before.height(), shape_bounds_after.height())
{
if let Type::Text(text_content) = &mut shape.shape_type.clone() {
if let Type::Text(text_content) = &shape.shape_type {
let width_changed =
!is_close_to(shape_bounds_before.width(), shape_bounds_after.width());
let height_changed =
!is_close_to(shape_bounds_before.height(), shape_bounds_after.height());
let resized_selrect = math::Rect::from_xywh(
shape.selrect.left(),
shape.selrect.top(),
@ -199,12 +205,15 @@ fn propagate_transform(
);
match text_content.grow_type() {
GrowType::AutoHeight => {
// For auto-height, always update layout when width changes
// because the new width affects how text wraps
let width_changed =
!is_close_to(shape_bounds_before.width(), shape_bounds_after.width());
if width_changed || text_content.needs_update_layout() {
text_content.update_layout(resized_selrect);
let height_before = text_content.size.height;
let new_height = if width_changed {
let mut clone = text_content.clone();
clone.update_layout(resized_selrect);
clone.size.height
} else {
height_before
};
if !is_close_to(height_before, new_height) && reflowed_shapes.insert(shape.id) {
entries.push_back(Modifier::reflow(shape.id, false));
if let Some(parent_id) = shape.parent_id {
@ -215,31 +224,28 @@ fn propagate_transform(
}
}
}
let height = text_content.size.height;
let resize_transform = math::resize_matrix(
&shape_bounds_after,
&shape_bounds_after,
shape_bounds_after.width(),
height,
new_height,
);
shape_bounds_after = shape_bounds_after.transform(&resize_transform);
transform.post_concat(&resize_transform);
}
GrowType::AutoWidth => {
// For auto-width, always update layout when height changes
// because the new height affects how text flows
let height_changed =
!is_close_to(shape_bounds_before.height(), shape_bounds_after.height());
if height_changed || text_content.needs_update_layout() {
text_content.update_layout(resized_selrect);
}
let width = text_content.width();
let height = text_content.size.height;
let (new_width, new_height) = if height_changed {
let mut clone = text_content.clone();
clone.update_layout(resized_selrect);
(clone.width(), clone.size.height)
} else {
(text_content.width(), text_content.size.height)
};
let resize_transform = math::resize_matrix(
&shape_bounds_after,
&shape_bounds_after,
width,
height,
new_width,
new_height,
);
shape_bounds_after = shape_bounds_after.transform(&resize_transform);
transform.post_concat(&resize_transform);
@ -404,6 +410,7 @@ pub fn propagate_modifiers(
// In order for loop to eventualy finish, we limit the flex reflow to just
// one (the reflown set).
while !entries.is_empty() {
let mut reflowed_shapes = HashSet::<Uuid>::new();
while let Some(modifier) = entries.pop_front() {
match modifier {
Modifier::Transform(entry, pixel) => propagate_transform(
@ -414,6 +421,7 @@ pub fn propagate_modifiers(
&mut bounds,
&mut modifiers,
&mut reflown,
&mut reflowed_shapes,
)?,
Modifier::Reflow(id, force_reflow) => {
if force_reflow {

View File

@ -26,7 +26,6 @@ use crate::math::Point;
use crate::shapes::{self, merge_fills, Shape, VerticalAlign};
use crate::utils::{get_fallback_fonts, get_font_collection};
use crate::Uuid;
use crate::STATE;
// TODO: maybe move this to the wasm module?
pub type ParagraphBuilderGroup = Vec<ParagraphBuilder>;
@ -334,13 +333,26 @@ pub fn calculate_normalized_line_height(
normalized_line_height
}
#[derive(Debug, PartialEq, Clone)]
#[derive(Debug, Clone)]
pub struct TextContent {
pub paragraphs: Vec<Paragraph>,
pub bounds: Rect,
pub grow_type: GrowType,
pub size: TextContentSize,
pub layout: TextContentLayout,
content_version: u64,
layout_version: u64,
layout_width: Option<f32>,
}
impl PartialEq for TextContent {
fn eq(&self, other: &Self) -> bool {
self.paragraphs == other.paragraphs
&& self.bounds == other.bounds
&& self.grow_type == other.grow_type
&& self.size == other.size
&& self.layout == other.layout
}
}
impl TextContent {
@ -351,6 +363,9 @@ impl TextContent {
grow_type,
size: TextContentSize::default(),
layout: TextContentLayout::new(),
content_version: 0,
layout_version: 0,
layout_width: None,
}
}
@ -363,6 +378,9 @@ impl TextContent {
grow_type,
size: TextContentSize::new_with_size(bounds.width(), bounds.height()),
layout: TextContentLayout::new(),
content_version: 0,
layout_version: 0,
layout_width: None,
}
}
@ -386,6 +404,7 @@ impl TextContent {
pub fn add_paragraph(&mut self, paragraph: Paragraph) {
self.paragraphs.push(paragraph);
self.content_version = self.content_version.wrapping_add(1);
}
pub fn paragraphs(&self) -> &[Paragraph] {
@ -393,6 +412,7 @@ impl TextContent {
}
pub fn paragraphs_mut(&mut self) -> &mut Vec<Paragraph> {
self.content_version = self.content_version.wrapping_add(1);
&mut self.paragraphs
}
@ -409,7 +429,10 @@ impl TextContent {
}
pub fn set_grow_type(&mut self, grow_type: GrowType) {
self.grow_type = grow_type;
if self.grow_type != grow_type {
self.grow_type = grow_type;
self.content_version = self.content_version.wrapping_add(1);
}
}
/// Compute a tight text rect from laid-out Skia paragraphs using glyph
@ -892,6 +915,15 @@ impl TextContent {
}
pub fn update_layout(&mut self, selrect: Rect) -> TextContentSize {
if !self.layout.needs_update()
&& self.layout_version == self.content_version
&& self
.layout_width
.is_some_and(|w| (w - selrect.width()).abs() < f32::EPSILON)
{
return self.size;
}
self.size.set_size(selrect.width(), selrect.height());
match self.grow_type() {
@ -916,6 +948,9 @@ impl TextContent {
self.size.max_width = placeholder_width;
}
self.layout_version = self.content_version;
self.layout_width = Some(selrect.width());
self.size
}
@ -1049,6 +1084,9 @@ impl Default for TextContent {
grow_type: GrowType::Fixed,
size: TextContentSize::default(),
layout: TextContentLayout::new(),
content_version: 0,
layout_version: 0,
layout_width: None,
}
}
}

View File

@ -38,26 +38,26 @@ impl State {
// Creates a new temporary shapes pool.
// Will panic if a previous temporary pool exists.
pub fn start_temp_objects(mut self) -> Result<Self> {
pub fn start_temp_objects(&mut self) -> Result<()> {
if self.saved_shapes.is_some() {
return Err(Error::CriticalError(
"Tried to start a temp objects while the previous have not been restored"
.to_string(),
));
}
self.saved_shapes = Some(self.shapes);
self.saved_shapes = Some(self.shapes.clone());
self.shapes = ShapesPool::new();
Ok(self)
Ok(())
}
// Disposes of the temporary shapes pool restoring the normal pool
// Will panic if a there is no temporary pool.
pub fn end_temp_objects(mut self) -> Result<Self> {
self.shapes = self.saved_shapes.ok_or(Error::CriticalError(
pub fn end_temp_objects(&mut self) -> Result<()> {
self.shapes = self.saved_shapes.clone().ok_or(Error::CriticalError(
"Tried to end temp objects but not content to be restored is present".to_string(),
))?;
self.saved_shapes = None;
Ok(self)
Ok(())
}
pub fn render_from_cache(&mut self) {

View File

@ -398,3 +398,19 @@ impl Default for ShapesPoolImpl {
Self::new()
}
}
impl Clone for ShapesPoolImpl {
fn clone(&self) -> Self {
ShapesPoolImpl {
shapes: self.shapes.clone(),
counter: self.counter,
uuid_to_idx: self.uuid_to_idx.clone(),
// The modified_shape_cache is a derived/computed cache; reset it on clone
// so it gets lazily rebuilt on demand rather than cloning OnceCell state.
modified_shape_cache: HashMap::default(),
modifiers: self.modifiers.clone(),
structure: self.structure.clone(),
scale_content: self.scale_content.clone(),
}
}
}

View File

@ -2,8 +2,7 @@ use crate::get_render_state;
use crate::skia::textlayout::FontCollection;
use crate::skia::Image;
use crate::uuid::Uuid;
use crate::with_state_mut;
use crate::STATE;
use crate::with_state;
use std::collections::HashSet;
pub fn uuid_from_u32_quartet(a: u32, b: u32, c: u32, d: u32) -> Uuid {
@ -35,7 +34,7 @@ pub fn get_fallback_fonts() -> &'static HashSet<String> {
}
pub fn get_font_collection() -> &'static FontCollection {
with_state_mut!(state, { state.font_collection() })
with_state!(state, { state.font_collection() })
}
#[derive(Debug, Clone, Copy)]

View File

@ -2,7 +2,7 @@ use macros::ToJs;
use skia_safe as skia;
use crate::shapes::BlendMode;
use crate::{with_current_shape_mut, STATE};
use crate::with_current_shape_mut;
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]

View File

@ -1,7 +1,7 @@
use macros::ToJs;
use crate::shapes::{Blur, BlurType};
use crate::{with_current_shape_mut, STATE};
use crate::with_current_shape_mut;
#[derive(Debug, Clone, Copy, PartialEq, ToJs)]
#[repr(u8)]

View File

@ -3,7 +3,6 @@ use macros::{wasm_error, ToJs};
use crate::mem;
use crate::shapes;
use crate::with_current_shape_mut;
use crate::STATE;
mod gradient;
mod image;

View File

@ -4,8 +4,7 @@ use crate::mem;
use crate::shapes::Fill;
use crate::state::State;
use crate::uuid::Uuid;
use crate::with_state_mut;
use crate::STATE;
use crate::with_state;
use crate::{shapes::ImageFill, utils::uuid_from_u32_quartet};
use macros::wasm_error;
@ -106,7 +105,7 @@ pub extern "C" fn store_image() -> Result<()> {
let image_bytes = &bytes[IMAGE_HEADER_SIZE..];
with_state_mut!(state, {
with_state!(state, {
if let Err(msg) = get_render_state().add_image(ids.image_id, is_thumbnail, image_bytes) {
eprintln!("{}", msg);
}
@ -176,7 +175,7 @@ pub extern "C" fn store_image_from_texture() -> Result<()> {
.map_err(|_| Error::CriticalError("Invalid bytes for height".to_string()))?,
);
with_state_mut!(state, {
with_state!(state, {
if let Err(msg) = get_render_state().add_image_from_gl_texture(
ids.image_id,
is_thumbnail,

View File

@ -1,5 +1,5 @@
use crate::shapes::Sizing;
use crate::{with_current_shape_mut, STATE};
use crate::with_current_shape_mut;
use macros::ToJs;
mod align;

View File

@ -3,7 +3,7 @@ use macros::ToJs;
use crate::shapes::{
AlignContent, AlignItems, AlignSelf, JustifyContent, JustifyItems, JustifySelf, VerticalAlign,
};
use crate::{with_current_shape_mut, STATE};
use crate::with_current_shape_mut;
#[derive(Debug, Clone, PartialEq, Copy, ToJs)]
#[repr(u8)]

View File

@ -1,7 +1,7 @@
use macros::ToJs;
use crate::shapes::{ConstraintH, ConstraintV};
use crate::{with_current_shape_mut, STATE};
use crate::with_current_shape_mut;
#[derive(Debug, Clone, PartialEq, Copy, ToJs)]
#[repr(u8)]

View File

@ -1,5 +1,5 @@
use crate::shapes::{FlexDirection, WrapType};
use crate::{with_current_shape_mut, STATE};
use crate::with_current_shape_mut;
use macros::ToJs;
use super::align;

View File

@ -4,7 +4,7 @@ use crate::get_render_state;
use crate::mem;
use crate::shapes::{GridCell, GridDirection, GridTrack, GridTrackType};
use crate::uuid::Uuid;
use crate::{uuid_from_u32_quartet, with_current_shape_mut, with_state, STATE};
use crate::{uuid_from_u32_quartet, with_current_shape_mut, with_state};
use super::align;

View File

@ -6,7 +6,7 @@ use std::sync::{Mutex, OnceLock};
use crate::error::{Error, Result};
use crate::shapes::{stroke_to_path, Path, Segment, ToPath};
use crate::{mem, with_current_shape, with_current_shape_mut, STATE};
use crate::{mem, with_current_shape, with_current_shape_mut};
const RAW_SEGMENT_DATA_SIZE: usize = size_of::<RawSegmentData>();

View File

@ -5,7 +5,7 @@ use crate::math;
use crate::shapes::BoolType;
use crate::uuid::Uuid;
use crate::{mem, SerializableResult};
use crate::{with_current_shape_mut, with_state, STATE};
use crate::{with_current_shape_mut, with_state};
use std::mem::size_of;
#[allow(unused_imports)]

View File

@ -2,7 +2,7 @@ use macros::ToJs;
use skia_safe as skia;
use crate::shapes::{Shadow, ShadowStyle};
use crate::{with_current_shape_mut, STATE};
use crate::with_current_shape_mut;
#[derive(Debug, Clone, Copy, PartialEq, ToJs)]
#[repr(u8)]

View File

@ -4,7 +4,7 @@ use crate::utils::uuid_from_u32_quartet;
use crate::uuid::Uuid;
use crate::wasm::blend::RawBlendMode;
use crate::wasm::layouts::constraints::{RawConstraintH, RawConstraintV};
use crate::{with_state_mut, STATE};
use crate::with_state;
#[allow(unused_imports)]
use crate::error::{Error, Result};
@ -128,7 +128,7 @@ pub extern "C" fn set_shape_base_props() -> Result<()> {
let parent_id = raw.parent_id();
let shape_type = RawShapeType::from(raw.shape_type);
with_state_mut!(state, {
with_state!(state, {
state.use_shape(id);
state.set_parent_for_current_shape(parent_id);
state.touch_current();

View File

@ -3,7 +3,7 @@ mod base_props;
use macros::ToJs;
use crate::shapes::{Bool, Frame, Group, Path, Rect, SVGRaw, TextContent, Type};
use crate::{with_current_shape_mut, STATE};
use crate::with_current_shape_mut;
#[derive(Debug, Clone, PartialEq, ToJs)]
#[repr(u8)]

View File

@ -3,7 +3,6 @@ use macros::ToJs;
use crate::mem;
use crate::shapes::{self, StrokeCap, StrokeStyle};
use crate::with_current_shape_mut;
use crate::STATE;
#[derive(Debug, Clone, PartialEq, Copy, ToJs)]
#[repr(u8)]

View File

@ -1,7 +1,7 @@
use macros::ToJs;
use crate::shapes::{FillRule, StrokeLineCap, StrokeLineJoin, SvgAttrs};
use crate::{with_current_shape_mut, STATE};
use crate::with_current_shape_mut;
#[derive(PartialEq, ToJs)]
#[repr(u8)]

View File

@ -7,7 +7,7 @@ use crate::shapes::{
self, GrowType, Shape, TextAlign, TextDecoration, TextDirection, TextTransform, Type,
};
use crate::utils::{uuid_from_u32, uuid_from_u32_quartet};
use crate::{with_current_shape, with_current_shape_mut, with_state, with_state_mut, STATE};
use crate::{with_current_shape, with_current_shape_mut, with_state};
use crate::error::Error;
@ -386,7 +386,7 @@ pub extern "C" fn update_shape_text_layout() {
#[no_mangle]
pub extern "C" fn update_shape_text_layout_for(a: u32, b: u32, c: u32, d: u32) {
with_state_mut!(state, {
with_state!(state, {
let shape_id = uuid_from_u32_quartet(a, b, c, d);
if let Some(shape) = state.shapes.get_mut(&shape_id) {
update_text_layout(shape);

View File

@ -1,7 +1,8 @@
use macros::{wasm_error, ToJs};
use crate::get_text_editor_state;
use crate::globals::{get_render_state, get_text_editor_state};
use crate::math::{Matrix, Point, Rect};
use crate::mem;
use crate::render::text_editor as text_editor_render;
use crate::render::SurfaceId;
use crate::shapes::{Shape, TextAlign, TextContent, TextPositionWithAffinity, Type, VerticalAlign};
@ -12,8 +13,7 @@ use crate::wasm::fills::RawFillData;
use crate::wasm::text::{
helpers as text_helpers, RawTextAlign, RawTextDecoration, RawTextDirection, RawTextTransform,
};
use crate::{get_render_state, mem};
use crate::{with_state, with_state_mut, STATE};
use crate::with_state;
use skia_safe::Color;
#[derive(PartialEq, ToJs)]
@ -42,7 +42,7 @@ pub extern "C" fn text_editor_apply_theme(selection_color: u32, cursor_color: u3
#[no_mangle]
pub extern "C" fn text_editor_focus(a: u32, b: u32, c: u32, d: u32) -> bool {
with_state_mut!(state, {
with_state!(state, {
let shape_id = uuid_from_u32_quartet(a, b, c, d);
let Some(shape) = state.shapes.get(&shape_id) else {
@ -107,7 +107,7 @@ pub extern "C" fn text_editor_get_active_shape_id(buffer_ptr: *mut u32) {
#[no_mangle]
pub extern "C" fn text_editor_select_all() -> bool {
with_state_mut!(state, {
with_state!(state, {
if !get_text_editor_state().has_focus {
return false;
}
@ -129,7 +129,7 @@ pub extern "C" fn text_editor_select_all() -> bool {
#[no_mangle]
pub extern "C" fn text_editor_select_word_boundary(x: f32, y: f32) {
with_state_mut!(state, {
with_state!(state, {
if !get_text_editor_state().has_focus {
return;
}
@ -164,7 +164,7 @@ pub extern "C" fn text_editor_poll_event() -> u8 {
#[no_mangle]
pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) {
with_state_mut!(state, {
with_state!(state, {
if !get_text_editor_state().has_focus {
return;
}
@ -188,7 +188,7 @@ pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) {
#[no_mangle]
pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) {
with_state_mut!(state, {
with_state!(state, {
if !get_text_editor_state().has_focus {
return;
}
@ -222,7 +222,7 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) {
#[no_mangle]
pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) {
with_state_mut!(state, {
with_state!(state, {
if !get_text_editor_state().has_focus {
return;
}
@ -249,7 +249,7 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) {
#[no_mangle]
pub extern "C" fn text_editor_set_cursor_from_offset(x: f32, y: f32) {
with_state_mut!(state, {
with_state!(state, {
// We need this flag to prevent handling the click behavior
// just after a pointerup event.
if get_text_editor_state().is_click_event_skipped {
@ -282,7 +282,7 @@ pub extern "C" fn text_editor_set_cursor_from_offset(x: f32, y: f32) {
#[no_mangle]
pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) {
with_state_mut!(state, {
with_state!(state, {
if !get_text_editor_state().has_focus {
return;
}
@ -330,7 +330,7 @@ pub extern "C" fn text_editor_composition_end() -> Result<()> {
Err(_) => return Ok(()),
};
with_state_mut!(state, {
with_state!(state, {
if !get_text_editor_state().has_focus {
return Ok(());
}
@ -386,7 +386,7 @@ pub extern "C" fn text_editor_composition_update() -> Result<()> {
Err(_) => return Ok(()),
};
with_state_mut!(state, {
with_state!(state, {
if !get_text_editor_state().has_focus {
return Ok(());
}
@ -444,7 +444,7 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> {
Err(_) => return Ok(()),
};
with_state_mut!(state, {
with_state!(state, {
if !get_text_editor_state().has_focus {
return Ok(());
}
@ -498,7 +498,7 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> {
#[no_mangle]
pub extern "C" fn text_editor_delete_backward(word_boundary: bool) {
with_state_mut!(state, {
with_state!(state, {
if !get_text_editor_state().has_focus {
return;
}
@ -522,7 +522,7 @@ pub extern "C" fn text_editor_delete_backward(word_boundary: bool) {
#[no_mangle]
pub extern "C" fn text_editor_delete_forward(word_boundary: bool) {
with_state_mut!(state, {
with_state!(state, {
if !get_text_editor_state().has_focus {
return;
}
@ -546,7 +546,7 @@ pub extern "C" fn text_editor_delete_forward(word_boundary: bool) {
#[no_mangle]
pub extern "C" fn text_editor_insert_paragraph() {
with_state_mut!(state, {
with_state!(state, {
if !get_text_editor_state().has_focus {
return;
}
@ -578,7 +578,7 @@ pub extern "C" fn text_editor_move_cursor(
word_boundary: bool,
extend_selection: bool,
) {
with_state_mut!(state, {
with_state!(state, {
if !get_text_editor_state().has_focus {
return;
}
@ -610,7 +610,7 @@ pub extern "C" fn text_editor_move_cursor(
#[no_mangle]
pub extern "C" fn text_editor_get_cursor_rect() -> *mut u8 {
with_state_mut!(state, {
with_state!(state, {
if !get_text_editor_state().has_focus || !get_text_editor_state().cursor_visible {
return std::ptr::null_mut();
}
@ -644,7 +644,7 @@ pub extern "C" fn text_editor_get_cursor_rect() -> *mut u8 {
#[no_mangle]
pub extern "C" fn text_editor_get_current_styles() -> *mut u8 {
with_state_mut!(state, {
with_state!(state, {
if !get_text_editor_state().has_focus {
return std::ptr::null_mut();
}
@ -812,7 +812,7 @@ pub extern "C" fn text_editor_get_current_styles() -> *mut u8 {
#[no_mangle]
pub extern "C" fn text_editor_get_selection_rects() -> *mut u8 {
with_state_mut!(state, {
with_state!(state, {
if !get_text_editor_state().has_focus {
return std::ptr::null_mut();
}
@ -858,7 +858,7 @@ pub extern "C" fn text_editor_update_blink(timestamp_ms: f32) {
#[no_mangle]
pub extern "C" fn text_editor_render_overlay() {
with_state_mut!(state, {
with_state!(state, {
let Some(shape_id) = get_text_editor_state().active_shape_id else {
return;
};

View File

@ -7,7 +7,7 @@ use skia_safe as skia;
use crate::mem;
use crate::shapes::{self, TransformEntry, TransformEntrySource};
use crate::utils::uuid_from_u32_quartet;
use crate::{with_state, STATE};
use crate::with_state;
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]

208
scripts/check-commit Executable file
View File

@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""
Check commit messages against Penpot's commit guidelines.
Validates commit messages using the rules defined in:
- .github/workflows/commit-checker.yml (regex pattern)
- CONTRIBUTING.md (formatting rules, subject length, DCO)
By default, checks HEAD. Use --commit to specify a different commit.
Usage:
./scripts/check-commit
./scripts/check-commit --commit HEAD~1
./scripts/check-commit -c abc1234
"""
import argparse
import re
import subprocess
import sys
# ── Emoji list ───────────────────────────────────────────────────────────────
# Combined from commit-checker.yml AND CONTRIBUTING.md
VALID_EMOJIS = (
"lipstick|globe_with_meridians|wrench|books|"
"arrow_up|arrow_down|zap|ambulance|construction|"
"boom|fire|whale|bug|sparkles|paperclip|tada|"
"recycle|rewind|construction_worker|rocket"
)
# ── Regex from .github/workflows/commit-checker.yml ──────────────────────────
# Matches:
# 1) ":emoji: <Capitalized subject without trailing dot>"
# 2) "Merge|Revert|Reapply ... without trailing dot"
COMMIT_PATTERN = re.compile(
r"^((:(" + VALID_EMOJIS + r"):\s[A-Z].*[^.]))$"
)
MERGE_PATTERN = re.compile(r"^(Merge|Revert|Reapply).+[^.]$")
# ═══════════════════════════════════════════════════════════════════════════════
# Helpers
# ═══════════════════════════════════════════════════════════════════════════════
def run_git(args):
"""Run a git command and return (returncode, stdout, stderr)."""
try:
result = subprocess.run(
["git"] + args,
capture_output=True,
text=True,
check=False,
)
return result.returncode, result.stdout, result.stderr
except FileNotFoundError:
print("ERROR: git not found. Is it installed?", file=sys.stderr)
sys.exit(1)
def get_commit_message(commit_ref):
"""Return the full commit message for *commit_ref*."""
rc, out, err = run_git(["log", "--format=%B", "-n", "1", commit_ref])
if rc != 0:
print(f"ERROR: could not read commit {commit_ref}: {err.strip()}", file=sys.stderr)
sys.exit(1)
if not out.strip():
print(f"ERROR: commit {commit_ref} has no message", file=sys.stderr)
sys.exit(1)
return out.rstrip("\n")
# ═══════════════════════════════════════════════════════════════════════════════
# Validators
# ═══════════════════════════════════════════════════════════════════════════════
def check_regex(message):
"""Check the commit message against the CI regex pattern."""
# Normalise: strip trailing newlines for single-line matching
first_line = message.split("\n")[0]
if MERGE_PATTERN.match(first_line):
return True, None
if COMMIT_PATTERN.match(first_line):
return True, None
return False, (
"Commit subject must match one of:\n"
" :emoji: <Capitalized subject without trailing dot>\n"
" Merge|Revert|Reapply <rest without trailing dot>\n"
f"Got: {first_line!r}"
)
def check_subject_length(message):
"""Subject line must be ≤ 90 characters."""
first_line = message.split("\n")[0]
if len(first_line) > 90:
return False, (
f"Subject line exceeds 90 characters ({len(first_line)} chars):\n"
f" {first_line}"
)
return True, None
def check_subject_no_trailing_dot(message):
"""Subject line must not end with a period ('.')."""
first_line = message.split("\n")[0]
if first_line.endswith("."):
return False, (
"Subject line must not end with a period:\n"
f" {first_line}"
)
return True, None
def check_subject_capitalized(message):
"""Subject must be capitalized, but only if it's a regular commit (not Merge/Revert/Reapply)."""
first_line = message.split("\n")[0]
# Skip check for Merge/Revert/Reapply commits
if MERGE_PATTERN.match(first_line):
return True, None
# Strip emoji prefix before checking capitalization
emoji_match = re.match(r"^:([a-z_]+):\s+(.*)", first_line)
if emoji_match:
rest = emoji_match.group(2)
else:
rest = first_line
if rest and not rest[0].isupper():
return False, (
"Subject line must start with a capital letter "
"(after the emoji prefix):\n"
f" {first_line}"
)
return True, None
def check_body_blank_line(message):
"""If a body exists, there must be a blank line between subject and body."""
lines = message.split("\n")
if len(lines) >= 3 and lines[1] != "":
return False, (
"A blank line must separate the subject from the body."
)
return True, None
def check_signed_off_by(message):
"""Check for the DCO Signed-off-by line (required for code changes)."""
if "Signed-off-by:" not in message:
return False, (
"Missing 'Signed-off-by:' line in the commit footer.\n"
" Add it with 'git commit -s' or append it manually:\n"
" Signed-off-by: Your Real Name <your.email@example.com>"
)
return True, None
# ═══════════════════════════════════════════════════════════════════════════════
def main():
parser = argparse.ArgumentParser(
description="Check a commit message against Penpot commit guidelines."
)
parser.add_argument(
"-c", "--commit",
default="HEAD",
help="Commit to check (default: HEAD)",
)
args = parser.parse_args()
commit_ref = args.commit
message = get_commit_message(commit_ref)
print(f"Checking commit {commit_ref} ...\n")
validators = [
("Regex pattern", check_regex),
("Subject ≤ 90 chars", check_subject_length),
("No trailing period in subject", check_subject_no_trailing_dot),
("Subject capitalized", check_subject_capitalized),
("Blank line after subject", check_body_blank_line),
]
all_ok = True
for name, validator in validators:
ok, error_msg = validator(message)
status = "✓" if ok else "✗"
print(f" [{status}] {name}")
if not ok:
all_ok = False
print(f" {error_msg}", file=sys.stderr)
print()
if all_ok:
print("All checks passed.")
sys.exit(0)
else:
print("Some checks FAILED. See messages above.", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()