Compare commits

...

113 Commits

Author SHA1 Message Date
Andrey Antukh
75860afe57 Merge remote-tracking branch 'origin/staging' into staging-render 2025-12-30 15:29:58 +01:00
Andrey Antukh
824ca1bbca 🔧 Make devenv init yarn indpendent 2025-12-30 15:28:19 +01:00
Alejandro Alonso
48e3f35bb3 🐛 Fix setting a portion of text as bold or underline messes things up 2025-12-30 11:34:24 +01:00
Andrey Antukh
6b794c9d12 Merge branch 'staging' into staging-render 2025-12-30 11:13:15 +01:00
Yamila Moreno
d3ee50daf5 🔧 Add ci for branch staging-render 2025-12-30 11:13:00 +01:00
Alejandro Alonso
a948e49e51 🐛 Fix using cache on first zoom after pan 2025-12-30 10:03:24 +01:00
Alejandro Alonso
d635f5a8dc 🐛 Detecting situations where WebGL context is lost or no WebGL support 2025-12-30 10:03:24 +01:00
Alejandro Alonso
ab3a3ef43b 🎉 Resize cache only when required 2025-12-30 10:03:24 +01:00
Alejandro Alonso
9c21fd3359 🐛 Fix resize cache memory leak 2025-12-30 10:03:24 +01:00
Alejandro Alonso
44b70cf1d4 Merge pull request #7998 from penpot/alotor-fix-problem-with-create-grid
🐛 Fix problem creating grid from elements
2025-12-29 14:31:15 +01:00
Alejandro Alonso
a8bd74b392 Merge pull request #8001 from penpot/alotor-fix-gfonts-references
🐛 Fix problem with some fonts
2025-12-29 14:25:54 +01:00
alonso.torres
3d3e3582d6 🐛 Fix problem with some fonts 2025-12-29 12:35:19 +01:00
Andrey Antukh
de052b5161 📎 Update changelog 2025-12-29 11:10:04 +01:00
Andrey Antukh
6ebd48b94c Merge branch 'staging' into staging-render 2025-12-29 10:41:08 +01:00
Andrey Antukh
8a3b33797f 🐛 Fix error handling on password change form
Fixes https://github.com/penpot/penpot/issues/7978
2025-12-29 10:27:27 +01:00
Andrey Antukh
13fd20f76f Backport form error management improvements from develop 2025-12-29 10:27:27 +01:00
alonso.torres
417cd80564 🐛 Fix problem creating grid from elements 2025-12-23 14:49:21 +01:00
Alejandro Alonso
a57011ec7b Merge remote-tracking branch 'origin/staging' into staging-render 2025-12-23 13:35:27 +01:00
Alejandro Alonso
cb325282ec Merge pull request #7994 from penpot/alotor-fix-font-style
🐛 Fix problem when changing colors with multiple fonts
2025-12-23 07:34:41 +01:00
Andrey Antukh
01ecde3bfa Add the ability to add relations on penpot sdk (#7987)
*  Add the ability to add relations on penpot sdk

* 📎 Remove debug console log
2025-12-22 20:55:31 +01:00
Alonso Torres
4000ec8762 🐛 Fix problem resizing auto size layouts (#7995) 2025-12-22 20:17:11 +01:00
Andrey Antukh
6a1854f180 Merge remote-tracking branch 'origin/staging' into staging-render 2025-12-22 17:28:01 +01:00
alonso.torres
bd580ab159 🐛 Fix problem when changing colors with multiple fonts 2025-12-22 17:14:37 +01:00
Alejandro Alonso
5780a43fe0 🐛 Fix object added in different page (#7988) 2025-12-22 16:59:47 +01:00
Alejandro Alonso
737eceda3a 🐛 Fix unmasking shapes (#7989) 2025-12-22 16:59:04 +01:00
Alonso Torres
923c3c2dbd 🐛 Fix font weight token (#7991) 2025-12-22 16:58:26 +01:00
Alejandro Alonso
a14b4561e7 🐛 Fix comment bubbles (#7990) 2025-12-22 16:57:45 +01:00
Andrey Antukh
bb5568e15a 🎉 Enable hindi translations on the application 2025-12-22 16:57:00 +01:00
Pablo Alba
5cbcec3db6 🐛 Fix "maximum call stack size exceeded" crash on variant 2025-12-22 16:57:00 +01:00
Alejandro Alonso
fe44c14bac Merge pull request #7982 from penpot/niwinz-staging-import-bucket
🐛 Prefill storage object bucket if it comes nil on import binfile
2025-12-22 12:17:16 +01:00
Belén Albeza
20061067ad 🐛 Fix text editor not getting focus back after font variant change 2025-12-22 11:18:25 +01:00
Andrey Antukh
336173645e 🐛 Fix regression on export shape on plungins API 2025-12-22 10:41:42 +01:00
Andrey Antukh
08267de242 Merge remote-tracking branch 'origin/staging' into staging-render 2025-12-22 09:23:48 +01:00
Andrey Antukh
83bb4bf221 🐛 Prefill storage object bucket if it comes nil on import binfile 2025-12-19 09:32:51 +01:00
Alejandro Alonso
15ed25ca79 Merge pull request #7966 from penpot/niwinz-staging-abrreviate
🐛 Fix incorrect string truncation with abbreviate template filter
2025-12-12 13:53:33 +01:00
Andrey Antukh
9aa387a473 🐛 Fix incorrect string truncation with abbreviate template filter 2025-12-12 13:50:46 +01:00
Alejandro Alonso
67ba91b4b9 Merge pull request #7971 from penpot/niwinz-staging-bugfix-6
🐛 Fix tokens-lib encoding when value is nilable
2025-12-12 13:46:06 +01:00
Alejandro Alonso
f67f1a6a0e Merge pull request #7972 from penpot/niwinz-staging-bugfix-7
🐛 Fix exception on assinging gradient to shadow on multiple selection
2025-12-12 13:42:39 +01:00
Alejandro Alonso
82d3e2024e Merge pull request #7973 from penpot/niwinz-staging-worker-scheduler
🐛 Fix incorrect redis connection error handling
2025-12-12 13:23:49 +01:00
Alejandro Alonso
4bd846c16d Merge pull request #7969 from penpot/niwinz-staging-fix-ratelimit
🐛 Fix issue on reading rlimit config
2025-12-12 13:22:53 +01:00
alonso.torres
8fde6b28ed 🐛 Fix problems with alignments and margins 2025-12-12 13:21:04 +01:00
alonso.torres
63325ec796 🐛 Fix problem with flex fill size distribution 2025-12-12 13:21:04 +01:00
alonso.torres
84415476d0 🐛 Fix problem with reflow layout 2025-12-12 13:21:04 +01:00
Andrey Antukh
94f95ca6b8 🐛 Fix incorrect redis connection error handling 2025-12-12 12:33:38 +01:00
Aitor Moreno
5a922c6bd6 Merge pull request #7960 from penpot/superalex-fix-too-many-active-webgl-contexts
🐛 Fix too many active WEBGL contexts
2025-12-12 12:03:46 +01:00
Andrey Antukh
507bf7445b 🐛 Fix tokens-lib encoding when value is nilable 2025-12-12 11:42:15 +01:00
Andrey Antukh
81b72c5acd 🐛 Fix exception on assinging gradient to shadow on multiple selection 2025-12-12 11:24:53 +01:00
Alejandro Alonso
1388865cfc 🐛 Fix too many active WEBGL contexts 2025-12-12 11:16:47 +01:00
Andrey Antukh
1738847694 Merge remote-tracking branch 'origin/staging' into staging-render 2025-12-12 10:20:21 +01:00
Aitor Moreno
ca1c3c799d Merge pull request #7968 from penpot/alotor-fix-border-radius
🐛 Fix problem with border radius to path
2025-12-12 10:18:07 +01:00
Andrey Antukh
974495e08f Reduce log level for profile picture download error
Because it is not blocking operation and does not provents user
to proceed.
2025-12-12 08:17:13 +01:00
Andrey Antukh
2ed39e43c3 🐛 Fix issue on reading rlimit config 2025-12-11 23:50:01 +01:00
alonso.torres
ce5006ae84 🐛 Fix problem with border radius to path 2025-12-11 22:40:44 +01:00
Eva Marco
50dbe6ab12 🐛 Fix horizontal scroll on layer panel (#7956) 2025-12-11 21:34:18 +01:00
Belén Albeza
0a7a65af5d ♻️ Make SerializableResult to depend on From traits 2025-12-11 16:00:03 +01:00
alonso.torres
ea4d0e1238 Calculate position data in wasm 2025-12-11 16:00:03 +01:00
Elena Torro
b705cf953a 🐛 Set layout data from set-object 2025-12-11 14:52:32 +01:00
Alejandro Alonso
90ce1f56e7 Merge pull request #7958 from penpot/superalex-fix-svg-extract-ids
🐛 Fix svg extract ids
2025-12-11 14:02:05 +01:00
Alejandro Alonso
ab0438cc6f 🐛 Fix svg extract ids 2025-12-11 13:47:00 +01:00
Aitor Moreno
c6aa9cc4b7 Merge pull request #7950 from penpot/ladybenko-12851-fix-text-selection
🐛 Fix text selection when editor regains focus
2025-12-11 13:45:29 +01:00
Andrey Antukh
5779adef33 Merge remote-tracking branch 'origin/staging' into staging-render 2025-12-11 13:30:59 +01:00
Andrey Antukh
2f46cbc0d4 Make render wasm import on worker http cache aware 2025-12-11 13:27:20 +01:00
Elena Torró
ebf1758958 Merge pull request #7935 from penpot/superalex-improve-svg-import
🎉 Improve svg import
2025-12-11 13:21:29 +01:00
Elena Torró
e94c56bfa7 Merge pull request #7954 from penpot/azazeln28-fix-font-weight-mixed-value
🐛 Fix font weight mixed value
2025-12-11 12:43:53 +01:00
Andrey Antukh
53be6f996b 🐛 Fix issues on build processs related to render-wasm 2025-12-11 12:41:19 +01:00
Alejandro Alonso
89d9591011 🎉 Improve svg import 2025-12-11 12:02:34 +01:00
Andrey Antukh
5a260294a1 🔧 Update build-tag.yml github workflow 2025-12-11 12:00:42 +01:00
Andrey Antukh
3f6e44316e 🐛 Add missing node depes install on render-wasm 2025-12-11 11:51:47 +01:00
Aitor Moreno
5501a2815f 🐛 Fix font-variant-id mixed value 2025-12-11 11:32:27 +01:00
Eva Marco
77ef8e6fe6 🐛 Fix scroll on move library modal (#7952) 2025-12-11 10:46:54 +01:00
Alejandro Alonso
1066438b02 Merge pull request #7922 from penpot/elenatorro-12855-improve-pan-rendering
🔧 Improve pan rendering
2025-12-10 15:58:59 +01:00
Alejandro Alonso
3b23a3ad19 Merge pull request #7947 from penpot/elenatorro-12880-fix-variant-ui
🔧 Support variants interactivity on the new render's UI
2025-12-10 15:27:48 +01:00
Alejandro Alonso
916b7709dc Update Pencil Penpot Design System System template in carousel (#7948) 2025-12-10 15:09:28 +01:00
Belén Albeza
5cf51f3d26 🐛 Fix text selection not being restore if it was only 1 word 2025-12-10 15:05:13 +01:00
Belén Albeza
25acad5154 🔧 Add formatting rules to the TextEditor 2025-12-10 15:04:34 +01:00
Elena Torro
0a212b6291 🔧 Support variants interactivity on the new render's UI 2025-12-10 14:39:59 +01:00
Eva Marco
443e41fea4 🐛 Fix multiple selection with color tokens (#7941) 2025-12-10 14:36:08 +01:00
Alejandro Alonso
c7c9b04095 Merge pull request #7944 from penpot/niwinz-staging-exporter-fix
🐛 Fix incorrect resource lifetime handling on exporter
2025-12-10 14:35:20 +01:00
Eva Marco
c61a0c0332 📚 Add line to changelog (#7945) 2025-12-10 13:58:18 +01:00
Andrey Antukh
a78477592b Merge remote-tracking branch 'origin/staging' into staging-render 2025-12-10 13:36:23 +01:00
Eva Marco
8707ff6511 🎉 Add spanish translation 2025-12-10 13:12:30 +01:00
Florian Schroedl
3d8a251741 🐛 Disallow font-family referencing composite token 2025-12-10 13:12:30 +01:00
Andrey Antukh
34e84ee3c8 🐛 Fix incorrect resource lifetime handling on exporter 2025-12-10 13:02:31 +01:00
Alejandro Alonso
e8201402a7 Merge pull request #7938 from penpot/niwinz-staging-bugfix-5
🐛 Fix several issues
2025-12-10 12:05:42 +01:00
Aitor Moreno
8a22477b96 Merge pull request #7932 from penpot/niwinz-staging-worker-wasm-load
🐛 Fix WASM loading strategy on worker
2025-12-10 11:47:31 +01:00
Elena Torro
a661b2564f 🐛 Fix default case on vertical align 2025-12-10 10:59:27 +01:00
Elena Torro
2c3732f3f4 🔧 Fix line height calculation 2025-12-10 10:59:27 +01:00
Andrey Antukh
45665a3c21 Merge remote-tracking branch 'origin/staging' into staging-render 2025-12-10 10:09:58 +01:00
Alejandro Alonso
3e684ea54f ⬆️ Update svgo dependency on frontend (#7936) 2025-12-10 10:07:02 +01:00
Andrey Antukh
98039f13d8 🐛 Fix main toolbar z-index 2025-12-10 09:47:40 +01:00
Alejandro Alonso
40c27591f6 🐛 Fix svg import (#7925) 2025-12-10 08:36:54 +01:00
Andrey Antukh
91d20a46d1 💄 Add cosmetic changes to exports assets progress component 2025-12-10 08:23:05 +01:00
Andrey Antukh
50bead7c56 🐛 Fix react warning on having p inside p on assets export progress 2025-12-10 08:22:41 +01:00
Andrey Antukh
b75b999903 📎 Fix devenv jvm warning 2025-12-10 08:22:05 +01:00
Andrey Antukh
810f1721c8 🐛 Fix recursion render on subscription modal 2025-12-10 07:54:52 +01:00
Andrey Antukh
a4646373cf ♻️ Refactor wasm loading strategy on worker 2025-12-09 19:41:19 +01:00
Andrey Antukh
f111cbb2a4 Add better cache config on devenv nginx 2025-12-09 19:38:30 +01:00
Aitor Moreno
a614207f7e 🐛 Fix exporter failing with HTTPS 2025-12-09 16:08:20 +01:00
Elena Torro
81bc1bb0af 🔧 Log performance when building using profile-macros 2025-12-09 15:25:13 +01:00
Luis de Dios
6ce3249c6d 🐛 Fix color format does not switch in the view mode (#7923)
* 🐛 Fix color format does not switch in the inspect mode of the view mode

* ♻️ Update components
2025-12-09 14:38:15 +01:00
Elena Torro
b8feb6374d 🔧 Rebuild indices on zoom change, not pan 2025-12-09 11:26:03 +01:00
Elena Torro
0889df8e08 🔧 Skip slow operations on fast render 2025-12-09 11:26:03 +01:00
Pablo Alba
b0351be724 🐛 Fix switch variants with paths 2025-12-09 11:08:55 +01:00
Andrey Antukh
b8392b3731 🐛 Fix regression on sending team invitations (#7912) 2025-12-05 12:36:06 +01:00
Andrey Antukh
77dba477ca 🔧 Backport build-tag github workflow from develop 2025-12-05 10:25:03 +01:00
Eva Marco
b6598d1f07 🐛 Fix scrollbar on color modal (#7906) 2025-12-05 09:55:41 +01:00
Xaviju
bf1dc21c75 💄 Hide themes & sets panels when none active (#7902) 2025-12-04 14:11:57 +01:00
Alejandro Alonso
46c20a993f Merge pull request #7904 from penpot/niwinz-staging-fix-invitation-resend
🐛 Fix exception on resending invitation
2025-12-04 11:56:07 +01:00
Andrey Antukh
0e0106f69a 🐛 Add correct assertion on create-invitation fn 2025-12-04 11:38:32 +01:00
Andrey Antukh
19bb69cc60 Improve invalid schema error report 2025-12-04 11:38:16 +01:00
Alejandro Alonso
504eb70988 Merge pull request #7885 from penpot/niwinz-staging-bugfix-2
🐛 Make workspace palette reposition on left sidebar collapse
2025-12-04 11:19:20 +01:00
Andrey Antukh
feababe2a8 🐛 Make workspace palette reposition on left sidebar collapse 2025-12-03 09:56:14 +01:00
Andrey Antukh
5ef06685fc 💄 Add cosmetic improvements to workspace palette component 2025-12-03 09:38:23 +01:00
161 changed files with 4358 additions and 1267 deletions

View File

@@ -0,0 +1,14 @@
name: _STAGING RENDER
on:
schedule:
- cron: '36 5-20 * * 1-5'
jobs:
build-bundle:
uses: ./.github/workflows/build-bundle.yml
secrets: inherit
with:
gh_ref: "staging-render"
build_wasm: "yes"
build_storybook: "yes"

View File

@@ -11,7 +11,7 @@ jobs:
secrets: inherit
with:
gh_ref: ${{ github.ref_name }}
build_wasm: "no"
build_wasm: "yes"
build_storybook: "yes"
build-docker:
@@ -21,6 +21,22 @@ jobs:
with:
gh_ref: ${{ github.ref_name }}
notify:
name: Notifications
runs-on: ubuntu-24.04
needs: build-docker
steps:
- name: Notify Mattermost
uses: mattermost/action-mattermost-notify@master
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd
TEXT: |
🐳 *[PENPOT] Docker image available.*
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
@infra
publish-final-tag:
if: ${{ !contains(github.ref_name, '-RC') && !contains(github.ref_name, '-alpha') && !contains(github.ref_name, '-beta') && contains(github.ref_name, '.') }}
needs: build-docker

View File

@@ -1,6 +1,12 @@
# CHANGELOG
## 2.12.0 (Unreleased)
## 2.12.1
### :bug: Bugs fixed
- Fix setting a portion of text as bold or underline messes things up [Github #7980](https://github.com/penpot/penpot/issues/7980)
## 2.12.0
### :boom: Breaking changes & Deprecations
@@ -62,6 +68,7 @@ example. It's still usable as before, we just removed the example.
- Ensure consistent snap behavior across all zoom levels [Github #7774](https://github.com/penpot/penpot/pull/7774) by [@Tokytome](https://github.com/Tokytome)
- Fix crash in token grid view due to tooltip validation (by @dfelinto) [Github #7887](https://github.com/penpot/penpot/pull/7887)
- Enable Hindi translations on the application
### :sparkles: New features & Enhancements
@@ -92,6 +99,10 @@ example. It's still usable as before, we just removed the example.
- Fix U and E icon displayed in project list [Taiga #12806](https://tree.taiga.io/project/penpot/issue/12806)
- Fix unpublish library modal not scrolling a long file list [Taiga #12285](https://tree.taiga.io/project/penpot/issue/12285)
- Fix incorrect interaction betwen hower and scroll on assets sidebar [Taiga #12389](https://tree.taiga.io/project/penpot/issue/12389)
- Fix switch variants with paths [Taiga #12841](https://tree.taiga.io/project/penpot/issue/12841)
- Fix referencing typography tokens on font-family tokens [Taiga #12492](https://tree.taiga.io/project/penpot/issue/12492)
- Fix horizontal scroll on layer panel [Taiga #12843](https://tree.taiga.io/project/penpot/issue/12843)
- Fix unicode handling on email template abbreviation filter [Github #7966](https://github.com/penpot/penpot/pull/7966)
## 2.11.1

View File

@@ -240,4 +240,4 @@
</div>
</body>
</html>
</html>

View File

@@ -3,7 +3,7 @@
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Tokens%20starter%20kit.penpot"}
{:id "penpot-design-system"
:name "Penpot Design System | Pencil"
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/penpot-app.penpot"}
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Pencil-Penpot-Design-System.penpot"}
{:id "wireframing-kit"
:name "Wireframe library"
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Wireframing%20kit%20v1.1.penpot"}

View File

@@ -821,9 +821,10 @@
entries (keep (match-storage-entry-fn) entries)]
(doseq [{:keys [id entry]} entries]
(let [object (->> (read-entry input entry)
(decode-storage-object)
(validate-storage-object))
(let [object (-> (read-entry input entry)
(decode-storage-object)
(update :bucket d/nilv sto/default-bucket)
(validate-storage-object))
ext (cmedia/mtype->extension (:content-type object))
path (str "objects/" id ext)

View File

@@ -307,7 +307,8 @@
:content-type (:mtype input)})]
(:id sobject))
(catch Throwable cause
(l/err :hint "unable to import profile picture"
(l/wrn :hint "unable to import profile picture"
:uri uri
:cause cause)
nil)))

View File

@@ -104,28 +104,29 @@
(def ^:private schema:limit
[:and
[:map
[::name :any]
[::name :keyword]
[::strategy schema:strategy]
[::key :string]
[::opts :string]]
[:or
[:map
[::capacity ::sm/int]
[::rate ::sm/int]
[::internal ::ct/duration]
[::params [::sm/vec :any]]]
[:map
[::nreq ::sm/int]
[::unit [:enum :days :hours :minutes :seconds :weeks]]]]])
[::opts :string]
[::capacity {:optional true} ::sm/int]
[::rate {:optional true} ::sm/int]
[::interval {:optional true} ::ct/duration]
[::params {:optional true} [::sm/vec :any]]
[::permits {:optional true} ::sm/int]
[::unit {:optional true} [:enum :days :hours :minutes :seconds :weeks]]]
[:fn (fn [attrs]
(let [contains-fn (partial contains? attrs)]
(or (every? contains-fn [::capacity ::rate ::interval])
(every? contains-fn [::permits ::unit]))))]])
(def ^:private schema:limits
[:map-of :keyword [::sm/vec schema:limit]])
(def ^:private valid-limit-tuple?
(sm/lazy-validator schema:limit-tuple))
(sm/validator schema:limit-tuple))
(def ^:private valid-rlimit-instance?
(sm/lazy-validator ::rpc/rlimit))
(sm/validator ::rpc/rlimit))
(defmethod parse-limit :window
[[name strategy opts :as vlimit]]
@@ -134,16 +135,16 @@
(merge
{::name name
::strategy strategy}
(if-let [[_ nreq unit] (re-find window-opts-re opts)]
(let [nreq (parse-long nreq)]
{::nreq nreq
(if-let [[_ permits unit] (re-find window-opts-re opts)]
(let [permits (parse-long permits)]
{::permits permits
::unit (case unit
"d" :days
"h" :hours
"m" :minutes
"s" :seconds
"w" :weeks)
::key (str "ratelimit.window." (d/name name))
::key (str "penpot.rlimit." (cf/get :tenant) ".window." (d/name name))
::opts opts})
(ex/raise :type :validation
:code :invalid-window-limit-opts
@@ -164,15 +165,15 @@
::interval interval
::opts opts
::params [(->seconds interval) rate capacity]
::key (str "ratelimit.bucket." (d/name name))})
::key (str "penpot.rlimit." (cf/get :tenant) ".bucket." (d/name name))})
(ex/raise :type :validation
:code :invalid-bucket-limit-opts
:hint (str/ffmt "looks like '%' does not have a valid format" opts))))
(defmethod process-limit :bucket
[rconn user-id now {:keys [::key ::params ::service ::capacity ::interval ::rate] :as limit}]
[rconn profile-id now {:keys [::key ::params ::service ::capacity ::interval ::rate] :as limit}]
(let [script (-> bucket-rate-limit-script
(assoc ::rscript/keys [(str key "." service "." user-id)])
(assoc ::rscript/keys [(str key "." service "." profile-id)])
(assoc ::rscript/vals (conj params (->seconds now))))
result (rds/eval rconn script)
allowed? (boolean (nth result 0))
@@ -192,18 +193,18 @@
(assoc ::lresult/remaining remaining))))
(defmethod process-limit :window
[rconn user-id now {:keys [::nreq ::unit ::key ::service] :as limit}]
[rconn profile-id now {:keys [::permits ::unit ::key ::service] :as limit}]
(let [ts (ct/truncate now unit)
ttl (ct/diff now (ct/plus ts {unit 1}))
script (-> window-rate-limit-script
(assoc ::rscript/keys [(str key "." service "." user-id "." (ct/format-inst ts))])
(assoc ::rscript/vals [nreq (->seconds ttl)]))
(assoc ::rscript/keys [(str key "." service "." profile-id "." (ct/format-inst ts))])
(assoc ::rscript/vals [permits (->seconds ttl)]))
result (rds/eval rconn script)
allowed? (boolean (nth result 0))
remaining (nth result 1)]
(l/trace :hint "limit processed"
:service service
:limit (name (::name limit))
:name (name (::name limit))
:strategy (name (::strategy limit))
:opts (::opts limit)
:allowed allowed?
@@ -214,8 +215,8 @@
(assoc ::lresult/reset (ct/plus ts {unit 1})))))
(defn- process-limits
[rconn user-id limits now]
(let [results (into [] (map (partial process-limit rconn user-id now)) limits)
[rconn profile-id limits now]
(let [results (into [] (map (partial process-limit rconn profile-id now)) limits)
remaining (->> results
(d/index-by ::name ::lresult/remaining)
(uri/map->query-string))
@@ -227,7 +228,7 @@
(when rejected
(l/warn :hint "rejected rate limit"
:user-id (str user-id)
:profile-id (str profile-id)
:limit-service (-> rejected ::service name)
:limit-name (-> rejected ::name name)
:limit-strategy (-> rejected ::strategy name)))
@@ -371,12 +372,9 @@
(defn- on-refresh-error
[_ cause]
(when-not (instance? java.util.concurrent.RejectedExecutionException cause)
(if-let [explain (-> cause ex-data ex/explain)]
(l/warn ::l/raw (str "unable to refresh config, invalid format:\n" explain)
::l/sync? true)
(l/warn :hint "unexpected exception on loading config"
:cause cause
::l/sync? true))))
(l/warn :hint "unexpected exception on loading config"
:cause cause
::l/sync? true)))
(defn- get-config-path
[]

View File

@@ -25,9 +25,9 @@ local allowed = filled >= requested
local newTokens = filled
if allowed then
newTokens = filled - requested
redis.call("hset", tokensKey, "tokens", newTokens, "timestamp", timestamp)
end
redis.call("hset", tokensKey, "tokens", newTokens, "timestamp", timestamp)
redis.call("expire", tokensKey, ttl)
return { allowed, newTokens }

View File

@@ -35,6 +35,9 @@
:assets-s3 :s3
nil)))
(def default-bucket
"file-media-object")
(def valid-buckets
#{"file-media-object"
"team-font-variant"

View File

@@ -25,7 +25,7 @@
[app.common.time :as ct]
[app.config :as cf]
[app.db :as db]
[app.storage :as-alias sto]
[app.storage :as sto]
[app.storage.impl :as impl]
[integrant.core :as ig]))
@@ -130,7 +130,7 @@
[{:keys [metadata]}]
(or (some-> metadata :bucket)
(some-> metadata :reference d/name)
"file-media-object"))
sto/default-bucket))
(defn- process-objects!
[conn has-refs? bucket objects]

View File

@@ -7,10 +7,18 @@
(ns app.util.template
(:require
[app.common.exceptions :as ex]
[cuerdas.core :as str]
[selmer.filters :as sf]
[selmer.parser :as sp]))
;; (sp/cache-off!)
(sf/add-filter! :abbreviate
(fn [s n]
(let [n (parse-long n)]
(str/abbreviate s n))))
(defn render
[path context]
(try

View File

@@ -137,33 +137,34 @@ RETURNING task.id, task.queue")
::wait)))
(run-batch []
(let [rconn (rds/connect cfg)]
(try
(-> cfg
(assoc ::rds/conn rconn)
(db/tx-run! run-batch'))
(try
(let [rconn (rds/connect cfg)]
(try
(-> cfg
(assoc ::rds/conn rconn)
(db/tx-run! run-batch'))
(finally
(.close ^AutoCloseable rconn))))
(catch InterruptedException cause
(throw cause))
(catch Exception cause
(cond
(rds/exception? cause)
(do
(l/wrn :hint "redis exception (will retry in an instant)" :cause cause)
(px/sleep timeout))
(catch InterruptedException cause
(throw cause))
(db/sql-exception? cause)
(do
(l/wrn :hint "database exception (will retry in an instant)" :cause cause)
(px/sleep timeout))
(catch Exception cause
(cond
(rds/exception? cause)
(do
(l/wrn :hint "redis exception (will retry in an instant)" :cause cause)
(px/sleep timeout))
:else
(do
(l/err :hint "unhandled exception (will retry in an instant)" :cause cause)
(px/sleep timeout))))
(db/sql-exception? cause)
(do
(l/wrn :hint "database exception (will retry in an instant)" :cause cause)
(px/sleep timeout))
(finally
(.close ^AutoCloseable rconn)))))
:else
(do
(l/err :hint "unhandled exception (will retry in an instant)" :cause cause)
(px/sleep timeout))))))
(dispatcher []
(l/inf :hint "started")
@@ -176,7 +177,7 @@ RETURNING task.id, task.queue")
(catch InterruptedException _
(l/trc :hint "interrupted"))
(catch Throwable cause
(l/err :hint " unexpected exception" :cause cause))
(l/err :hint "unexpected exception" :cause cause))
(finally
(l/inf :hint "terminated"))))]

View File

@@ -30,7 +30,7 @@
integrant/integrant {:mvn/version "1.0.0"}
funcool/tubax {:mvn/version "2021.05.20-0"}
funcool/cuerdas {:mvn/version "2025.06.16-414"}
funcool/cuerdas {:mvn/version "2026.415"}
funcool/promesa
{:git/sha "46048fc0d4bf5466a2a4121f5d52aefa6337f2e8"
:git/url "https://github.com/funcool/promesa"}

View File

@@ -82,6 +82,113 @@
(declare create-svg-children)
(declare parse-svg-element)
(defn- process-gradient-stops
"Processes gradient stops to extract stop-color and stop-opacity from style attributes
and convert them to direct attributes. This ensures stops with style='stop-color:#...;stop-opacity:1'
are properly converted to stop-color and stop-opacity attributes."
[stops]
(mapv (fn [stop]
(let [stop-attrs (:attrs stop)
stop-style (get stop-attrs :style)
;; Parse style if it's a string using csvg/parse-style utility
parsed-style (when (and (string? stop-style) (seq stop-style))
(csvg/parse-style stop-style))
;; Extract stop-color and stop-opacity from style
style-stop-color (when parsed-style (:stop-color parsed-style))
style-stop-opacity (when parsed-style (:stop-opacity parsed-style))
;; Merge: use direct attributes first, then style values as fallback
final-attrs (cond-> stop-attrs
(and style-stop-color (not (contains? stop-attrs :stop-color)))
(assoc :stop-color style-stop-color)
(and style-stop-opacity (not (contains? stop-attrs :stop-opacity)))
(assoc :stop-opacity style-stop-opacity)
;; Remove style attribute if we've extracted its values
(or style-stop-color style-stop-opacity)
(dissoc :style))]
(assoc stop :attrs final-attrs)))
stops))
(defn- resolve-gradient-href
"Resolves xlink:href references in gradients by merging the referenced gradient's
stops and attributes with the referencing gradient. This ensures gradients that
reference other gradients (like linearGradient3550 referencing linearGradient3536)
inherit the stops from the base gradient.
According to SVG spec, when a gradient has xlink:href:
- It inherits all attributes from the referenced gradient
- It inherits all stops from the referenced gradient
- The referencing gradient's attributes override the base ones
- If the referencing gradient has stops, they replace the base stops
Returns the defs map with all gradient href references resolved."
[defs]
(letfn [(resolve-gradient [gradient-id gradient-node defs visited]
(if (contains? visited gradient-id)
(do
#?(:cljs (js/console.warn "[resolve-gradient] Circular reference detected for" gradient-id)
:clj nil)
gradient-node) ;; Avoid circular references
(let [attrs (:attrs gradient-node)
href-id (or (:href attrs) (:xlink:href attrs))
href-id (when (and (string? href-id) (pos? (count href-id)))
(subs href-id 1)) ;; Remove leading #
base-gradient (when (and href-id (contains? defs href-id))
(get defs href-id))
resolved-base (when base-gradient (resolve-gradient href-id base-gradient defs (conj visited gradient-id)))]
(if resolved-base
;; Merge: base gradient attributes + referencing gradient attributes
;; Use referencing gradient's stops if present, otherwise use base stops
(let [base-attrs (:attrs resolved-base)
ref-attrs (:attrs gradient-node)
;; Start with base attributes (without id), then merge with ref attributes
;; This ensures ref attributes override base ones
base-attrs-clean (dissoc base-attrs :id)
ref-attrs-clean (dissoc ref-attrs :href :xlink:href :id)
;; Special handling for gradientTransform: if both have it, combine them
base-transform (get base-attrs :gradientTransform)
ref-transform (get ref-attrs :gradientTransform)
combined-transform (cond
(and base-transform ref-transform)
(str base-transform " " ref-transform) ;; Apply base first, then ref
:else (or ref-transform base-transform))
;; Merge attributes: base first, then ref (ref overrides)
merged-attrs (-> (d/deep-merge base-attrs-clean ref-attrs-clean)
(cond-> combined-transform
(assoc :gradientTransform combined-transform)))
;; If referencing gradient has content (stops), use it; otherwise use base content
final-content (if (seq (:content gradient-node))
(:content gradient-node)
(:content resolved-base))
;; Process stops to extract stop-color and stop-opacity from style attributes
processed-content (process-gradient-stops final-content)
result {:tag (:tag gradient-node)
:attrs (assoc merged-attrs :id gradient-id)
:content processed-content}]
result)
;; Process stops even for gradients without references to extract style attributes
(let [processed-content (process-gradient-stops (:content gradient-node))]
(assoc gradient-node :content processed-content))))))]
(let [gradient-tags #{:linearGradient :radialGradient}
result (reduce-kv
(fn [acc id node]
(if (contains? gradient-tags (:tag node))
(assoc acc id (resolve-gradient id node defs #{}))
(assoc acc id node)))
{}
defs)]
result)))
(defn create-svg-shapes
([svg-data pos objects frame-id parent-id selected center?]
(create-svg-shapes (uuid/next) svg-data pos objects frame-id parent-id selected center?))
@@ -112,6 +219,9 @@
(csvg/fix-percents)
(csvg/extract-defs))
;; Resolve gradient href references in all defs before processing shapes
def-nodes (resolve-gradient-href def-nodes)
;; In penpot groups have the size of their children. To
;; respect the imported svg size and empty space let's create
;; a transparent shape as background to respect the imported
@@ -142,12 +252,23 @@
(reduce (partial create-svg-children objects selected frame-id root-id svg-data)
[unames []]
(d/enumerate (->> (:content svg-data)
(mapv #(csvg/inherit-attributes root-attrs %)))))]
(mapv #(csvg/inherit-attributes root-attrs %)))))
[root-shape children])))
;; Collect all defs from children and merge into root shape
all-defs-from-children (reduce (fn [acc child]
(if-let [child-defs (:svg-defs child)]
(merge acc child-defs)
acc))
{}
children)
;; Merge defs from svg-data and children into root shape
root-shape-with-defs (assoc root-shape :svg-defs (merge def-nodes all-defs-from-children))]
[root-shape-with-defs children])))
(defn create-raw-svg
[name frame-id {:keys [x y width height offset-x offset-y]} {:keys [attrs] :as data}]
[name frame-id {:keys [x y width height offset-x offset-y defs] :as svg-data} {:keys [attrs] :as data}]
(let [props (csvg/attrs->props attrs)
vbox (grc/make-rect offset-x offset-y width height)]
(cts/setup-shape
@@ -160,10 +281,11 @@
:y y
:content data
:svg-attrs props
:svg-viewbox vbox})))
:svg-viewbox vbox
:svg-defs defs})))
(defn create-svg-root
[id frame-id parent-id {:keys [name x y width height offset-x offset-y attrs]}]
[id frame-id parent-id {:keys [name x y width height offset-x offset-y attrs defs] :as svg-data}]
(let [props (-> (dissoc attrs :viewBox :view-box :xmlns)
(d/without-keys csvg/inheritable-props)
(csvg/attrs->props))]
@@ -177,7 +299,8 @@
:height height
:x (+ x offset-x)
:y (+ y offset-y)
:svg-attrs props})))
:svg-attrs props
:svg-defs defs})))
(defn create-svg-children
[objects selected frame-id parent-id svg-data [unames children] [_index svg-element]]
@@ -198,7 +321,7 @@
(defn create-group
[name frame-id {:keys [x y width height offset-x offset-y] :as svg-data} {:keys [attrs]}]
[name frame-id {:keys [x y width height offset-x offset-y defs] :as svg-data} {:keys [attrs]}]
(let [transform (csvg/parse-transform (:transform attrs))
attrs (-> attrs
(d/without-keys csvg/inheritable-props)
@@ -214,7 +337,8 @@
:height height
:svg-transform transform
:svg-attrs attrs
:svg-viewbox vbox})))
:svg-viewbox vbox
:svg-defs defs})))
(defn create-path-shape [name frame-id svg-data {:keys [attrs] :as data}]
(when (and (contains? attrs :d) (seq (:d attrs)))
@@ -523,6 +647,21 @@
:else (dm/str tag))]
(dm/str "svg-" suffix)))
(defn- filter-valid-def-references
"Filters out false positive references that are not valid def IDs.
Filters out:
- Colors in style attributes (hex colors like #f9dd67)
- Style fragments that contain CSS keywords (like stop-opacity)
- References that don't exist in defs"
[ref-ids defs]
(let [is-style-fragment? (fn [ref-id]
(or (clr/hex-color-string? (str "#" ref-id))
(str/includes? ref-id ";") ;; Contains CSS separator
(str/includes? ref-id "stop-opacity") ;; CSS keyword
(str/includes? ref-id "stop-color")))] ;; CSS keyword
(->> ref-ids
(remove is-style-fragment?) ;; Filter style fragments and hex colors
(filter #(contains? defs %))))) ;; Only existing defs
(defn parse-svg-element
[frame-id svg-data {:keys [tag attrs hidden] :as element} unames]
@@ -534,7 +673,11 @@
(let [name (or (:id attrs) (tag->name tag))
att-refs (csvg/find-attr-references attrs)
defs (get svg-data :defs)
references (csvg/find-def-references defs att-refs)
valid-refs (filter-valid-def-references att-refs defs)
all-refs (csvg/find-def-references defs valid-refs)
;; Filter the final result to ensure all references are valid defs
;; This prevents false positives from style attributes in gradient stops
references (filter-valid-def-references all-refs defs)
href-id (or (:href attrs) (:xlink:href attrs) " ")
href-id (if (and (string? href-id)

View File

@@ -12,8 +12,11 @@
[app.common.files.changes-builder :as pcb]
[app.common.files.helpers :as cfh]
[app.common.files.variant :as cfv]
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
[app.common.geom.rect :as grc]
[app.common.geom.shapes :as gsh]
[app.common.geom.shapes.common :as gco]
[app.common.logging :as log]
[app.common.logic.shapes :as cls]
[app.common.logic.variant-properties :as clvp]
@@ -26,6 +29,7 @@
[app.common.types.library :as ctl]
[app.common.types.page :as ctp]
[app.common.types.pages-list :as ctpl]
[app.common.types.path.segment :as segment]
[app.common.types.shape :as cts]
[app.common.types.shape-tree :as ctst]
[app.common.types.shape.interactions :as ctsi]
@@ -1876,6 +1880,44 @@
roperations'
uoperations')))))))
(defn- set-path-new-values
[current-shape prev-shape transform]
(let [new-content (segment/transform-content
(:content current-shape)
(gmt/transform-in (gpt/point 0 0) transform))
new-points (-> (segment/content->selrect new-content)
(grc/rect->points))
points-center (gco/points->center new-points)
new-selrect (gsh/calculate-selrect new-points points-center)
shape (assoc current-shape
:content new-content
:points new-points
:selrect new-selrect)
prev-center (segment/content-center (:content prev-shape))
delta (gpt/subtract points-center (first new-points))
new-pos (gpt/subtract prev-center delta)]
(gsh/absolute-move shape new-pos)))
(defn- switch-path-change-value
[prev-shape ;; The shape before the switch
current-shape ;; The shape after the switch (a clean copy)
ref-shape ;; The referenced shape on the main component
;; before the switch
attr]
(let [old-width (-> ref-shape :selrect :width)
new-width (-> prev-shape :selrect :width)
old-height (-> ref-shape :selrect :height)
new-height (-> prev-shape :selrect :height)
transform (-> (gpt/point (/ new-width old-width)
(/ new-height old-height))
(gmt/scale-matrix))
shape (set-path-new-values current-shape prev-shape transform)]
(get shape attr)))
(defn- switch-text-change-value
[prev-content ;; The :content of the text before the switch
@@ -2027,6 +2069,10 @@
(= :content attr)
(touched attr-group))
path-change?
(and (= :path (:type current-shape))
(contains? #{:points :selrect :content} attr))
;; position-data is a special case because can be affected by :geometry-group and :content-group
;; so, if the position-data changes but the geometry is touched we need to reset the position-data
;; so it's calculated again
@@ -2055,6 +2101,12 @@
(:content origin-ref-shape)
touched)
path-change?
(switch-path-change-value previous-shape
current-shape
origin-ref-shape
attr)
:else
(get previous-shape attr)))

View File

@@ -281,7 +281,20 @@
(defn check-fn
"Create a predefined check function"
[s & {:keys [hint type code]}]
(let [s (schema s)
(let [s #?(:clj
(schema s)
:cljs
(try
(schema s)
(catch :default cause
(let [data (ex-data cause)]
(if (= :malli.core/invalid-schema (:type data))
(throw (ex-info
(str "Invalid schema\n"
(pp/pprint-str (:data data)))
{}))
(throw cause))))))
validator* (delay (m/validator s))
explainer* (delay (m/explainer s))
hint (or ^boolean hint "check error")

View File

@@ -546,9 +546,19 @@
filter-values)))
(defn extract-ids [val]
(when (some? val)
;; Extract referenced ids from string values like "url(#myId)".
;; Non-string values (maps, numbers, nil, etc.) return an empty seq
;; to avoid re-seq type errors when attributes carry nested structures.
(cond
(string? val)
(->> (re-seq xml-id-regex val)
(mapv second))))
(mapv second))
(sequential? val)
(mapcat extract-ids val)
:else
[]))
(defn fix-dot-number
"Fixes decimal numbers starting in dot but without leading 0"

View File

@@ -362,24 +362,24 @@
component (ctkl/get-component component-file (:component-id top-instance) true)
remote-shape (get-ref-shape component-file component shape)
component-container (get-component-container component-file component)
[remote-shape component-container]
[remote-shape component-container component-file]
(if (some? remote-shape)
[remote-shape component-container]
[remote-shape component-container component-file]
;; If not found, try the case of this being a fostered or swapped children
(let [head-instance (ctn/get-head-shape (:objects container) shape)
component-file (get-in libraries [(:component-file head-instance) :data])
head-component (ctkl/get-component component-file (:component-id head-instance) true)
remote-shape' (get-ref-shape component-file head-component shape)
component-container (get-component-container component-file component)]
[remote-shape' component-container]))]
(let [head-instance (ctn/get-head-shape (:objects container) shape)
component-file (get-in libraries [(:component-file head-instance) :data])
head-component (ctkl/get-component component-file (:component-id head-instance) true)
remote-shape' (get-ref-shape component-file head-component shape)
component-container' (get-component-container component-file head-component)]
[remote-shape' component-container' component-file]))]
(if (nil? remote-shape)
nil
(if (nil? (:shape-ref remote-shape))
(cond-> remote-shape
(and remote-shape with-context?)
(with-meta {:file {:id (:id file-data)
:data file-data}
(with-meta {:file {:id (:id component-file)
:data component-file}
:container component-container}))
(find-remote-shape component-container libraries remote-shape :with-context? with-context?)))))

View File

@@ -1410,8 +1410,8 @@ Will return a value that matches this schema:
;; NOTE: we can't assign statically at eval time the value of a
;; function that is declared but not defined; so we need to pass
;; an anonymous function and delegate the resolution to runtime
{:encode/json #(export-dtcg-json %)
:decode/json #(read-multi-set-dtcg %)
{:encode/json #(some-> % export-dtcg-json)
:decode/json #(some-> % read-multi-set-dtcg)
;; FIXME: add better, more reallistic generator
:gen/gen (->> (sg/small-int)
(sg/fmap (fn [_]

View File

@@ -223,16 +223,19 @@ http {
add_header X-Cache-Status $upstream_cache_status;
}
location ~ ^/(/|css|fonts|images|js|wasm|mjs|map) {
location ~* \.(jpg|png|svg|ttf|woff|woff2)$ {
add_header Cache-Control "public, max-age=604800" always; # 7 days
}
location ~* \.(js|css|wasm)$ {
add_header Cache-Control "no-store" always;
}
location ~ ^/[^/]+/(.*)$ {
return 301 " /404";
}
add_header Cache-Control "no-store";
# This header is what we need to use on prod
# add_header Cache-Control "public, must-revalidate, max-age=0";
add_header Cache-Control "no-store" always;
try_files $uri /index.html$is_args$args /index.html =404;
}
}

View File

@@ -23,30 +23,25 @@ tmux -2 new-session -d -s penpot
tmux rename-window -t penpot:0 'frontend watch'
tmux select-window -t penpot:0
tmux send-keys -t penpot 'cd penpot/frontend' enter C-l
tmux send-keys -t penpot 'yarn run watch' enter
tmux send-keys -t penpot './scripts/watch app' enter
tmux new-window -t penpot:1 -n 'frontend shadow'
tmux new-window -t penpot:1 -n 'frontend storybook'
tmux select-window -t penpot:1
tmux send-keys -t penpot 'cd penpot/frontend' enter C-l
tmux send-keys -t penpot 'yarn run watch:app' enter
tmux send-keys -t penpot './scripts/watch storybook' enter
tmux new-window -t penpot:2 -n 'frontend storybook'
tmux new-window -t penpot:2 -n 'exporter'
tmux select-window -t penpot:2
tmux send-keys -t penpot 'cd penpot/frontend' enter C-l
tmux send-keys -t penpot 'yarn run watch:storybook' enter
tmux new-window -t penpot:3 -n 'exporter'
tmux select-window -t penpot:3
tmux send-keys -t penpot 'cd penpot/exporter' enter C-l
tmux send-keys -t penpot 'rm -f target/app.js*' enter C-l
tmux send-keys -t penpot 'yarn run watch' enter
tmux send-keys -t penpot './scripts/watch' enter
tmux split-window -v
tmux send-keys -t penpot 'cd penpot/exporter' enter C-l
tmux send-keys -t penpot './scripts/wait-and-start.sh' enter
tmux new-window -t penpot:4 -n 'backend'
tmux select-window -t penpot:4
tmux new-window -t penpot:3 -n 'backend'
tmux select-window -t penpot:3
tmux send-keys -t penpot 'cd penpot/backend' enter C-l
tmux send-keys -t penpot './scripts/start-dev' enter

View File

@@ -21,6 +21,7 @@
"raw-body": "^3.0.1",
"source-map-support": "^0.5.21",
"svgo": "penpot/svgo#v3.1",
"undici": "^7.16.0",
"xml-js": "^1.6.11",
"xregexp": "^5.1.2"
},
@@ -29,8 +30,8 @@
},
"scripts": {
"clear:shadow-cache": "rm -rf .shadow-cljs && rm -rf target",
"watch:app": "clojure -M:dev:shadow-cljs watch main",
"watch": "yarn run clear:shadow-cache && yarn run watch:app",
"watch:app": "yarn run clear:shadow-cache && clojure -M:dev:shadow-cljs watch main",
"watch": "yarn run watch:app",
"build:app": "clojure -M:dev:shadow-cljs release main",
"build": "yarn run clear:shadow-cache && yarn run build:app",
"fmt:clj:check": "cljfmt check --parallel=false src/",

7
exporter/scripts/watch Executable file
View File

@@ -0,0 +1,7 @@
#!/usr/bin/env bash
TARGET=${1:-app};
set -ex
exec yarn run watch:$TARGET

View File

@@ -100,7 +100,7 @@
(def browser-pool-factory
(letfn [(create []
(p/let [opts #js {:args #js ["--font-render-hinting=none"]}
(p/let [opts #js {:args #js ["--allow-insecure-localhost" "--font-render-hinting=none"]}
browser (.launch pw/chromium opts)
id (swap! pool-browser-id inc)]
(l/info :origin "factory" :action "create" :browser-id id)

View File

@@ -74,7 +74,7 @@
(p/fmap (fn [resource]
(assoc exchange :response/body resource)))
(p/merr (fn [cause]
(l/error :hint "unexpected error on export multiple"
(l/error :hint "unexpected error on single export"
:cause cause)
(p/rejected cause))))))
@@ -94,7 +94,7 @@
(redis/pub! topic data))))
on-error (fn [cause]
(l/error :hint "unexpected error on multiple exportation" :cause cause)
(l/error :hint "unexpected error on multiple export" :cause cause)
(if wait
(p/rejected cause)
(redis/pub! topic {:type :export-update
@@ -107,12 +107,12 @@
:on-progress on-progress)
append (fn [{:keys [filename path] :as resource}]
(rsc/add-to-zip! zip path (str/replace filename sanitize-file-regex "_")))
(rsc/add-to-zip zip path (str/replace filename sanitize-file-regex "_")))
proc (->> exports
(map (fn [export] (rd/render export append)))
(p/all)
(p/fnly (fn [_] (.finalize zip)))
(p/mcat (fn [_] (rsc/close-zip zip)))
(p/fmap (constantly resource))
(p/mcat (partial rsc/upload-resource auth-token))
(p/fmap (fn [resource]

View File

@@ -11,6 +11,7 @@
["node:fs" :as fs]
["node:fs/promises" :as fsp]
["node:path" :as path]
["undici" :as http]
[app.common.exceptions :as ex]
[app.common.transit :as t]
[app.common.uri :as u]
@@ -53,30 +54,40 @@
(.pipe zip out)
zip))
(defn add-to-zip!
(defn add-to-zip
[zip path name]
(.file ^js zip path #js {:name name}))
(defn close-zip!
(defn close-zip
[zip]
(.finalize ^js zip))
(p/create (fn [resolve]
(.on ^js zip "close" resolve)
(.finalize ^js zip))))
(defn upload-resource
[auth-token resource]
(->> (fsp/readFile (:path resource))
(p/fmap (fn [buffer]
(js/console.log buffer)
(new js/Blob #js [buffer] #js {:type (:mtype resource)})))
(p/mcat (fn [blob]
(let [fdata (new js/FormData)
uri (-> (cf/get :public-uri)
(u/ensure-path-slash)
(u/join "api/management/methods/upload-tempfile")
(str))]
(let [fdata (new http/FormData)
agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}})
headers #js {"X-Shared-Key" cf/management-key
"Authorization" (str "Bearer " auth-token)}
request #js {:headers headers
:method "POST"
:body fdata
:dispatcher agent}
uri (-> (cf/get :public-uri)
(u/ensure-path-slash)
(u/join "api/management/methods/upload-tempfile")
(str))]
(.append fdata "content" blob (:filename resource))
(js/fetch uri #js {:headers #js {"X-Shared-Key" cf/management-key
"Authorization" (str "Bearer " auth-token)}
:method "POST"
:body fdata}))))
(http/fetch uri request))))
(p/mcat (fn [response]
(if (not= (.-status response) 200)
(ex/raise :type :internal

View File

@@ -75,7 +75,8 @@
[path]
(->> (.stat fs/promises path)
(p/fmap (fn [data]
{:created-at (inst-ms (.-ctime ^js data))
{:path path
:created-at (inst-ms (.-ctime ^js data))
:size (.-size data)}))
(p/merr (fn [_cause]
(p/resolved nil)))))

View File

@@ -582,6 +582,7 @@ __metadata:
raw-body: "npm:^3.0.1"
source-map-support: "npm:^0.5.21"
svgo: "penpot/svgo#v3.1"
undici: "npm:^7.16.0"
ws: "npm:^8.18.3"
xml-js: "npm:^1.6.11"
xregexp: "npm:^5.1.2"
@@ -1513,6 +1514,13 @@ __metadata:
languageName: node
linkType: hard
"undici@npm:^7.16.0":
version: 7.16.0
resolution: "undici@npm:7.16.0"
checksum: 10c0/efd867792e9f233facf9efa0a087e2d9c3e4415c0b234061b9b40307ca4fa01d945fee4d43c7b564e1b80e0d519bcc682f9f6e0de13c717146c00a80e2f1fb0f
languageName: node
linkType: hard
"unique-filename@npm:^4.0.0":
version: 4.0.0
resolution: "unique-filename@npm:4.0.0"

View File

@@ -50,5 +50,8 @@
:shadow-cljs
{:main-opts ["-m" "shadow.cljs.devtools.cli"]
:jvm-opts ["--sun-misc-unsafe-memory-access=allow" "-Dpenpot.wasm.profile-marks=true"]}
:jvm-opts ["--sun-misc-unsafe-memory-access=allow"
"-Dpenpot.wasm.profile-marks=true"
"-XX:+UnlockExperimentalVMOptions"
"-XX:CompileCommand=blackhole,criterium.blackhole.Blackhole::consume"]}
}}

View File

@@ -32,8 +32,8 @@
"e2e:server": "node ./scripts/e2e-server.js",
"fmt:clj": "cljfmt fix --parallel=true src/ test/",
"fmt:clj:check": "cljfmt check --parallel=false src/ test/",
"fmt:js": "yarn run prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -w",
"fmt:js:check": "yarn run prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js",
"fmt:js": "yarn run prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -c text-editor/**/*.js -w",
"fmt:js:check": "yarn run prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js text-editor/**/*.js",
"lint:clj": "clj-kondo --parallel --lint src/",
"lint:scss": "yarn run prettier -c resources/styles -c src/**/*.scss",
"lint:scss:fix": "yarn run prettier -c resources/styles -c src/**/*.scss -w",
@@ -45,12 +45,11 @@
"translations": "node ./scripts/translations.js",
"watch:app:assets": "node ./scripts/watch.js",
"watch:app:libs": "node ./scripts/build-libs.js --watch",
"watch:app:main": "clojure -M:dev:shadow-cljs watch main storybook",
"watch:app:main": "clojure -M:dev:shadow-cljs watch main worker storybook",
"clear:shadow-cache": "rm -rf .shadow-cljs",
"watch:app": "yarn run clear:shadow-cache && yarn run build:app:worker && concurrently \"yarn run watch:app:main\" \"yarn run watch:app:libs\"",
"watch": "yarn run watch:app:assets",
"watch:storybook": "yarn run build:storybook:assets && concurrently \"storybook dev -p 6006 --no-open\" \"yarn run watch:storybook:assets\"",
"watch:storybook:assets": "node ./scripts/watch-storybook.js"
"watch": "exit 0",
"watch:app": "yarn run clear:shadow-cache && concurrently --kill-others-on-fail \"yarn run watch:app:assets\" \"yarn run watch:app:main\" \"yarn run watch:app:libs\"",
"watch:storybook": "yarn run build:storybook:assets && concurrently --kill-others-on-fail \"storybook dev -p 6006 --no-open\" \"node ./scripts/watch-storybook.js\""
},
"devDependencies": {
"@playwright/test": "1.52.0",
@@ -106,7 +105,7 @@
"@penpot/hljs": "portal:./vendor/hljs",
"@penpot/mousetrap": "portal:./vendor/mousetrap",
"@penpot/plugins-runtime": "1.3.2",
"@penpot/svgo": "penpot/svgo#v3.1",
"@penpot/svgo": "penpot/svgo#v3.2",
"@penpot/text-editor": "portal:./text-editor",
"@tokens-studio/sd-transforms": "1.2.11",
"@zip.js/zip.js": "patch:@zip.js/zip.js@npm%3A2.7.60#~/.yarn/patches/@zip.js-zip.js-npm-2.7.60-b6b814410b.patch",

View File

@@ -1,12 +1,12 @@
export class Clipboard {
static Permission = {
ONLY_READ: ['clipboard-read'],
ONLY_WRITE: ['clipboard-write'],
ALL: ['clipboard-read', 'clipboard-write']
}
ONLY_READ: ["clipboard-read"],
ONLY_WRITE: ["clipboard-write"],
ALL: ["clipboard-read", "clipboard-write"],
};
static enable(context, permissions) {
return context.grantPermissions(permissions)
return context.grantPermissions(permissions);
}
static writeText(page, text) {
@@ -18,8 +18,8 @@ export class Clipboard {
}
constructor(page, context) {
this.page = page
this.context = context
this.page = page;
this.context = context;
}
enable(permissions) {

View File

@@ -1,18 +1,16 @@
export class Transit {
static parse(value) {
if (typeof value !== 'string')
return value
if (typeof value !== "string") return value;
if (value.startsWith('~'))
return value.slice(2)
if (value.startsWith("~")) return value.slice(2);
return value
return value;
}
static get(object, ...path) {
let aux = object;
for (const name of path) {
if (typeof name !== 'string') {
if (typeof name !== "string") {
if (!(name in aux)) {
return undefined;
}

View File

@@ -9,7 +9,7 @@ export class BasePage {
*/
static async mockRPCs(page, paths, options) {
for (const [path, jsonFilename] of Object.entries(paths)) {
await this.mockRPC(page, path, jsonFilename, options)
await this.mockRPC(page, path, jsonFilename, options);
}
}

View File

@@ -1,7 +1,7 @@
import { expect } from "@playwright/test";
import { readFile } from 'node:fs/promises';
import { readFile } from "node:fs/promises";
import { BaseWebSocketPage } from "./BaseWebSocketPage";
import { Transit } from '../../helpers/Transit';
import { Transit } from "../../helpers/Transit";
export class WorkspacePage extends BaseWebSocketPage {
static TextEditor = class TextEditor {

View File

@@ -51,7 +51,7 @@ test.skip("BUG 12164 - Crash when trying to fetch a missing font", async ({
pageId: "2b7f0188-51a1-8193-8006-e05bad87b74d",
});
await workspacePage.page.waitForTimeout(1000)
await workspacePage.page.waitForTimeout(1000);
await workspacePage.waitForFirstRender();
await expect(

View File

@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test";
import { Clipboard } from '../../helpers/Clipboard';
import { Clipboard } from "../../helpers/Clipboard";
import { WorkspacePage } from "../pages/WorkspacePage";
const timeToWait = 100;
@@ -11,14 +11,14 @@ test.beforeEach(async ({ page, context }) => {
await WorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-v2"]);
});
test.afterEach(async ({ context}) => {
test.afterEach(async ({ context }) => {
context.clearPermissions();
})
});
test("Create a new text shape", async ({ page }) => {
const initialText = "Lorem ipsum";
const workspace = new WorkspacePage(page, {
textEditor: true
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.goToWorkspace();
@@ -36,10 +36,7 @@ test("Create a new text shape from pasting text", async ({ page, context }) => {
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.mockRPC(
"update-file?id=*",
"text-editor/update-file.json",
);
await workspace.mockRPC("update-file?id=*", "text-editor/update-file.json");
await workspace.goToWorkspace();
await Clipboard.writeText(page, textToPaste);
@@ -55,10 +52,13 @@ test("Create a new text shape from pasting text", async ({ page, context }) => {
await workspace.textEditor.stopEditing();
});
test("Create a new text shape from pasting text using context menu", async ({ page, context }) => {
test("Create a new text shape from pasting text using context menu", async ({
page,
context,
}) => {
const textToPaste = "Lorem ipsum";
const workspace = new WorkspacePage(page, {
textEditor: true
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.goToWorkspace();
@@ -72,11 +72,13 @@ test("Create a new text shape from pasting text using context menu", async ({ pa
expect(textContent).toBe(textToPaste);
await workspace.textEditor.stopEditing();
})
});
test("Update an already created text shape by appending text", async ({ page }) => {
test("Update an already created text shape by appending text", async ({
page,
}) => {
const workspace = new WorkspacePage(page, {
textEditor: true
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
@@ -94,7 +96,7 @@ test("Update an already created text shape by prepending text", async ({
page,
}) => {
const workspace = new WorkspacePage(page, {
textEditor: true
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
@@ -112,7 +114,7 @@ test("Update an already created text shape by inserting text in between", async
page,
}) => {
const workspace = new WorkspacePage(page, {
textEditor: true
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
@@ -126,10 +128,13 @@ test("Update an already created text shape by inserting text in between", async
await workspace.textEditor.stopEditing();
});
test("Update a new text shape appending text by pasting text", async ({ page, context }) => {
test("Update a new text shape appending text by pasting text", async ({
page,
context,
}) => {
const textToPaste = " dolor sit amet";
const workspace = new WorkspacePage(page, {
textEditor: true
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
@@ -147,11 +152,12 @@ test("Update a new text shape appending text by pasting text", async ({ page, co
});
test("Update a new text shape prepending text by pasting text", async ({
page, context
page,
context,
}) => {
const textToPaste = "Dolor sit amet ";
const workspace = new WorkspacePage(page, {
textEditor: true
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
@@ -173,7 +179,7 @@ test("Update a new text shape replacing (starting) text with pasted text", async
}) => {
const textToPaste = "Dolor sit amet";
const workspace = new WorkspacePage(page, {
textEditor: true
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
@@ -197,7 +203,7 @@ test("Update a new text shape replacing (ending) text with pasted text", async (
}) => {
const textToPaste = "dolor sit amet";
const workspace = new WorkspacePage(page, {
textEditor: true
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
@@ -221,7 +227,7 @@ test("Update a new text shape replacing (in between) text with pasted text", asy
}) => {
const textToPaste = "dolor sit amet";
const workspace = new WorkspacePage(page, {
textEditor: true
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
@@ -244,14 +250,11 @@ test("Update text font size selecting a part of it (starting)", async ({
page,
}) => {
const workspace = new WorkspacePage(page, {
textEditor: true
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
await workspace.mockRPC(
"update-file?id=*",
"text-editor/update-file.json",
);
await workspace.mockRPC("update-file?id=*", "text-editor/update-file.json");
await workspace.goToWorkspace();
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing();
@@ -280,7 +283,10 @@ test.skip("Update text line height selecting a part of it (starting)", async ({
await workspace.textEditor.selectFromStart(5);
await workspace.textEditor.changeLineHeight(1.4);
const lineHeight = await workspace.textEditor.waitForParagraphStyle(1, 'line-height');
const lineHeight = await workspace.textEditor.waitForParagraphStyle(
1,
"line-height",
);
expect(lineHeight).toBe("1.4");
const textContent = await workspace.textEditor.waitForTextSpanContent();

View File

@@ -303,7 +303,7 @@ test.describe("Tokens: Tokens Tab", () => {
const nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.pressSequentially(".changed");
await tokensUpdateCreateModal.getByRole("button", {name: "Save"}).click();
await tokensUpdateCreateModal.getByRole("button", { name: "Save" }).click();
await expect(tokensUpdateCreateModal).not.toBeVisible();

View File

@@ -73,7 +73,7 @@ export function isJsFile(path) {
export async function compileSass(worker, path, options) {
path = ph.resolve(path);
log.info("compile:", path);
// log.info("compile:", path);
return worker.exec("compileSass", [path, options]);
}
@@ -180,8 +180,8 @@ export async function watch(baseDir, predicate, callback) {
});
}
async function readManifestFile() {
const manifestPath = "resources/public/js/manifest.json";
async function readManifestFile(resource) {
const manifestPath = "resources/public/" + resource;
let content = await fs.readFile(manifestPath, { encoding: "utf8" });
return JSON.parse(content);
}
@@ -189,19 +189,23 @@ async function readManifestFile() {
async function readShadowManifest() {
const ts = Date.now();
try {
const content = await readManifestFile();
const content = await readManifestFile("js/manifest.json");
const index = {
ts: ts,
config: "js/config.js?ts=" + ts,
polyfills: "js/polyfills.js?ts=" + ts,
worker_main: "js/worker/main.js?ts=" + ts,
};
for (let item of content) {
index[item.name] = "js/" + item["output-name"];
}
const content2 = await readManifestFile("js/worker/manifest.json");
for (let item of content2) {
index["worker_" + item.name] = "js/worker/" + item["output-name"];
}
return index;
} catch (cause) {
return {
@@ -274,6 +278,7 @@ async function readTranslations() {
"id",
"ru",
"tr",
"hi",
"zh_CN",
"zh_Hant",
"hr",

View File

@@ -20,21 +20,24 @@ echo $PATH
set -ex
corepack enable;
corepack install || exit 1;
corepack install;
yarn install || exit 1;
rm -rf resources/public;
rm -rf target/dist;
rm -rf resources/public;
yarn run build:app:main --config-merge "{:release-version \"${CURRENT_HASH}-${TS}\"}" $EXTRA_PARAMS || exit 1
mkdir -p resources/public;
if [ "$INCLUDE_WASM" = "yes" ]; then
yarn run build:wasm || exit 1;
fi
pushd ../render-wasm;
./build
popd
yarn run build:app:main --config-merge "{:release-version \"${CURRENT_HASH}-${TS}\"}" $EXTRA_PARAMS;
yarn run build:app:libs || exit 1;
yarn run build:app:assets || exit 1;
sed -i "s/\.\/render.js/.\/render.js?version=$CURRENT_VERSION/g" resources/public/js/worker/main*.js
mkdir -p target/dist;
rsync -avr resources/public/ target/dist/
@@ -44,10 +47,6 @@ sed -i -re "s/\%version\%/$CURRENT_VERSION/g" ./target/dist/rasterizer.html;
sed -i -re "s/\%buildDate\%/$BUILD_DATE/g" ./target/dist/index.html;
sed -i -re "s/\%buildDate\%/$BUILD_DATE/g" ./target/dist/rasterizer.html;
if [ "$INCLUDE_WASM" = "yes" ]; then
sed -i "s/version=develop/version=$CURRENT_VERSION/g" ./target/dist/js/render_wasm.js;
fi
if [ "$INCLUDE_STORYBOOK" = "yes" ]; then
# build storybook
yarn run build:storybook || exit 1;

7
frontend/scripts/watch Executable file
View File

@@ -0,0 +1,7 @@
#!/usr/bin/env bash
TARGET=${1:-app};
set -ex
exec yarn run watch:$TARGET

View File

@@ -83,7 +83,7 @@
:source-map-detail-level :all}}}
:worker
{:target :esm
{:target :browser
:output-dir "resources/public/js/worker/"
:asset-path "/js/worker"
:devtools {:browser-inject :main
@@ -94,6 +94,7 @@
{:main
{:entries [app.worker]
:web-worker true
:prepend-js "importScripts('./render.js');"
:depends-on #{}}}
:js-options

View File

@@ -127,7 +127,7 @@
public-uri))
(def worker-uri
(obj/get global "penpotWorkerURI" "/js/worker.js"))
(obj/get global "penpotWorkerURI" "/js/worker/main.js"))
(defn external-feature-flag
[flag value]
@@ -189,7 +189,11 @@
(true? thumbnail?) (u/join (dm/str id "/thumbnail"))
(false? thumbnail?) (u/join (dm/str id)))))))
(defn resolve-static-asset
[path]
(let [uri (u/join public-uri path)]
(assoc uri :query (dm/str "version=" (:full version)))))
(defn resolve-href
[resource]
(let [version (get version :full)
href (-> public-uri
(u/ensure-path-slash)
(u/join resource)
(get :path))]
(str href "?version=" version)))

View File

@@ -24,6 +24,8 @@
(def revn-data (atom {}))
(def queue-conj (fnil conj #queue []))
(def force-persist? #(= % ::force-persist))
(defn- update-status
[status]
(ptk/reify ::update-status

View File

@@ -255,14 +255,19 @@
(defn- parse-sd-token-font-family-value
[value]
(let [missing-references (seq (some cto/find-token-value-references value))]
(let [value (-> (js->clj value) (flatten))
valid-font-family (or (string? value) (every? string? value))
missing-references (seq (some cto/find-token-value-references value))]
(cond
(not valid-font-family)
{:errors [(wte/error-with-value :error.style-dictionary/invalid-token-value-font-family value)]}
missing-references
{:errors [(wte/error-with-value :error.style-dictionary/missing-reference missing-references)]
:references missing-references}
:else
{:value (-> (js->clj value) (flatten))})))
{:value value})))
(defn parse-atomic-typography-value [token-type token-value]
(case token-type

View File

@@ -351,19 +351,31 @@
(on-success))))
(rx/catch on-error))))))
(def ^:private schema:create-invitation
[:and
[:map
[:emails {:optional true} [::sm/set ::sm/email]]
[:invitations {:optional true}
[:vector
[:map
[:email ::sm/email]
[:role [::sm/one-of ctt/valid-roles]]]]]
[:team-id ::sm/uuid]
[:resend? {:optional true} ::sm/boolean]]
[:fn (fn [attrs]
(or (contains? attrs :emails)
(contains? attrs :invitations)))]])
(def ^:private check-create-invitations-params
(sm/check-fn schema:create-invitation))
(defn create-invitations
"Unified function to create invitations. Supports two parameter formats:
1. {:emails #{...} :role :admin :team-id uuid} - single role for all emails
2. {:invitations [{:email ... :role ...}] :team-id uuid} - individual roles per email"
[{:keys [emails role team-id invitations resend?] :as params}]
(assert (uuid? team-id))
;; Validate input format - must have either emails+role OR invitations
(assert (or (and emails role (sm/check-set-of-emails emails) (keyword? role))
(and invitations
(sm/check-set-of-emails (map :email invitations))
(every? #(contains? ctt/valid-roles (:role %)) invitations)))
"Must provide either emails+role or invitations with individual roles")
(check-create-invitations-params params)
(ptk/reify ::create-invitations
ev/Event

View File

@@ -32,7 +32,7 @@
[app.main.data.helpers :as dsh]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.data.persistence :as-alias dps]
[app.main.data.persistence :as dps]
[app.main.data.plugins :as dp]
[app.main.data.profile :as du]
[app.main.data.project :as dpj]
@@ -67,6 +67,7 @@
[app.main.errors]
[app.main.features :as features]
[app.main.features.pointer-map :as fpmap]
[app.main.refs :as refs]
[app.main.repo :as rp]
[app.main.router :as rt]
[app.render-wasm :as wasm]
@@ -269,8 +270,12 @@
(ptk/reify ::process-wasm-object
ptk/EffectEvent
(effect [_ state _]
(let [objects (dsh/lookup-page-objects state)]
(wasm.api/process-object (get objects id))))))
(let [objects (dsh/lookup-page-objects state)
shape (get objects id)]
;; Only process objects that exist in the current page
;; This prevents errors when processing changes from other pages
(when shape
(wasm.api/process-object shape))))))
(defn initialize-workspace
[team-id file-id]
@@ -379,6 +384,59 @@
(->> (rx/from added)
(rx/map process-wasm-object)))))))
(when render-wasm?
(let [local-commits-s
(->> stream
(rx/filter dch/commit?)
(rx/map deref)
(rx/filter #(and (= :local (:source %))
(not (contains? (:tags %) :position-data))))
(rx/filter (complement empty?)))
notifier-s
(rx/merge
(->> local-commits-s (rx/debounce 1000))
(->> stream (rx/filter dps/force-persist?)))
objects-s
(rx/from-atom refs/workspace-page-objects {:emit-current-value? true})
current-page-id-s
(rx/from-atom refs/current-page-id {:emit-current-value? true})]
(->> local-commits-s
(rx/buffer-until notifier-s)
(rx/with-latest-from objects-s)
(rx/map
(fn [[commits objects]]
(->> commits
(mapcat :redo-changes)
(filter #(contains? #{:mod-obj :add-obj} (:type %)))
(filter #(cfh/text-shape? objects (:id %)))
(map #(vector
(:id %)
(wasm.api/calculate-position-data (get objects (:id %))))))))
(rx/with-latest-from current-page-id-s)
(rx/map
(fn [[text-position-data page-id]]
(let [changes
(->> text-position-data
(mapv (fn [[id position-data]]
{:type :mod-obj
:id id
:page-id page-id
:operations
[{:type :set
:attr :position-data
:val position-data
:ignore-touched true
:ignore-geometry true}]})))]
(dch/commit-changes
{:redo-changes changes :undo-changes []
:save-undo? false
:tags #{:position-data}})))))))
(->> stream
(rx/filter dch/commit?)
(rx/map deref)

View File

@@ -14,7 +14,7 @@
[app.common.types.fills :as types.fills]
[app.common.types.library :as ctl]
[app.common.types.shape :as shp]
[app.common.types.shape.shadow :refer [check-shadow]]
[app.common.types.shape.shadow :as types.shadow]
[app.common.types.text :as txt]
[app.main.broadcast :as mbc]
[app.main.data.helpers :as dsh]
@@ -406,30 +406,30 @@
(defn change-shadow
[ids attrs index]
(ptk/reify ::change-shadow
ptk/WatchEvent
(watch [_ _ _]
(rx/of (dwsh/update-shapes
ids
(fn [shape]
(let [;; If we try to set a gradient to a shadow (for
;; example using the color selection from
;; multiple shapes) let's use the first stop
;; color
attrs (cond-> attrs
(:gradient attrs)
(dm/get-in [:gradient :stops 0]))
(letfn [(update-shadow [shape]
(let [;; If we try to set a gradient to a shadow (for
;; example using the color selection from
;; multiple shapes) let's use the first stop
;; color
attrs (cond-> attrs
(:gradient attrs)
(-> (dm/get-in [:gradient :stops 0])
(select-keys types.shadow/color-attrs)))
attrs' (-> (dm/get-in shape [:shadow index :color])
(merge attrs)
(d/without-nils))]
(assoc-in shape [:shadow index :color] attrs'))))))))
attrs' (-> (dm/get-in shape [:shadow index :color])
(merge attrs)
(d/without-nils))]
(assoc-in shape [:shadow index :color] attrs')))]
(ptk/reify ::change-shadow
ptk/WatchEvent
(watch [_ _ _]
(rx/of (dwsh/update-shapes ids update-shadow))))))
(defn add-shadow
[ids shadow]
(assert
(check-shadow shadow)
(types.shadow/check-shadow shadow)
"expected a valid shadow struct")
(assert
@@ -1146,16 +1146,16 @@
(defn- shadow->color-attr
"Given a stroke map enriched with :shape-id, :index, and optionally
:has-token-applied / :token-name, returns a color attribute map.
If :has-token-applied is true, adds token metadata to :attrs:
{:has-token-applied true
:token-name <token-name>}
Args:
- stroke: map with stroke info, including :shape-id and :index
- file-id: current file UUID
- libraries: map of shared color libraries
Returns:
A map like:
{:attrs {...color data...}
@@ -1260,12 +1260,12 @@
will include extra attributes in its :attrs map:
{:has-token-applied true
:token-name <token-name>}
Args:
- shapes: vector of shape maps
- file-id: current file UUID
- libraries: map of shared color libraries
Returns:
A vector of color attribute maps with metadata for each shape."
[shapes file-id libraries]

View File

@@ -554,7 +554,7 @@
(when (features/active-feature? state "text-editor/v2")
(let [instance (:workspace-editor state)
styles (some-> (editor.v2/getCurrentStyle instance)
(styles/get-styles-from-style-declaration)
(styles/get-styles-from-style-declaration :removed-mixed true)
((comp update-node-fn migrate-node))
(styles/attrs->styles))]
(editor.v2/applyStylesToSelection instance styles)))))))

View File

@@ -88,6 +88,10 @@
{:error/code :error.style-dictionary/invalid-token-value-font-weight
:error/fn #(tr "workspace.tokens.invalid-font-weight-token-value" %)}
:error.style-dictionary/invalid-token-value-font-family
{:error/code :error.style-dictionary/invalid-token-value-font-family
:error/fn #(tr "workspace.tokens.invalid-font-family-token-value" %)}
:error.style-dictionary/invalid-token-value-typography
{:error/code :error.style-dictionary/invalid-token-value-typography
:error/fn #(tr "workspace.tokens.invalid-token-value-typography" %)}

View File

@@ -238,12 +238,12 @@
:always
(ctm/resize scalev resize-origin shape-transform shape-transform-inverse)
(and (ctl/any-layout-immediate-child? objects shape)
(and (or (ctl/any-layout-immediate-child? objects shape) (ctl/any-layout? shape))
(not= (:layout-item-h-sizing shape) :fix)
^boolean change-width?)
(ctm/change-property :layout-item-h-sizing :fix)
(and (ctl/any-layout-immediate-child? objects shape)
(and (or (ctl/any-layout-immediate-child? objects shape) (ctl/any-layout? shape))
(not= (:layout-item-v-sizing shape) :fix)
^boolean change-height?)
(ctm/change-property :layout-item-v-sizing :fix)

View File

@@ -30,6 +30,9 @@
(def profile
(l/derived (l/key :profile) st/state))
(def current-page-id
(l/derived (l/key :current-page-id) st/state))
(def team
(l/derived (fn [state]
(let [team-id (:current-team-id state)

View File

@@ -50,7 +50,8 @@
touched? (and (contains? (:data @form) input-name)
(get-in @form [:touched input-name]))
error (get-in @form [:errors input-name])
error (or (get-in @form [:errors input-name])
(get-in @form [:extra-errors input-name]))
value (get-in @form [:data input-name] "")

View File

@@ -60,6 +60,7 @@
current-id (get state :id)
current-value (get state :current-value)
current-label (get label-index current-value)
is-open? (get state :is-open?)
node-ref (mf/use-ref nil)

View File

@@ -45,7 +45,7 @@
.element-list {
@include t.use-typography("body-large");
color: var(--modal-text-foreground-color);
overflow-y: scroll;
overflow-y: auto;
margin-block: 0;
}

View File

@@ -223,24 +223,30 @@
circ (* 2 Math/PI 12)
pct (- circ (* circ (/ progress total)))
pwidth (if error?
280
(/ (* progress 280) total))
color (cond
error? clr/new-danger
healthy? (if is-default-theme?
clr/new-primary
clr/new-primary-light)
(not healthy?) clr/new-warning)
pwidth
(if error?
280
(/ (* progress 280) total))
background-clr (if is-default-theme?
clr/background-quaternary
clr/background-quaternary-light)
title (cond
error? (tr "workspace.options.exporting-object-error")
complete? (tr "workspace.options.exporting-complete")
healthy? (tr "workspace.options.exporting-object")
(not healthy?) (tr "workspace.options.exporting-object-slow"))
color
(cond
error? clr/new-danger
healthy? (if is-default-theme?
clr/new-primary
clr/new-primary-light)
(not healthy?) clr/new-warning)
background-clr
(if is-default-theme?
clr/background-quaternary
clr/background-quaternary-light)
title
(cond
error? (tr "workspace.options.exporting-object-error")
complete? (tr "workspace.options.exporting-complete")
healthy? (tr "workspace.options.exporting-object")
(not healthy?) (tr "workspace.options.exporting-object-slow"))
retry-last-export
(mf/use-fn #(st/emit! (de/retry-last-export)))
@@ -284,7 +290,7 @@
:on-click retry-last-export}
(tr "workspace.options.retry")]
[:p {:class (stl/css :progress)}
[:span {:class (stl/css :progress)}
(dm/str progress " / " total)])]
[:button {:class (stl/css :progress-close-button)

View File

@@ -36,7 +36,7 @@
:text [:visibility :geometry :text :shadow :blur :stroke :layout-element]
:variant [:variant :geometry :fill :stroke :shadow :blur :layout :layout-element]})
(mf/defc attributes
(mf/defc attributes*
[{:keys [page-id file-id shapes frame from libraries share-id objects color-space]}]
(let [shapes (hooks/use-equal-memo shapes)
first-shape (first shapes)

View File

@@ -96,7 +96,7 @@
embed-images? (replace-map images-data))]
(str/format page-template style-code markup-code)))
(mf/defc code
(mf/defc code*
[{:keys [shapes frame on-expand from]}]
(let [style-type* (mf/use-state "css")
markup-type* (mf/use-state "html")

View File

@@ -16,8 +16,8 @@
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.ds.layout.tab-switcher :refer [tab-switcher*]]
[app.main.ui.icons :as deprecated-icon]
[app.main.ui.inspect.attributes :refer [attributes]]
[app.main.ui.inspect.code :refer [code]]
[app.main.ui.inspect.attributes :refer [attributes*]]
[app.main.ui.inspect.code :refer [code*]]
[app.main.ui.inspect.selection-feedback :refer [resolve-shapes]]
[app.main.ui.inspect.styles :refer [styles-tab*]]
[app.util.dom :as dom]
@@ -122,8 +122,7 @@
(fn []
(if (seq shapes)
(st/emit! (ptk/event ::ev/event {::ev/name "inspect-mode-click-element"}))
(handle-change-tab (if (contains? cf/flags :inspect-styles) :styles :info)))
(reset! color-space* "hex")))
(handle-change-tab (if (contains? cf/flags :inspect-styles) :styles :info)))))
[:aside {:class (stl/css-case :settings-bar-right true
:viewer-code (= from :viewer))}
@@ -189,41 +188,41 @@
:libraries libraries
:file-id file-id}]
:computed
[:& attributes {:color-space color-space
:page-id page-id
:objects objects
:file-id file-id
:frame frame
:shapes shapes
:from from
:libraries libraries
:share-id share-id}]
[:> attributes* {:color-space color-space
:page-id page-id
:objects objects
:file-id file-id
:frame frame
:shapes shapes
:from from
:libraries libraries
:share-id share-id}]
:code
[:& code {:frame frame
:shapes shapes
:on-expand handle-expand
:from from}])]
[:> code* {:frame frame
:shapes shapes
:on-expand handle-expand
:from from}])]
[:> tab-switcher* {:tabs tabs
:selected (name @section)
:on-change handle-change-tab
:class (stl/css :viewer-tab-switcher)}
(case @section
:info
[:& attributes {:page-id page-id
:objects objects
:file-id file-id
:frame frame
:shapes shapes
:from from
:libraries libraries
:share-id share-id}]
[:> attributes* {:page-id page-id
:objects objects
:file-id file-id
:frame frame
:shapes shapes
:from from
:libraries libraries
:share-id share-id}]
:code
[:& code {:frame frame
:shapes shapes
:on-expand handle-expand
:from from}])])]]
[:> code* {:frame frame
:shapes shapes
:on-expand handle-expand
:from from}])])]]
[:div {:class (stl/css :empty)}
[:div {:class (stl/css :code-info)}
[:span {:class (stl/css :placeholder-icon)}

View File

@@ -133,7 +133,7 @@
(swap! shorthands* assoc (:panel shorthand) (:property shorthand))))]
[:ol {:class (stl/css :styles-tab) :aria-label (tr "labels.styles")}
;; TOKENS PANEL
(when (or active-themes active-sets)
(when (or (seq active-themes) (seq active-sets))
[:li
[:> style-box* {:panel :token}
[:> tokens-panel* {:theme-paths active-themes :set-names active-sets}]]])

View File

@@ -18,16 +18,18 @@
(defn- on-error
[form error]
(case (:code (ex-data error))
:old-password-not-match
(swap! form assoc-in [:errors :password-old]
{:message (tr "errors.wrong-old-password")})
:email-as-password
(swap! form assoc-in [:errors :password-1]
{:message (tr "errors.email-as-password")})
(let [data (ex-data error)]
(case (:code data)
:old-password-not-match
(swap! form assoc-in [:extra-errors :password-old]
{:message (tr "errors.wrong-old-password")})
(let [msg (tr "generic.error")]
(st/emit! (ntf/error msg)))))
:email-as-password
(swap! form assoc-in [:extra-errors :password-1]
{:message (tr "errors.email-as-password")})
(let [msg (tr "generic.error")]
(st/emit! (ntf/error msg))))))
(defn- on-success
[form]

View File

@@ -77,7 +77,7 @@
[:button {:class (stl/css :cta-button :bottom-link)
:on-click cta-link-trial} cta-text-trial])])
(defn schema:seats-form [min-editors]
(defn- make-management-form-schema [min-editors]
[:map {:title "SeatsForm"}
[:min-members [::sm/number {:min min-editors
:max 9999}]]
@@ -87,7 +87,6 @@
{::mf/register modal/components
::mf/register-as :management-dialog}
[{:keys [subscription-type current-subscription editors subscribe-to-trial]}]
(let [unlimited-modal-step*
(mf/use-state 1)
@@ -112,9 +111,12 @@
{:min-members min-editors
:redirect-to-payment-details false})
schema
(mf/with-memo [min-editors]
(make-management-form-schema min-editors))
form
(fm/use-form :schema (schema:seats-form min-editors)
:initial initial)
(fm/use-form :schema schema :initial initial)
submit-in-progress
(mf/use-ref false)
@@ -334,11 +336,15 @@
[:> raw-svg* {:id (if (= "light" (:theme profile)) "logo-subscription-light" "logo-subscription")}]]
[:div {:class (stl/css :modal-end)}
[:div {:class (stl/css :modal-title)} (tr "subscription.settings.sucess.dialog.title" subscription-name)]
[:div {:class (stl/css :modal-title)}
(tr "subscription.settings.sucess.dialog.title" subscription-name)]
(when (not= subscription-name "professional")
[:p {:class (stl/css :modal-text-large)} (tr "subscription.settings.success.dialog.thanks" subscription-name)])
[:p {:class (stl/css :modal-text-large)} (tr "subscription.settings.success.dialog.description")]
[:p {:class (stl/css :modal-text-large)} (tr "subscription.settings.sucess.dialog.footer")]
[:p {:class (stl/css :modal-text-large)}
(tr "subscription.settings.success.dialog.thanks" subscription-name)])
[:p {:class (stl/css :modal-text-large)}
(tr "subscription.settings.success.dialog.description")]
[:p {:class (stl/css :modal-text-large)}
(tr "subscription.settings.sucess.dialog.footer")]
[:div {:class (stl/css :success-action-buttons)}
[:input
@@ -418,7 +424,11 @@
(mf/with-effect []
(dom/set-html-title (tr "subscription.labels")))
(mf/with-effect [authenticated? show-subscription-success-modal? show-trial-subscription-modal? success-modal-is-trial? subscription]
(mf/with-effect [authenticated?
show-subscription-success-modal?
show-trial-subscription-modal?
success-modal-is-trial?
subscription]
(when ^boolean authenticated?
(cond
^boolean show-trial-subscription-modal?

View File

@@ -106,9 +106,11 @@
:overflowWrap "initial"
:lineBreak "auto"
:whiteSpace "break-spaces"
:textRendering "geometricPrecision"
:display "inline-block"
:verticalAlign "top"}
:textRendering "geometricPrecision"}
base (cond-> base
(= (:line-height data) "0")
(-> (obj/set! "display" "inline-block")
(obj/set! "verticalAlign" "top")))
fills
(cond
;; DEPRECATED: still here for backward compatibility with

View File

@@ -27,7 +27,7 @@
[app.main.ui.workspace.coordinates :as coordinates]
[app.main.ui.workspace.libraries]
[app.main.ui.workspace.nudge]
[app.main.ui.workspace.palette :refer [palette]]
[app.main.ui.workspace.palette :refer [palette*]]
[app.main.ui.workspace.plugins]
[app.main.ui.workspace.sidebar :refer [sidebar*]]
[app.main.ui.workspace.sidebar.history :refer [history-toolbox*]]
@@ -84,8 +84,8 @@
node-ref (use-resize-observer on-resize)]
[:*
(when (not ^boolean hide-ui?)
[:& palette {:layout layout
:on-change-palette-size on-resize-palette}])
[:> palette* {:layout layout
:on-change-size on-resize-palette}])
[:section
{:key (dm/str "workspace-" page-id)

View File

@@ -156,7 +156,7 @@
(let [{:keys [modal title]} (get dwta/token-properties :color)
window-size (dom/get-window-size)
left-sidebar (dom/get-element "left-sidebar-aside")
x-size (dom/get-data left-sidebar "left-sidebar-width")
x-size (dom/get-data left-sidebar "width")
modal-height 392
x (- (int x-size) 30)
y (- (/ (:height window-size) 2) (/ modal-height 2))]

View File

@@ -33,12 +33,13 @@
[okulary.core :as l]
[rumext.v2 :as mf]))
(def viewport
(def ^:private ref:viewport
(l/derived :vport refs/workspace-local))
(defn calculate-palette-padding [rulers?]
(defn- calculate-palette-style
[rulers?]
(let [left-sidebar (dom/get-element "left-sidebar-aside")
left-sidebar-size (-> (dom/get-data left-sidebar "left-sidebar-width")
left-sidebar-size (-> (dom/get-data left-sidebar "width")
(d/parse-integer))
rulers-width (if rulers? 22 0)
min-left-sidebar-width left-sidebar-default-width
@@ -48,36 +49,46 @@
#js {"paddingLeft" (dm/str calculate-padding-left "px")
"paddingRight" "322px"}))
(mf/defc palette
[{:keys [layout on-change-palette-size]}]
(let [color-palette? (:colorpalette layout)
text-palette? (:textpalette layout)
hide-palettes? (:hide-palettes layout)
workspace-read-only? (mf/use-ctx ctx/workspace-read-only?)
container (mf/use-ref nil)
state* (mf/use-state {:show-menu false})
state (deref state*)
show-menu? (:show-menu state)
selected (h/use-shared-state mdc/colorpalette-selected-broadcast-key :recent)
selected-text* (mf/use-state :file)
selected-text (deref selected-text*)
on-select (mf/use-fn #(reset! selected %))
rulers? (mf/deref refs/rulers?)
{:keys [on-pointer-down on-lost-pointer-capture on-pointer-move parent-ref size]}
(r/use-resize-hook :palette 72 54 80 :y true :bottom on-change-palette-size)
(mf/defc palette*
[{:keys [layout on-change-size]}]
(let [color-palette? (:colorpalette layout)
text-palette? (:textpalette layout)
hide-palettes? (:hide-palettes layout)
vport (mf/deref viewport)
vport-width (:width vport)
read-only? (mf/use-ctx ctx/workspace-read-only?)
container (mf/use-ref nil)
state* (mf/use-state #(-> {:show-menu false}))
state (deref state*)
show-menu? (:show-menu state)
selected (h/use-shared-state mdc/colorpalette-selected-broadcast-key :recent)
selected-text* (mf/use-state :file)
selected-text (deref selected-text*)
on-select (mf/use-fn #(reset! selected %))
rulers? (mf/deref refs/rulers?)
vport (mf/deref ref:viewport)
vport-width (get vport :width)
{:keys [on-pointer-down
on-lost-pointer-capture
on-pointer-move
parent-ref
size]}
(r/use-resize-hook :palette 72 54 80 :y true :bottom on-change-size)
on-resize
(mf/use-callback
(mf/use-fn
(fn [_]
(let [dom (mf/ref-val container)
width (obj/get dom "clientWidth")]
(swap! state* assoc :width width))))
on-close-menu
(mf/use-callback
(mf/use-fn
(fn [_]
(swap! state* assoc :show-menu false)))
@@ -100,7 +111,7 @@
(reset! selected-text* (:id lib)))))
toggle-palettes
(mf/use-callback
(mf/use-fn
(fn [_]
(r/set-resize-type! :top)
(dom/add-class! (dom/get-element-by-class "color-palette") "fade-out-down")
@@ -131,7 +142,9 @@
(vary-meta assoc ::ev/origin "workspace-left-toolbar"))))
(dom/blur! node))))
any-palette? (or color-palette? text-palette?)
any-palette?
(or color-palette? text-palette?)
size-classname
(cond
(<= size 64) (stl/css :small-palette)
@@ -142,16 +155,16 @@
(let [key1 (events/listen js/window "resize" on-resize)]
#(events/unlistenByKey key1)))
(mf/use-layout-effect
#(let [dom (mf/ref-val parent-ref)
(mf/with-layout-effect []
(let [dom (mf/ref-val parent-ref)
width (obj/get dom "clientWidth")]
(swap! state* assoc :width width)))
[:div {:class (stl/css :palette-wrapper)
:id "palette-wrapper"
:style (calculate-palette-padding rulers?)
:style (calculate-palette-style rulers?)
:data-testid "palette"}
(when-not workspace-read-only?
(when-not ^boolean read-only?
[:div {:ref parent-ref
:class (dm/str size-classname " " (stl/css-case :palettes true
:wide any-palette?

View File

@@ -333,7 +333,7 @@
(case valign
"bottom" (- y (- height selrect-height))
"center" (- y (/ (- height selrect-height) 2))
"top" y)
y)
y)]
[(assoc selrect :y y :width max-width :height max-height) transform])

View File

@@ -27,7 +27,6 @@
[app.main.ui.workspace.left-header :refer [left-header*]]
[app.main.ui.workspace.right-header :refer [right-header*]]
[app.main.ui.workspace.sidebar.assets :refer [assets-toolbox*]]
[app.main.ui.workspace.sidebar.collapsable-button :refer [collapsed-button*]]
[app.main.ui.workspace.sidebar.debug :refer [debug-panel*]]
[app.main.ui.workspace.sidebar.debug-shape-info :refer [debug-shape-info*]]
[app.main.ui.workspace.sidebar.history :refer [history-toolbox*]]
@@ -44,19 +43,34 @@
;; --- Left Sidebar (Component)
(defn- on-collapse-left-sidebar
[]
(st/emit! (dw/toggle-layout-flag :collapse-left-sidebar)))
(def ^:private toggle-collapse-left-sidebar
(partial st/emit! (dw/toggle-layout-flag :collapse-left-sidebar)))
(mf/defc collapse-button*
{::mf/private true}
[]
;; NOTE: This custom button may be replace by an action button when this variant is designed
[:button {:class (stl/css :collapse-sidebar-button)
:on-click on-collapse-left-sidebar}
:on-click toggle-collapse-left-sidebar}
[:> icon* {:icon-id i/arrow
:size "s"
:aria-label (tr "workspace.sidebar.collapse")}]])
(mf/defc collapsed-button*
{::mf/memo true
::mf/private true}
[]
[:div {:id "left-sidebar-aside"
:data-width "0"
:class (stl/css :collapsed-sidebar)}
[:div {:class (stl/css :collapsed-title)}
[:button {:class (stl/css :collapsed-button)
:title (tr "workspace.sidebar.expand")
:on-click toggle-collapse-left-sidebar}
[:> icon* {:icon-id i/arrow
:size "s"
:aria-label (tr "workspace.sidebar.expand")}]]]])
(mf/defc layers-content*
{::mf/private true
::mf/memo true}
@@ -97,6 +111,7 @@
[:> layers-toolbox* {:size-parent width}]]))
(mf/defc left-sidebar*
{::mf/memo true}
[{:keys [layout file page-id tokens-lib active-tokens resolved-active-tokens]}]
@@ -161,7 +176,7 @@
[:aside {:ref parent-ref
:id "left-sidebar-aside"
:data-testid "left-sidebar"
:data-left-sidebar-width (str width)
:data-width (str width)
:class aside-class
:style {:--left-sidebar-width (dm/str width "px")}}

View File

@@ -116,6 +116,44 @@
}
}
.collapsed-sidebar {
@include deprecated.flexCenter;
position: absolute;
top: deprecated.$s-48;
left: 0;
padding: deprecated.$s-4;
border-radius: deprecated.$br-8;
background: var(--color-background-primary);
margin-inline-start: var(--sp-m);
}
.collapsed-title {
@include deprecated.flexCenter;
height: deprecated.$s-36;
width: deprecated.$s-24;
border-radius: deprecated.$br-8;
background: var(--color-background-secondary);
}
.collapsed-button {
@include deprecated.buttonStyle;
height: deprecated.$s-24;
width: deprecated.$s-16;
padding: 0;
border-radius: deprecated.$br-5;
svg {
@include deprecated.flexCenter;
height: deprecated.$s-16;
width: deprecated.$s-16;
color: transparent;
fill: none;
stroke: var(--icon-foreground);
}
&:hover {
svg {
stroke: var(--icon-foreground-hover);
}
}
}
.versions-tab {
width: 100%;
overflow: hidden;

View File

@@ -1,29 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.workspace.sidebar.collapsable-button
(:require-macros [app.main.style :as stl])
(:require
[app.main.data.workspace :as dw]
[app.main.store :as st]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.util.i18n :refer [tr]]
[rumext.v2 :as mf]))
(mf/defc collapsed-button*
{::mf/memo true}
[]
(let [on-click (mf/use-fn #(st/emit! (dw/toggle-layout-flag :collapse-left-sidebar)))]
[:div {:id "left-sidebar-aside"
:data-size "0"
:class (stl/css :collapsed-sidebar)}
[:div {:class (stl/css :collapsed-title)}
[:button {:class (stl/css :collapsed-button)
:title (tr "workspace.sidebar.expand")
:on-click on-click}
[:> icon* {:icon-id i/arrow
:size "s"
:aria-label (tr "workspace.sidebar.expand")}]]]]))

View File

@@ -1,45 +0,0 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@use "refactor/common-refactor.scss" as deprecated;
.collapsed-sidebar {
@include deprecated.flexCenter;
position: absolute;
top: deprecated.$s-48;
left: 0;
padding: deprecated.$s-4;
border-radius: deprecated.$br-8;
background: var(--color-background-primary);
margin-inline-start: var(--sp-m);
}
.collapsed-title {
@include deprecated.flexCenter;
height: deprecated.$s-36;
width: deprecated.$s-24;
border-radius: deprecated.$br-8;
background: var(--color-background-secondary);
}
.collapsed-button {
@include deprecated.buttonStyle;
height: deprecated.$s-24;
width: deprecated.$s-16;
padding: 0;
border-radius: deprecated.$br-5;
svg {
@include deprecated.flexCenter;
height: deprecated.$s-16;
width: deprecated.$s-16;
color: transparent;
fill: none;
stroke: var(--icon-foreground);
}
&:hover {
svg {
stroke: var(--icon-foreground-hover);
}
}
}

View File

@@ -5,6 +5,7 @@
// Copyright (c) KALEIDOS INC
@use "refactor/common-refactor.scss" as deprecated;
@use "ds/_utils.scss" as *;
.layer-row {
--layer-indentation-size: calc(#{deprecated.$s-4} * 6);
@@ -87,7 +88,7 @@
height: deprecated.$s-32;
width: calc(100% - (var(--depth) * var(--layer-indentation-size)));
cursor: pointer;
min-width: px2rem(140);
&.filtered {
width: calc(100% - deprecated.$s-12);
}

View File

@@ -211,9 +211,7 @@
overflow-x: auto;
overflow-y: overlay;
scrollbar-gutter: stable;
.element-list {
width: var(--left-sidebar-width);
display: grid;
}
}
.element-list {
display: grid;
}

View File

@@ -102,7 +102,7 @@
[:> deprecated-input/numeric-input*
{:placeholder (cond
(not all-equal?)
"Mixed"
(tr "settings.multiple")
(= :multiple (:r1 values))
(tr "settings.multiple")
:else

View File

@@ -264,12 +264,16 @@
(mf/deps font on-change)
(fn [new-variant-id]
(let [variant (d/seek #(= new-variant-id (:id %)) (:variants font))]
(on-change {:font-id (:id font)
:font-family (:family font)
:font-variant-id new-variant-id
:font-weight (:weight variant)
:font-style (:style variant)})
(dom/blur! (dom/get-target new-variant-id)))))
(when-not (nil? variant)
(on-change {:font-id (:id font)
:font-family (:family font)
:font-variant-id new-variant-id
:font-weight (:weight variant)
:font-style (:style variant)}))
;; NOTE: the select component we are using does not fire on-blur event
;; so we need to call on-blur manually
(when (some? on-blur)
(on-blur)))))
on-font-select
(mf/use-fn
@@ -303,7 +307,7 @@
:title (tr "inspect.attributes.typography.font-family")
:on-click #(reset! open-selector? true)}
(cond
(= :multiple font-id)
(or (= :multiple font-id) (= "mixed" font-id))
"--"
(some? font)
@@ -341,12 +345,13 @@
{:value (:id variant)
:key (pr-str variant)
:label (:name variant)})))
variant-options (if (= font-size :multiple)
variant-options (if (= font-variant-id :multiple)
(conj basic-variant-options
{:value :multiple
{:value ""
:key :multiple-variants
:label "--"})
basic-variant-options)]
;; TODO Add disabled mode
[:& select
{:class (stl/css :font-variant-select)

View File

@@ -68,7 +68,7 @@
(mf/defc color-token-row*
{::mf/private true}
[{:keys [active-tokens color-token color on-swatch-click-token detach-token open-modal-from-token]}]
[{:keys [active-tokens applied-token-name color on-swatch-click-token detach-token open-modal-from-token]}]
(let [;; `active-tokens` may be provided as a `delay` (lazy computation).
;; In that case we must deref it (`@active-tokens`) to force evaluation
;; and obtain the actual value. If its already realized (not a delay),
@@ -77,21 +77,22 @@
@active-tokens
active-tokens)
color-tokens (:color active-tokens)
active-color-tokens (:color active-tokens)
token (some #(when (= (:name %) color-token) %) color-tokens)
token (some #(when (= (:name %) applied-token-name) %) active-color-tokens)
on-detach-token
(mf/use-fn
(mf/deps detach-token token color-token)
(mf/deps detach-token token applied-token-name)
(fn []
(let [token (or token color-token)]
(let [token (or token applied-token-name)]
(detach-token token))))
has-errors (some? (:errors token))
token-name (:name token)
resolved (:resolved-value token)
not-active (and (some? active-tokens) (nil? token))
not-active (and (empty? active-tokens)
(nil? token))
id (dm/str (:id token) "-name")
swatch-tooltip-content (cond
not-active
@@ -109,7 +110,7 @@
#(mf/html
[:div
[:span (dm/str (tr "workspace.tokens.token-name") ": ")]
[:span {:class (stl/css :token-name-tooltip)} color-token]]))]
[:span {:class (stl/css :token-name-tooltip)} applied-token-name]]))]
[:div {:class (stl/css :color-info)}
[:div {:class (stl/css-case :token-color-wrapper true
@@ -128,7 +129,7 @@
:class (stl/css :token-tooltip)}
[:div {:class (stl/css :token-name)
:aria-labelledby id}
(or token-name color-token)]]
(or token-name applied-token-name)]]
[:div {:class (stl/css :token-actions)}
[:> icon-button*
{:variant "action"
@@ -146,7 +147,11 @@
on-change on-reorder on-detach on-open on-close on-remove origin on-detach-token
disable-drag on-focus on-blur select-only select-on-focus on-token-change applied-token]}]
(let [token-color (contains? cfg/flags :token-color)
(let [;; TODO: Remove this workaround fixing `get-attrs*` fn on sidebar/options/shapes/multiple.cljs
applied-token (if (= :multiple applied-token)
nil
applied-token)
token-color (contains? cfg/flags :token-color)
libraries (mf/deref refs/files)
color-without-hash (mf/use-memo
@@ -177,7 +182,6 @@
(-> (deref active-tokens*)
(select-keys (get tk/tokens-by-input origin))
(not-empty)))))
on-focus'
(mf/use-fn
(mf/deps on-focus)
@@ -352,7 +356,7 @@
(cond
(and token-color applied-token)
[:> color-token-row* {:active-tokens tokens
:color-token applied-token
:applied-token-name applied-token
:color (dissoc color :ref-id :ref-file)
:on-swatch-click-token on-swatch-click-token
:detach-token detach-token

View File

@@ -63,7 +63,8 @@
:data {:index index})
[nil nil])
stroke-color-token (:stroke-color applied-tokens)
stroke-color-token
(:stroke-color applied-tokens)
on-color-change-refactor
(mf/use-fn

View File

@@ -20,15 +20,21 @@
;; Component -------------------------------------------------------------------
(defn calculate-position
(defn- calculate-position
"Calculates the style properties for the given coordinates and position"
[{vh :height} position x y color?]
(let [;; picker height in pixels
;; TODO: Revisit these harcoded values
h (if color? 610 510)
[{vh :height} position x y token-type]
(let [; TODO: Revisit these harcoded values
modal-height (case token-type
:color
500
:typography
660
:shadow
660
400)
;; Checks for overflow outside the viewport height
max-y (- vh h)
overflow-fix (max 0 (+ y (- 50) h (- vh)))
max-y (- vh modal-height)
overflow-fix (max 0 (+ y (- 50) modal-height (- vh)))
bottom-offset "1rem"
top-offset (dm/str (- y 70) "px")
max-height-top (str "calc(100vh - " top-offset)
@@ -61,17 +67,19 @@
:top (dm/str (- y 70 overflow-fix) "px")
:maxHeight max-height-top}))))
(defn use-viewport-position-style [x y position color?]
(defn use-viewport-position-style [x y position token-type]
(let [vport (-> (l/derived :vport refs/workspace-local)
(mf/deref))]
(-> (calculate-position vport position x y color?)
(-> (calculate-position vport position x y token-type)
(clj->js))))
(mf/defc token-update-create-modal
{::mf/wrap-props false}
[{:keys [x y position token token-type action selected-token-set-id] :as _args}]
(let [wrapper-style (use-viewport-position-style x y position (= token-type :color))
modal-size-large* (mf/use-state (= token-type :typography))
(let [wrapper-style (use-viewport-position-style x y position token-type)
modal-size-large* (mf/use-state (or (= token-type :typography)
(= token-type :color)
(= token-type :shadow)))
modal-size-large? (deref modal-size-large*)
close-modal (mf/use-fn
(fn []

View File

@@ -18,7 +18,7 @@
padding: deprecated.$s-8 deprecated.$s-16;
border-radius: deprecated.$s-8;
border: deprecated.$s-2 solid var(--panel-border-color);
z-index: deprecated.$z-index-3;
z-index: deprecated.$z-index-1;
background-color: var(--color-background-primary);
transition:
top 0.3s,

View File

@@ -19,5 +19,5 @@
}
.threads {
position: fixed;
position: absolute;
}

View File

@@ -16,6 +16,7 @@
[app.common.geom.shapes.points :as gpo]
[app.common.types.shape.layout :as ctl]
[app.common.uuid :as uuid]
[app.render-wasm.api :as wasm.api]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
@@ -275,3 +276,26 @@
:y2 (:y end-p)
:style {:stroke "red"
:stroke-width (/ 1 zoom)}}]))]))))
(mf/defc debug-text-wasm-position-data
{::mf/wrap-props false}
[props]
(let [zoom (unchecked-get props "zoom")
selected-shapes (unchecked-get props "selected-shapes")
selected-text
(when (and (= (count selected-shapes) 1) (= :text (-> selected-shapes first :type)))
(first selected-shapes))
position-data
(when selected-text
(wasm.api/calculate-position-data selected-text))]
(for [{:keys [x y width height]} position-data]
[:rect {:x x
:y (- y height)
:width width
:height height
:fill "none"
:strokeWidth (/ 1 zoom)
:stroke "red"}])))

View File

@@ -12,10 +12,13 @@
[app.common.files.helpers :as cfh]
[app.common.geom.shapes :as gsh]
[app.common.types.color :as clr]
[app.common.types.component :as ctk]
[app.common.types.path :as path]
[app.common.types.shape :as cts]
[app.common.types.shape.layout :as ctl]
[app.main.data.common :as dcm]
[app.main.data.workspace.transforms :as dwt]
[app.main.data.workspace.variants :as dwv]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
@@ -257,6 +260,16 @@
first-shape (first selected-shapes)
show-add-variant? (and single-select?
(or (ctk/is-variant-container? first-shape)
(ctk/is-variant? first-shape)))
add-variant
(mf/use-fn
(mf/deps first-shape)
#(st/emit!
(dwv/add-new-variant (:id first-shape))))
show-padding?
(and (nil? transform)
single-select?
@@ -293,9 +306,15 @@
(->> wasm.api/module
(p/fmap (fn [ready?]
(when ready?
(let [init? (wasm.api/init-canvas-context canvas)]
(let [init? (try
(wasm.api/init-canvas-context canvas)
(catch :default e
(js/console.error "Error initializing canvas context:" e)
false))]
(reset! canvas-init? init?)
(when-not init? (js/alert "WebGL not supported")))))))
(when-not init?
(js/alert "WebGL not supported")
(st/emit! (dcm/go-to-dashboard-recent))))))))
(fn []
(wasm.api/clear-canvas))))
@@ -635,6 +654,12 @@
:hover-top-frame-id @hover-top-frame-id
:zoom zoom}])
(when (dbg/enabled? :text-outline)
[:& wvd/debug-text-wasm-position-data
{:selected-shapes selected-shapes
:objects base-objects
:zoom zoom}])
(when show-selection-handlers?
[:g.selection-handlers {:clipPath "url(#clip-handlers)"}
(when-not text-editing?
@@ -663,6 +688,11 @@
{:id (first selected)
:zoom zoom}])
(when show-add-variant?
[:> widgets/button-add* {:shape first-shape
:zoom zoom
:on-click add-variant}])
[:g.grid-layout-editor {:clipPath "url(#clip-handlers)"}
(when show-grid-editor?
[:& grid-layout/editor

View File

@@ -55,6 +55,7 @@
[app.plugins.ruler-guides :as rg]
[app.plugins.text :as text]
[app.plugins.utils :as u]
[app.util.http :as http]
[app.util.object :as obj]
[beicon.v2.core :as rx]
[cuerdas.core :as str]))
@@ -1196,7 +1197,12 @@
(js/Promise.
(fn [resolve reject]
(->> (rp/cmd! :export payload)
(rx/mapcat #(rp/cmd! :export {:cmd :get-resource :wait true :id (:id %) :blob? true}))
(rx/mapcat (fn [{:keys [uri]}]
(->> (http/send! {:method :get
:uri uri
:response-type :blob
:omit-default-headers true})
(rx/map :body))))
(rx/mapcat #(.arrayBuffer %))
(rx/map #(js/Uint8Array. %))
(rx/subs! resolve reject))))))))

View File

@@ -18,11 +18,14 @@
[app.common.types.path :as path]
[app.common.types.path.impl :as path.impl]
[app.common.types.shape.layout :as ctl]
[app.common.types.text :as txt]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.render-wasm :as drw]
[app.main.refs :as refs]
[app.main.render :as render]
[app.main.store :as st]
[app.main.ui.shapes.text]
[app.main.worker :as mw]
[app.render-wasm.api.fonts :as f]
[app.render-wasm.api.texts :as t]
@@ -33,7 +36,7 @@
[app.render-wasm.performance :as perf]
[app.render-wasm.serializers :as sr]
[app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.svg-fills :as svg-fills]
[app.render-wasm.svg-filters :as svg-filters]
;; FIXME: rename; confunsing name
[app.render-wasm.wasm :as wasm]
[app.util.debug :as dbg]
@@ -41,6 +44,7 @@
[app.util.globals :as ug]
[app.util.text.content :as tc]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[promesa.core :as p]
[rumext.v2 :as mf]))
@@ -92,20 +96,20 @@
;; This should never be called from the outside.
(defn- render
[timestamp]
(when wasm/context-initialized?
(when (and wasm/context-initialized? (not @wasm/context-lost?))
(h/call wasm/internal-module "_render" timestamp)
(set! wasm/internal-frame-id nil)
(ug/dispatch! (ug/event "penpot:wasm:render"))))
(defn render-sync
[]
(when wasm/context-initialized?
(when (and wasm/context-initialized? (not @wasm/context-lost?))
(h/call wasm/internal-module "_render_sync")
(set! wasm/internal-frame-id nil)))
(defn render-sync-shape
[id]
(when wasm/context-initialized?
(when (and wasm/context-initialized? (not @wasm/context-lost?))
(let [buffer (uuid/get-u32 id)]
(h/call wasm/internal-module "_render_sync_shape"
(aget buffer 0)
@@ -119,7 +123,7 @@
(defn request-render
[_requester]
(when (not @pending-render)
(when (and wasm/context-initialized? (not @pending-render) (not @wasm/context-lost?))
(reset! pending-render true)
(js/requestAnimationFrame
(fn [ts]
@@ -702,7 +706,7 @@
(set-grid-layout-columns (get shape :layout-grid-columns))
(set-grid-layout-cells (get shape :layout-grid-cells)))
(defn set-layout-child
(defn set-layout-data
[shape]
(let [margins (get shape :layout-item-margin)
margin-top (get margins :m1 0)
@@ -725,7 +729,7 @@
is-absolute (boolean (get shape :layout-item-absolute))
z-index (get shape :layout-item-z-index)]
(h/call wasm/internal-module
"_set_layout_child_data"
"_set_layout_data"
margin-top
margin-right
margin-bottom
@@ -745,18 +749,18 @@
is-absolute
(d/nilv z-index 0))))
(defn has-any-layout-prop? [shape]
(some #(and (keyword? %)
(str/starts-with? (name %) "layout-"))
(keys shape)))
(defn clear-layout
[]
(h/call wasm/internal-module "_clear_shape_layout"))
(defn- set-shape-layout
[shape objects]
[shape]
(clear-layout)
(when (or (ctl/any-layout? shape)
(ctl/any-layout-immediate-child? objects shape))
(set-layout-child shape))
(when (ctl/flex-layout? shape)
(set-flex-layout shape))
@@ -874,27 +878,43 @@
(def render-finish
(letfn [(do-render [ts]
(perf/begin-measure "render-finish")
(h/call wasm/internal-module "_set_view_end")
(render ts))]
(render ts)
(perf/end-measure "render-finish"))]
(fns/debounce do-render DEBOUNCE_DELAY_MS)))
(def render-pan
(fns/throttle render THROTTLE_DELAY_MS))
(letfn [(do-render-pan [ts]
(perf/begin-measure "render-pan")
(render ts)
(perf/end-measure "render-pan"))]
(fns/throttle do-render-pan THROTTLE_DELAY_MS)))
(defn set-view-box
[prev-zoom zoom vbox]
(h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox)))
(let [is-pan (mth/close? prev-zoom zoom)]
(perf/begin-measure "set-view-box")
(h/call wasm/internal-module "_set_view_start")
(h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox)))
(if (mth/close? prev-zoom zoom)
(do (render-pan)
(render-finish))
(do (h/call wasm/internal-module "_render_from_cache" 0)
(render-finish))))
(if is-pan
(do (perf/end-measure "set-view-box")
(perf/begin-measure "set-view-box::pan")
(render-pan)
(render-finish)
(perf/end-measure "set-view-box::pan"))
(do (perf/end-measure "set-view-box")
(perf/begin-measure "set-view-box::zoom")
(h/call wasm/internal-module "_render_from_cache" 0)
(render-finish)
(perf/end-measure "set-view-box::zoom")))))
(defn set-object
[objects shape]
[shape]
(perf/begin-measure "set-object")
(let [id (dm/get-prop shape :id)
(let [shape (svg-filters/apply-svg-derived shape)
id (dm/get-prop shape :id)
type (dm/get-prop shape :type)
parent-id (get shape :parent-id)
@@ -908,14 +928,7 @@
rotation (get shape :rotation)
transform (get shape :transform)
;; If the shape comes from an imported SVG (we know this because
;; it has the :svg-attrs attribute) and it does not have its
;; own fill, we set a default black fill. This fill will be
;; inherited by child nodes and emulates the behavior of
;; standard SVG, where a node without an explicit fill
;; defaults to black.
fills (svg-fills/resolve-shape-fills shape)
fills (get shape :fills)
strokes (if (= type :group)
[] (get shape :strokes))
children (get shape :shapes)
@@ -947,8 +960,8 @@
(set-shape-children children)
(set-shape-corners corners)
(set-shape-blur blur)
(when (and (= type :group) masked)
(set-masked masked))
(when (= type :group)
(set-masked (boolean masked)))
(when (= type :bool)
(set-shape-bool-type bool-type))
(when (and (some? content)
@@ -959,12 +972,11 @@
(set-shape-svg-attrs svg-attrs))
(when (and (some? content) (= type :svg-raw))
(set-shape-svg-raw-content (get-static-markup shape)))
(when (some? shadows) (set-shape-shadows shadows))
(set-shape-shadows shadows)
(when (= type :text)
(set-shape-grow-type grow-type))
(set-shape-layout shape objects)
(set-shape-layout shape)
(set-shape-selrect selrect)
(let [pending_thumbnails (into [] (concat
@@ -988,10 +1000,7 @@
(run!
(fn [id]
(f/update-text-layout id)
(mw/emit! {:cmd :index/update-text-rect
:page-id (:current-page-id @st/state)
:shape-id id
:dimensions (get-text-dimensions id)})))))
(update-text-rect! id)))))
(defn process-pending
([shapes thumbnails full on-complete]
@@ -1021,7 +1030,7 @@
(defn process-object
[shape]
(let [{:keys [thumbnails full]} (set-object [] shape)]
(let [{:keys [thumbnails full]} (set-object shape)]
(process-pending [shape] thumbnails full noop-fn)))
(defn set-objects
@@ -1036,7 +1045,7 @@
(loop [index 0 thumbnails-acc [] full-acc []]
(if (< index total-shapes)
(let [shape (nth shapes index)
{:keys [thumbnails full]} (set-object objects shape)]
{:keys [thumbnails full]} (set-object shape)]
(recur (inc index)
(into thumbnails-acc thumbnails)
(into full-acc full)))
@@ -1232,32 +1241,65 @@
(when-not (nil? context)
(let [handle (.registerContext ^js gl context #js {"majorVersion" 2})]
(.makeContextCurrent ^js gl handle)
(set! wasm/gl-context-handle handle)
(set! wasm/gl-context context)
;; Force the WEBGL_debug_renderer_info extension as emscripten does not enable it
(.getExtension context "WEBGL_debug_renderer_info")
;; Initialize Wasm Render Engine
(h/call wasm/internal-module "_init" (/ (.-width ^js canvas) dpr) (/ (.-height ^js canvas) dpr))
(h/call wasm/internal-module "_set_render_options" flags dpr))
(set! wasm/context-initialized? true))
(h/call wasm/internal-module "_set_render_options" flags dpr)
(h/call wasm/internal-module "_set_browser" browser)
;; Set browser and canvas size only after initialization
(h/call wasm/internal-module "_set_browser" browser)
(set-canvas-size canvas)
;; Add event listeners for WebGL context lost
(let [handler (fn [event]
(.preventDefault event)
(reset! wasm/context-lost? true)
(log/warn :hint "WebGL context lost")
(st/emit! (drw/context-lost)))]
(set! wasm/context-lost-handler handler)
(set! wasm/context-lost-canvas canvas)
(.addEventListener canvas "webglcontextlost" handler))
(set! wasm/context-initialized? true)))
(h/call wasm/internal-module "_set_render_options" flags dpr)
(set-canvas-size canvas)
context-init?))
(defn clear-canvas
[]
(try
;; TODO: perform corresponding cleaning
(set! wasm/context-initialized? false)
(h/call wasm/internal-module "_clean_up")
(when wasm/context-initialized?
(try
;; TODO: perform corresponding cleaning
(set! wasm/context-initialized? false)
(h/call wasm/internal-module "_clean_up")
;; If this calls panics we don't want to crash. This happens sometimes
;; with hot-reload in develop
(catch :default error
(.error js/console error))))
;; Remove event listener for WebGL context lost
(when (and wasm/context-lost-handler wasm/context-lost-canvas)
(.removeEventListener wasm/context-lost-canvas "webglcontextlost" wasm/context-lost-handler)
(set! wasm/context-lost-canvas nil)
(set! wasm/context-lost-handler nil))
;; Ensure the WebGL context is properly disposed so browsers do not keep
;; accumulating active contexts between page switches.
(when-let [gl (unchecked-get wasm/internal-module "GL")]
(when-let [handle wasm/gl-context-handle]
(try
;; Ask the browser to release resources explicitly if available.
(when-let [ctx wasm/gl-context]
(when-let [lose-ext (.getExtension ^js ctx "WEBGL_lose_context")]
(.loseContext ^js lose-ext)))
(.deleteContext ^js gl handle)
(finally
(set! wasm/gl-context-handle nil)
(set! wasm/gl-context nil)))))
;; If this calls panics we don't want to crash. This happens sometimes
;; with hot-reload in develop
(catch :default error
(.error js/console error)))))
(defn show-grid
[id]
@@ -1347,50 +1389,72 @@
(h/call wasm/internal-module "_end_temp_objects")
content)))
(def POSITION-DATA-U8-SIZE 36)
(def POSITION-DATA-U32-SIZE (/ POSITION-DATA-U8-SIZE 4))
(defn calculate-position-data
[shape]
(when wasm/context-initialized?
(use-shape (:id shape))
(let [heapf32 (mem/get-heap-f32)
heapu32 (mem/get-heap-u32)
offset (-> (h/call wasm/internal-module "_calculate_position_data")
(mem/->offset-32))
length (aget heapu32 offset)
max-offset (+ offset 1 (* length POSITION-DATA-U32-SIZE))
result
(loop [result (transient [])
offset (inc offset)]
(if (< offset max-offset)
(let [entry (dr/read-position-data-entry heapu32 heapf32 offset)]
(recur (conj! result entry)
(+ offset POSITION-DATA-U32-SIZE)))
(persistent! result)))
result
(->> result
(mapv
(fn [{:keys [paragraph span start-pos end-pos direction x y width height]}]
(let [content (:content shape)
element (-> content :children
(get 0) :children ;; paragraph-set
(get paragraph) :children ;; paragraph
(get span))
text (subs (:text element) start-pos end-pos)]
(d/patch-object
txt/default-text-attrs
(d/without-nils
{:x x
:y (+ y height)
:width width
:height height
:direction (dr/translate-direction direction)
:font-family (get element :font-family)
:font-size (get element :font-size)
:font-weight (get element :font-weight)
:text-transform (get element :text-transform)
:text-decoration (get element :text-decoration)
:letter-spacing (get element :letter-spacing)
:font-style (get element :font-style)
:fills (get element :fills)
:text text}))))))]
(mem/free)
result)))
(defn init-wasm-module
[module]
(let [default-fn (unchecked-get module "default")
serializers
#js
{:blur-type (unchecked-get module "RawBlurType")
:blend-mode (unchecked-get module "RawBlendMode")
:bool-type (unchecked-get module "RawBoolType")
:font-style (unchecked-get module "RawFontStyle")
:flex-direction (unchecked-get module "RawFlexDirection")
:grid-direction (unchecked-get module "RawGridDirection")
:grow-type (unchecked-get module "RawGrowType")
:align-items (unchecked-get module "RawAlignItems")
:align-self (unchecked-get module "RawAlignSelf")
:align-content (unchecked-get module "RawAlignContent")
:justify-items (unchecked-get module "RawJustifyItems")
:justify-content (unchecked-get module "RawJustifyContent")
:justify-self (unchecked-get module "RawJustifySelf")
:wrap-type (unchecked-get module "RawWrapType")
:grid-track-type (unchecked-get module "RawGridTrackType")
:shadow-style (unchecked-get module "RawShadowStyle")
:stroke-style (unchecked-get module "RawStrokeStyle")
:stroke-cap (unchecked-get module "RawStrokeCap")
:shape-type (unchecked-get module "RawShapeType")
:constraint-h (unchecked-get module "RawConstraintH")
:constraint-v (unchecked-get module "RawConstraintV")
:sizing (unchecked-get module "RawSizing")
:vertical-align (unchecked-get module "RawVerticalAlign")
:fill-data (unchecked-get module "RawFillData")
:text-align (unchecked-get module "RawTextAlign")
:text-direction (unchecked-get module "RawTextDirection")
:text-decoration (unchecked-get module "RawTextDecoration")
:text-transform (unchecked-get module "RawTextTransform")
:segment-data (unchecked-get module "RawSegmentData")
:stroke-linecap (unchecked-get module "RawStrokeLineCap")
:stroke-linejoin (unchecked-get module "RawStrokeLineJoin")
:fill-rule (unchecked-get module "RawFillRule")}]
(set! wasm/serializers serializers)
(default-fn)))
href (cf/resolve-href "js/render-wasm.wasm")]
(default-fn #js {:locateFile (constantly href)})))
(defonce module
(delay
(if (exists? js/dynamicImport)
(let [uri (cf/resolve-static-asset "js/render_wasm.js")]
(let [uri (cf/resolve-href "js/render-wasm.js")]
(->> (js/dynamicImport (str uri))
(p/mcat init-wasm-module)
(p/fmap

View File

@@ -354,32 +354,32 @@
:is-fallback true}))
(def noto-fonts
{:japanese {:font-id "gfont-noto-sans-jp" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:chinese {:font-id "gfont-noto-sans-sc" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:korean {:font-id "gfont-noto-sans-kr" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:arabic {:font-id "gfont-noto-sans-arabic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:cyrillic {:font-id "gfont-noto-sans-cyrillic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:greek {:font-id "gfont-noto-sans-greek" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:hebrew {:font-id "gfont-noto-sans-hebrew" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:thai {:font-id "gfont-noto-sans-thai" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:devanagari {:font-id "gfont-noto-sans-devanagari" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:tamil {:font-id "gfont-noto-sans-tamil" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:latin-ext {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:vietnamese {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:armenian {:font-id "gfont-noto-sans-armenian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:bengali {:font-id "gfont-noto-sans-bengali" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:cherokee {:font-id "gfont-noto-sans-cherokee" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:ethiopic {:font-id "gfont-noto-sans-ethiopic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:georgian {:font-id "gfont-noto-sans-georgian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:gujarati {:font-id "gfont-noto-sans-gujarati" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:gurmukhi {:font-id "gfont-noto-sans-gurmukhi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:khmer {:font-id "gfont-noto-sans-khmer" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:lao {:font-id "gfont-noto-sans-lao" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:malayalam {:font-id "gfont-noto-sans-malayalam" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:myanmar {:font-id "gfont-noto-sans-myanmar" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:sinhala {:font-id "gfont-noto-sans-sinhala" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:telugu {:font-id "gfont-noto-sans-telugu" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:tibetan {:font-id "gfont-noto-sans-tibetan" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
{:japanese {:font-id "gfont-noto-sans-jp" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:chinese {:font-id "gfont-noto-sans-sc" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:korean {:font-id "gfont-noto-sans-kr" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:arabic {:font-id "gfont-noto-sans-arabic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:cyrillic {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:greek {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:hebrew {:font-id "gfont-noto-sans-hebrew" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:thai {:font-id "gfont-noto-sans-thai" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:devanagari {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:tamil {:font-id "gfont-noto-sans-tamil" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:latin-ext {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:vietnamese {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:armenian {:font-id "gfont-noto-sans-armenian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:bengali {:font-id "gfont-noto-sans-bengali" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:cherokee {:font-id "gfont-noto-sans-cherokee" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:ethiopic {:font-id "gfont-noto-sans-ethiopic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:georgian {:font-id "gfont-noto-sans-georgian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:gujarati {:font-id "gfont-noto-sans-gujarati" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:gurmukhi {:font-id "gfont-noto-sans-gurmukhi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:khmer {:font-id "gfont-noto-sans-khmer" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:lao {:font-id "gfont-noto-sans-lao" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:malayalam {:font-id "gfont-noto-sans-malayalam" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:myanmar {:font-id "gfont-noto-sans-myanmar" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:sinhala {:font-id "gfont-noto-sans-sinhala" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:telugu {:font-id "gfont-noto-sans-telugu" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:tibetan {:font-id "gfont-noto-serif-tibetan" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:javanese {:font-id "gfont-noto-sans-javanese" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:kannada {:font-id "gfont-noto-sans-kannada" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:oriya {:font-id "gfont-noto-sans-oriya" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
@@ -399,8 +399,8 @@
:bamum {:font-id "gfont-noto-sans-bamum" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:meroitic {:font-id "gfont-noto-sans-meroitic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:symbols {:font-id "gfont-noto-sans-symbols" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:symbols-2 {:font-id "gfont-noto-sans-symbols-2" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:music {:font-id "gfont-noto-music" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}})
:symbols-2 {:font-id "gfont-noto-sans-symbols-2" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:music {:font-id "gfont-noto-music" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}})
(defn add-noto-fonts [fonts languages]
(reduce (fn [acc lang]

View File

@@ -0,0 +1,242 @@
export const GrowType = {
"fixed": 0,
"auto-width": 1,
"auto-height": 2,
};
export const RawBlendMode = {
"normal": 3,
"screen": 14,
"overlay": 15,
"darken": 16,
"lighten": 17,
"color-dodge": 18,
"color-burn": 19,
"hard-light": 20,
"soft-light": 21,
"difference": 22,
"exclusion": 23,
"multiply": 24,
"hue": 25,
"saturation": 26,
"color": 27,
"luminosity": 28,
};
export const RawBlurType = {
"layer-blur": 0,
};
export const RawFillData = {
"solid": 0,
"linear": 1,
"radial": 2,
"image": 3,
};
export const RawFontStyle = {
"normal": 0,
"italic": 1,
};
export const RawAlignItems = {
"start": 0,
"end": 1,
"center": 2,
"stretch": 3,
};
export const RawAlignContent = {
"start": 0,
"end": 1,
"center": 2,
"space-between": 3,
"space-around": 4,
"space-evenly": 5,
"stretch": 6,
};
export const RawJustifyItems = {
"start": 0,
"end": 1,
"center": 2,
"stretch": 3,
};
export const RawJustifyContent = {
"start": 0,
"end": 1,
"center": 2,
"space-between": 3,
"space-around": 4,
"space-evenly": 5,
"stretch": 6,
};
export const RawJustifySelf = {
"none": 0,
"auto": 1,
"start": 2,
"end": 3,
"center": 4,
"stretch": 5,
};
export const RawAlignSelf = {
"none": 0,
"auto": 1,
"start": 2,
"end": 3,
"center": 4,
"stretch": 5,
};
export const RawVerticalAlign = {
"top": 0,
"center": 1,
"bottom": 2,
};
export const RawConstraintH = {
"left": 0,
"right": 1,
"leftright": 2,
"center": 3,
"scale": 4,
};
export const RawConstraintV = {
"top": 0,
"bottom": 1,
"topbottom": 2,
"center": 3,
"scale": 4,
};
export const RawFlexDirection = {
"row": 0,
"row-reverse": 1,
"column": 2,
"column-reverse": 3,
};
export const RawWrapType = {
"wrap": 0,
"nowrap": 1,
};
export const RawGridDirection = {
"row": 0,
"column": 1,
};
export const RawGridTrackType = {
"percent": 0,
"flex": 1,
"auto": 2,
"fixed": 3,
};
export const RawSizing = {
"fill": 0,
"fix": 1,
"auto": 2,
};
export const RawBoolType = {
"union": 0,
"difference": 1,
"intersection": 2,
"exclusion": 3,
};
export const RawSegmentData = {
"move-to": 1,
"line-to": 2,
"curve-to": 3,
"close": 4,
};
export const RawShadowStyle = {
"drop-shadow": 0,
"inner-shadow": 1,
};
export const RawShapeType = {
"frame": 0,
"group": 1,
"bool": 2,
"rect": 3,
"path": 4,
"text": 5,
"circle": 6,
"svg-raw": 7,
};
export const RawStrokeStyle = {
"solid": 0,
"dotted": 1,
"dashed": 2,
"mixed": 3,
};
export const RawStrokeCap = {
"none": 0,
"line-arrow": 1,
"triangle-arrow": 2,
"square-marker": 3,
"circle-marker": 4,
"diamond-marker": 5,
"round": 6,
"square": 7,
};
export const RawFillRule = {
"nonzero": 0,
"evenodd": 1,
};
export const RawStrokeLineCap = {
"butt": 0,
"round": 1,
"square": 2,
};
export const RawStrokeLineJoin = {
"miter": 0,
"round": 1,
"bevel": 2,
};
export const RawTextAlign = {
"left": 0,
"center": 1,
"right": 2,
"justify": 3,
};
export const RawTextDirection = {
"ltr": 0,
"rtl": 1,
};
export const RawTextDecoration = {
"none": 0,
"underline": 1,
"line-through": 2,
"overline": 3,
};
export const RawTextTransform = {
"none": 0,
"uppercase": 1,
"lowercase": 2,
"capitalize": 3,
};
export const RawGrowType = {
"fixed": 0,
"auto-width": 1,
"auto-height": 2,
};

View File

@@ -45,4 +45,29 @@
:center (gpt/point cx cy)
:transform (gmt/matrix a b c d e f)}))
(defn read-position-data-entry
[heapu32 heapf32 offset]
(let [paragraph (aget heapu32 (+ offset 0))
span (aget heapu32 (+ offset 1))
start-pos (aget heapu32 (+ offset 2))
end-pos (aget heapu32 (+ offset 3))
x (aget heapf32 (+ offset 4))
y (aget heapf32 (+ offset 5))
width (aget heapf32 (+ offset 6))
height (aget heapf32 (+ offset 7))
direction (aget heapu32 (+ offset 8))]
{:paragraph paragraph
:span span
:start-pos start-pos
:end-pos end-pos
:x x
:y y
:width width
:height height
:direction direction}))
(defn translate-direction
[direction]
(case direction
0 "rtl"
"ltr"))

View File

@@ -14,7 +14,7 @@
[app.common.types.shape.layout :as ctl]
[app.main.refs :as refs]
[app.render-wasm.api :as api]
[app.render-wasm.svg-fills :as svg-fills]
[app.render-wasm.svg-filters :as svg-filters]
[app.render-wasm.wasm :as wasm]
[beicon.v2.core :as rx]
[cljs.core :as c]
@@ -130,7 +130,11 @@
(defn- set-wasm-attr!
[shape k]
(when wasm/context-initialized?
(let [v (get shape k)
(let [shape (case k
:svg-attrs (svg-filters/apply-svg-derived (assoc shape :svg-attrs (get shape :svg-attrs)))
(:fills :blur :shadow) (svg-filters/apply-svg-derived shape)
shape)
v (get shape k)
id (get shape :id)]
(case k
:parent-id
@@ -163,8 +167,7 @@
(api/set-shape-transform v)
:fills
(let [fills (svg-fills/resolve-shape-fills shape)]
(into [] (api/set-shape-fills id fills false)))
(api/set-shape-fills id v false)
:strokes
(into [] (api/set-shape-strokes id v false))
@@ -222,12 +225,16 @@
v])
:svg-attrs
(when (cfh/path-shape? shape)
(api/set-shape-svg-attrs v))
(do
(api/set-shape-svg-attrs v)
;; Always update fills/blur/shadow to clear previous state if filters disappear
(api/set-shape-fills id (:fills shape) false)
(api/set-shape-blur (:blur shape))
(api/set-shape-shadows (:shadow shape)))
:masked-group
(when (cfh/mask-shape? shape)
(api/set-masked (:masked-group shape)))
(when (cfh/group-shape? shape)
(api/set-masked (boolean (:masked-group shape))))
:content
(cond
@@ -262,7 +269,7 @@
:layout-item-min-w
:layout-item-absolute
:layout-item-z-index)
(api/set-layout-child shape)
(api/set-layout-data shape)
:layout-grid-rows
(api/set-grid-layout-rows v)
@@ -273,8 +280,18 @@
:layout-grid-cells
(api/set-grid-layout-cells v)
(:layout
:layout-flex-dir
:layout
(do
(api/clear-layout)
(cond
(ctl/grid-layout? shape)
(api/set-grid-layout shape)
(ctl/flex-layout? shape)
(api/set-flex-layout shape))
(api/set-layout-data shape))
(:layout-flex-dir
:layout-gap-type
:layout-gap
:layout-align-items
@@ -284,15 +301,12 @@
:layout-wrap-type
:layout-padding-type
:layout-padding)
(do
(api/clear-layout)
(cond
(ctl/grid-layout? shape)
(api/set-grid-layout-data shape)
(cond
(ctl/grid-layout? shape)
(api/set-grid-layout-data shape)
(ctl/flex-layout? shape)
(api/set-flex-layout shape))
(api/set-layout-child shape))
(ctl/flex-layout? shape)
(api/set-flex-layout shape))
;; Property not in WASM
nil))))

View File

@@ -74,6 +74,30 @@
:width (max 0.01 (or (dm/get-prop shape :width) 1))
:height (max 0.01 (or (dm/get-prop shape :height) 1))}))))
(defn- apply-svg-transform
"Applies SVG transform to a point if present."
[pt svg-transform]
(if svg-transform
(gpt/transform pt svg-transform)
pt))
(defn- apply-viewbox-transform
"Transforms a point from viewBox space to selrect space."
[pt viewbox rect]
(if viewbox
(let [{svg-x :x svg-y :y svg-width :width svg-height :height} viewbox
rect-width (max 0.01 (dm/get-prop rect :width))
rect-height (max 0.01 (dm/get-prop rect :height))
origin-x (or (dm/get-prop rect :x) (dm/get-prop rect :x1) 0)
origin-y (or (dm/get-prop rect :y) (dm/get-prop rect :y1) 0)
scale-x (/ rect-width svg-width)
scale-y (/ rect-height svg-height)
;; Transform from viewBox space to selrect space
transformed-x (+ origin-x (* (- (dm/get-prop pt :x) svg-x) scale-x))
transformed-y (+ origin-y (* (- (dm/get-prop pt :y) svg-y) scale-y))]
(gpt/point transformed-x transformed-y))
pt))
(defn- normalize-point
[pt units shape]
(if (= units "userspaceonuse")
@@ -81,9 +105,16 @@
width (max 0.01 (dm/get-prop rect :width))
height (max 0.01 (dm/get-prop rect :height))
origin-x (or (dm/get-prop rect :x) (dm/get-prop rect :x1) 0)
origin-y (or (dm/get-prop rect :y) (dm/get-prop rect :y1) 0)]
(gpt/point (/ (- (dm/get-prop pt :x) origin-x) width)
(/ (- (dm/get-prop pt :y) origin-y) height)))
origin-y (or (dm/get-prop rect :y) (dm/get-prop rect :y1) 0)
svg-transform (:svg-transform shape)
viewbox (:svg-viewbox shape)
;; For userSpaceOnUse, coordinates are in SVG user space
;; We need to transform them to shape space before normalizing
pt-after-svg-transform (apply-svg-transform pt svg-transform)
transformed-pt (apply-viewbox-transform pt-after-svg-transform viewbox rect)
normalized-x (/ (- (dm/get-prop transformed-pt :x) origin-x) width)
normalized-y (/ (- (dm/get-prop transformed-pt :y) origin-y) height)]
(gpt/point normalized-x normalized-y))
pt))
(defn- normalize-attrs
@@ -257,18 +288,25 @@
(parse-gradient-stop node))))
vec)]
(when (seq stops)
(let [[center radius-point]
(let [[center point-x point-y]
(let [points (apply-gradient-transform [(gpt/point cx cy)
(gpt/point (+ cx r) cy)]
(gpt/point (+ cx r) cy)
(gpt/point cx (+ cy r))]
transform)]
(map #(normalize-point % units shape) points))
radius (gpt/distance center radius-point)]
radius-x (gpt/distance center point-x)
radius-y (gpt/distance center point-y)
;; Prefer Y as the base radius so width becomes the X/Y ratio.
base-radius (if (pos? radius-y) radius-y radius-x)
radius-point (if (pos? radius-y) point-y point-x)
width (let [safe-radius (max base-radius 1.0e-6)]
(/ radius-x safe-radius))]
{:type :radial
:start-x (dm/get-prop center :x)
:start-y (dm/get-prop center :y)
:end-x (dm/get-prop radius-point :x)
:end-y (dm/get-prop radius-point :y)
:width radius
:width width
:stops stops}))))
(defn- svg-gradient->fill

View File

@@ -0,0 +1,98 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.render-wasm.svg-filters
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.svg :as csvg]
[app.common.uuid :as uuid]
[app.render-wasm.svg-fills :as svg-fills]))
(def ^:private drop-shadow-tags
#{:feOffset :feGaussianBlur :feColorMatrix})
(defn- find-filter-element
"Finds a filter element by tag in filter content."
[filter-content tag]
(some #(when (= tag (:tag %)) %) filter-content))
(defn- find-filter-def
[shape]
(let [filter-attr (or (dm/get-in shape [:svg-attrs :filter])
(dm/get-in shape [:svg-attrs :style :filter]))
svg-defs (dm/get-prop shape :svg-defs)]
(when (and filter-attr svg-defs)
(let [filter-ids (csvg/extract-ids filter-attr)]
(some #(get svg-defs %) filter-ids)))))
(defn- build-blur
[gaussian-blur]
(when gaussian-blur
{:id (uuid/next)
:type :layer-blur
;; For layer blur the value matches stdDeviation directly
:value (-> (dm/get-in gaussian-blur [:attrs :stdDeviation])
(d/parse-double 0))
:hidden false}))
(defn- build-drop-shadow
[filter-content drop-shadow-elements]
(let [offset-elem (find-filter-element filter-content :feOffset)]
(when (and offset-elem (seq drop-shadow-elements))
(let [blur-elem (find-filter-element drop-shadow-elements :feGaussianBlur)
dx (-> (dm/get-in offset-elem [:attrs :dx])
(d/parse-double 0))
dy (-> (dm/get-in offset-elem [:attrs :dy])
(d/parse-double 0))
blur-value (if blur-elem
(-> (dm/get-in blur-elem [:attrs :stdDeviation])
(d/parse-double 0)
(* 2))
0)]
[{:id (uuid/next)
:style :drop-shadow
:offset-x dx
:offset-y dy
:blur blur-value
:spread 0
:hidden false
;; TODO: parse feColorMatrix to extract color/opacity
:color {:color "#000000" :opacity 1}}]))))
(defn apply-svg-filters
"Derives native blur/shadow from SVG filter definitions when the shape does
not already have them. The SVG attributes are left untouched so SVG fallback
rendering keeps working the same way as gradient fills."
[shape]
(let [existing-blur (:blur shape)
existing-shadow (:shadow shape)]
(if-let [filter-def (find-filter-def shape)]
(let [content (:content filter-def)
gaussian-blur (find-filter-element content :feGaussianBlur)
drop-shadow-elements (filter #(contains? drop-shadow-tags (:tag %)) content)
blur (or existing-blur (build-blur gaussian-blur))
shadow (if (seq existing-shadow)
existing-shadow
(build-drop-shadow content drop-shadow-elements))]
(cond-> shape
blur (assoc :blur blur)
(seq shadow) (assoc :shadow shadow)))
shape)))
(defn apply-svg-derived
"Applies SVG-derived effects (fills, blur, shadows) uniformly.
- Keeps user fills if present; otherwise derives from SVG.
- Converts SVG filters into native blur/shadow when needed.
- Always returns shape with :fills (possibly []) and blur/shadow keys."
[shape]
(let [shape' (apply-svg-filters shape)
fills (or (svg-fills/resolve-shape-fills shape') [])]
(assoc shape'
:fills fills
:blur (:blur shape')
:shadow (:shadow shape'))))

View File

@@ -4,9 +4,48 @@
;;
;; Copyright (c) KALEIDOS INC
(ns app.render-wasm.wasm)
(ns app.render-wasm.wasm
(:require ["./api/shared.js" :as shared]))
(defonce internal-frame-id nil)
(defonce internal-module #js {})
(defonce serializers #js {})
(defonce gl-context-handle nil)
(defonce gl-context nil)
(defonce serializers
#js {:blur-type shared/RawBlurType
:blend-mode shared/RawBlendMode
:bool-type shared/RawBoolType
:font-style shared/RawFontStyle
:flex-direction shared/RawFlexDirection
:grid-direction shared/RawGridDirection
:grow-type shared/RawGrowType
:align-items shared/RawAlignItems
:align-self shared/RawAlignSelf
:align-content shared/RawAlignContent
:justify-items shared/RawJustifyItems
:justify-content shared/RawJustifyContent
:justify-self shared/RawJustifySelf
:wrap-type shared/RawWrapType
:grid-track-type shared/RawGridTrackType
:shadow-style shared/RawShadowStyle
:stroke-style shared/RawStrokeStyle
:stroke-cap shared/RawStrokeCap
:shape-type shared/RawShapeType
:constraint-h shared/RawConstraintH
:constraint-v shared/RawConstraintV
:sizing shared/RawSizing
:vertical-align shared/RawVerticalAlign
:fill-data shared/RawFillData
:text-align shared/RawTextAlign
:text-direction shared/RawTextDirection
:text-decoration shared/RawTextDecoration
:text-transform shared/RawTextTransform
:segment-data shared/RawSegmentData
:stroke-linecap shared/RawStrokeLineCap
:stroke-linejoin shared/RawStrokeLineJoin
:fill-rule shared/RawFillRule})
(defonce context-initialized? false)
(defonce context-lost? (atom false))
(defonce context-lost-handler nil)
(defonce context-lost-canvas nil)

View File

@@ -48,7 +48,11 @@
(let [props (m/properties schema)
tprops (m/type-properties schema)
field (or (first in)
(:error/field props))]
(:error/field props))
field (if (vector? field)
field
[field])]
(if (contains? acc field)
acc
@@ -58,30 +62,30 @@
(or (= type :malli.core/missing-key)
(nil? value))
(assoc acc field {:message (tr "errors.field-missing")})
(assoc-in acc field {:message (tr "errors.field-missing")})
;; --- CHECK on schema props
(contains? props :error/fn)
(assoc acc field (handle-error-fn props problem))
(assoc-in acc field (handle-error-fn props problem))
(contains? props :error/message)
(assoc acc field (handle-error-message props))
(assoc-in acc field (handle-error-message props))
(contains? props :error/code)
(assoc acc field (handle-error-code props))
(assoc-in acc field (handle-error-code props))
;; --- CHECK on type props
(contains? tprops :error/fn)
(assoc acc field (handle-error-fn tprops problem))
(assoc-in acc field (handle-error-fn tprops problem))
(contains? tprops :error/message)
(assoc acc field (handle-error-message tprops))
(assoc-in acc field (handle-error-message tprops))
(contains? tprops :error/code)
(assoc acc field (handle-error-code tprops))
(assoc-in acc field (handle-error-code tprops))
:else
(assoc acc field {:message (tr "errors.invalid-data")})))))
(assoc-in acc field {:message (tr "errors.invalid-data")})))))
(defn- use-rerender-fn
[]
@@ -114,20 +118,35 @@
[f {:keys [schema validators]}]
(fn [& args]
(let [state (apply f args)
cleaned (sm/decode schema (:data state) sm/string-transformer)
cleaned (sm/decode schema (:data state) sm/json-transformer)
valid? (sm/validate schema cleaned)
errors (when-not valid?
(collect-schema-errors schema validators state))]
errors
(when-not valid?
(collect-schema-errors schema validators state))
extra-errors
(not-empty (:extra-errors state))]
(assoc state
:errors errors
:clean-data (when valid? cleaned)
:valid (and (not errors) valid?)))))
:valid (and (not errors)
(not extra-errors)
valid?)))))
(defn- make-initial-state
[initial-data]
(let [initial (if (fn? initial-data) (initial-data) initial-data)
initial (d/nilv initial {})]
{:initial initial
:data initial
:errors {}
:touched {}}))
(defn- create-form-mutator
[internal-state rerender-fn wrap-update-fn initial opts]
(mf/set-ref-val! internal-state initial)
[internal-state rerender-fn wrap-update-fn opts]
(reify
IDeref
(-deref [_]
@@ -136,7 +155,10 @@
IReset
(-reset! [_ new-value]
(if (nil? new-value)
(mf/set-ref-val! internal-state (if (fn? initial) (initial) initial))
(let [initial (-> (mf/ref-val internal-state)
(get :initial)
(make-initial-state))]
(mf/set-ref-val! internal-state initial))
(mf/set-ref-val! internal-state new-value))
(rerender-fn))
@@ -162,24 +184,25 @@
(rerender-fn)))))
(defn use-form
[& {:keys [initial] :as opts}]
[& {:keys [initial schema validators] :as opts}]
(let [rerender-fn (use-rerender-fn)
initial
(mf/with-memo [initial]
{:data (if (fn? initial) (initial) initial)
:errors {}
:touched {}})
(make-initial-state initial))
internal-state
(mf/use-ref nil)
(mf/use-ref initial)
form-mutator
(mf/with-memo [initial]
(create-form-mutator internal-state rerender-fn wrap-update-schema-fn initial opts))]
(mf/with-memo [schema validators]
(let [mutator (create-form-mutator internal-state rerender-fn wrap-update-schema-fn
(select-keys opts [:schema :validators]))]
(swap! mutator identity)
mutator))]
;; Initialize internal state once
(mf/with-layout-effect []
(mf/with-effect []
(mf/set-ref-val! internal-state initial))
(mf/with-effect [initial]
@@ -191,11 +214,16 @@
([form field value]
(on-input-change form field value false))
([form field value trim?]
(swap! form (fn [state]
(-> state
(assoc-in [:touched field] true)
(assoc-in [:data field] (if trim? (str/trim value) value))
(update :errors dissoc field))))))
(letfn [(clean-errors [errors]
(-> errors
(dissoc field)
(not-empty)))]
(swap! form (fn [state]
(-> state
(assoc-in [:touched field] true)
(assoc-in [:data field] (if trim? (str/trim value) value))
(update :errors clean-errors)
(update :extra-errors clean-errors)))))))
(defn update-input-value!
[form field value]

View File

@@ -48,6 +48,7 @@
{:label "Føroyskt mál (community)" :value "fo"}
{:label "Korean (community)" :value "ko"}
{:label "עִבְרִית (community)" :value "he"}
{:label "आधुनिक मानक हिन्दी (community)" :value "hi"}
{:label "عربي/عربى (community)" :value "ar"}
{:label "فارسی (community)" :value "fa"}
{:label "日本語 (Community)" :value "ja_jp"}

View File

@@ -187,19 +187,23 @@
style-value (normalize-style-value style-name v)]
(assoc acc style-name style-value)))) {} style-defaults)))
(def mixed-values #{:mixed :multiple "mixed" "multiple"})
(defn get-styles-from-style-declaration
"Returns a ClojureScript object compatible with text nodes"
[style-declaration]
[style-declaration & {:keys [removed-mixed] :or {removed-mixed false}}]
(reduce
(fn [acc k]
(if (contains? mapping k)
(let [style-name (get-style-name-as-css-variable k)
[_ style-decode] (get mapping k)
style-value (.getPropertyValue style-declaration style-name)]
(assoc acc k (style-decode style-value)))
(when (or (not removed-mixed) (not (contains? mixed-values style-value)))
(assoc acc k (style-decode style-value))))
(let [style-name (get-style-name k)
style-value (normalize-attr-value k (.getPropertyValue style-declaration style-name))]
(assoc acc k style-value)))) {} txt/text-style-attrs))
(when (or (not removed-mixed) (not (contains? mixed-values style-value)))
(assoc acc k style-value))))) {} txt/text-style-attrs))
(defn get-styles-from-event
"Returns a ClojureScript object compatible with text nodes"

View File

@@ -89,7 +89,7 @@
(defn init
"Return a initialized webworker instance."
[path on-error]
(let [instance (js/Worker. path #js {:type "module"})
(let [instance (js/Worker. path)
bus (rx/subject)
worker (Worker. instance (rx/to-observable bus))

View File

@@ -24,11 +24,12 @@
[beicon.v2.core :as rx]
[okulary.core :as l]
[promesa.core :as p]
[rumext.v2 :as mf]
[shadow.esm :refer (dynamic-import)]))
[rumext.v2 :as mf]))
(log/set-level! :trace)
(def ^:private ^:const thumbnail-aspect-ratio (/ 2 3))
(defn- handle-response
[{:keys [body status] :as response}]
(cond
@@ -64,6 +65,10 @@
(rx/map http/conditional-decode-transit)
(rx/mapcat handle-response))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SVG RENDERING (LEGACY RENDER)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- render-thumbnail
[{:keys [page file-id revn] :as params}]
(try
@@ -98,15 +103,13 @@
(->> (request-data-for-thumbnail file-id revn true)
(rx/map render-thumbnail)))
(def init-wasm
(delay
(let [uri (cf/resolve-static-asset "js/render_wasm.js")]
(-> (dynamic-import (str uri))
(p/then #(wasm.api/init-wasm-module %))
(p/then #(set! wasm/internal-module %))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; WASM RENDERING
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(mf/defc svg-wrapper
[{:keys [data-uri background width height]}]
(mf/defc svg-wrapper*
{::mf/private true}
[{:keys [uri background width height]}]
[:svg {:version "1.1"
:xmlns "http://www.w3.org/2000/svg"
:xmlnsXlink "http://www.w3.org/1999/xlink"
@@ -116,85 +119,97 @@
:background background}
:fill "none"
:viewBox (dm/str "0 0 " width " " height)}
[:image {:xlinkHref data-uri
[:image {:xlinkHref uri
:width width
:height height}]])
(defn blob->uri
(defn- blob->uri
[blob]
(.readAsDataURL (js/FileReaderSync.) blob))
(def thumbnail-aspect-ratio (/ 2 3))
(defn- render-canvas-blob
[canvas width height background]
(->> (.convertToBlob ^js canvas)
(p/fmap (fn [blob]
(rds/renderToStaticMarkup
(mf/element svg-wrapper*
#js {:uri (blob->uri blob)
:width width
:height height
:background background}))))))
(defn render-canvas-blob
[canvas width height background-color]
(-> (.convertToBlob canvas)
(p/then
(fn [blob]
(rds/renderToStaticMarkup
(mf/element
svg-wrapper
#js {:data-uri (blob->uri blob)
:width width
:height height
:background background-color}))))))
(defonce ^:private wasm-module
(delay
(let [module (unchecked-get js/globalThis "WasmModule")
init-fn (unchecked-get module "default")
href (cf/resolve-href "js/render-wasm.wasm")]
(->> (init-fn #js {:locateFile (constantly href)})
(p/fnly (fn [module cause]
(if cause
(js/console.error cause)
(set! wasm/internal-module module))))))))
(defn process-wasm-thumbnail
(defn- render-thumbnail-with-wasm
[{:keys [id file-id revn width] :as message}]
(->> (rx/from @init-wasm)
(->> (rx/from @wasm-module)
(rx/mapcat #(request-data-for-thumbnail file-id revn false))
(rx/mapcat
(fn [{:keys [page] :as file}]
(rx/create
(fn [subs]
(let [background-color (or (:background page) cc/canvas)
height (* width thumbnail-aspect-ratio)
canvas (js/OffscreenCanvas. width height)
init? (wasm.api/init-canvas-context canvas)]
(let [bgcolor (or (:background page) cc/canvas)
height (* width thumbnail-aspect-ratio)
canvas (js/OffscreenCanvas. width height)
init? (wasm.api/init-canvas-context canvas)]
(if init?
(let [objects (:objects page)
frame (some->> page :thumbnail-frame-id (get objects))
vbox (if frame
(-> (gsb/get-object-bounds objects frame)
(grc/fix-aspect-ratio thumbnail-aspect-ratio))
(render/calculate-dimensions objects thumbnail-aspect-ratio))
zoom (/ width (:width vbox))]
frame (some->> page :thumbnail-frame-id (get objects))
vbox (if frame
(-> (gsb/get-object-bounds objects frame)
(grc/fix-aspect-ratio thumbnail-aspect-ratio))
(render/calculate-dimensions objects thumbnail-aspect-ratio))
zoom (/ width (:width vbox))]
(wasm.api/initialize-viewport
objects zoom vbox background-color
objects zoom vbox bgcolor
(fn []
(if frame
(wasm.api/render-sync-shape (:id frame))
(wasm.api/render-sync))
(-> (render-canvas-blob canvas width height background-color)
(p/then #(rx/push! subs {:id id :data % :file-id file-id :revn revn}))
(p/catch #(rx/error! subs %))
(p/finally #(rx/end! subs))))))
(->> (render-canvas-blob canvas width height bgcolor)
(p/fnly (fn [data cause]
(if cause
(rx/error! subs cause)
(rx/push! subs
{:id id
:data data
:file-id file-id
:revn revn}))
(rx/end! subs)))))))
(rx/end! subs))
nil)))))))
(defonce thumbs-subject (rx/subject))
(defonce ^:private
thumbnails-queue
(rx/subject))
(defonce thumbs-stream
(->> thumbs-subject
(rx/mapcat process-wasm-thumbnail)
(defonce ^:private
thumbnails-stream
(->> thumbnails-queue
(rx/mapcat render-thumbnail-with-wasm)
(rx/share)))
(defmethod impl/handler :thumbnails/generate-for-file-wasm
[message _]
(rx/create
(fn [subs]
(let [id (uuid/next)
sid
(->> thumbs-stream
(rx/filter #(= id (:id %)))
(rx/subs!
#(do
(rx/push! subs %)
(rx/end! subs))))]
(rx/push! thumbs-subject (assoc message :id id))
(let [id (uuid/next)
sid (->> thumbnails-stream
(rx/filter #(= id (:id %)))
(rx/subs!
(fn [result]
(rx/push! subs result)
(rx/end! subs))))]
(rx/push! thumbnails-queue (assoc message :id id))
#(rx/dispose! sid)))))

View File

@@ -42,6 +42,37 @@
(deftest skips-when-no-svg-fill
(is (nil? (svg-fills/svg-fill->fills {:svg-attrs {:fill "none"}}))))
(def elliptical-shape
{:selrect {:x 0 :y 0 :width 200 :height 100}
:svg-attrs {:style {:fill "url(#grad-ellipse)"}}
:svg-defs {"grad-ellipse"
{:tag :radialGradient
:attrs {:id "grad-ellipse"
:gradientUnits "userSpaceOnUse"
:cx "50"
:cy "50"
:r "50"
:gradientTransform "matrix(2 0 0 1 0 0)"}
:content [{:tag :stop
:attrs {:offset "0"
:style "stop-color:#000000;stop-opacity:1"}}
{:tag :stop
:attrs {:offset "1"
:style "stop-color:#ffffff;stop-opacity:1"}}]}}})
(deftest builds-elliptical-radial-gradient-with-transform
(let [fills (svg-fills/svg-fill->fills elliptical-shape)
gradient (get-in (first fills) [:fill-color-gradient])]
(testing "ellipse from gradientTransform is preserved"
(is (= 1 (count fills)))
(is (= :radial (:type gradient)))
(is (= 0.5 (:start-x gradient)))
(is (= 0.5 (:start-y gradient)))
(is (= 0.5 (:end-x gradient)))
(is (= 1.0 (:end-y gradient)))
;; Scaling the X axis in the gradientTransform should reflect on width.
(is (= 1.0 (:width gradient))))))
(deftest resolve-shape-fills-prefers-existing-fills
(let [fills [{:fill-color "#ff00ff" :fill-opacity 0.75}]
resolved (svg-fills/resolve-shape-fills {:fills fills})]

View File

@@ -0,0 +1,49 @@
;; 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 frontend-tests.svg-filters-test
(:require
[app.render-wasm.svg-filters :as svg-filters]
[cljs.test :refer [deftest is testing]]))
(def sample-filter-shape
{:svg-attrs {:filter "url(#simple-filter)"}
:svg-defs {"simple-filter"
{:tag :filter
:content [{:tag :feOffset :attrs {:dx "2" :dy "3"}}
{:tag :feGaussianBlur :attrs {:stdDeviation "4"}}]}}})
(deftest derives-blur-and-shadow-from-svg-filter
(let [shape (svg-filters/apply-svg-filters sample-filter-shape)
blur (:blur shape)
shadow (:shadow shape)]
(testing "layer blur derived from feGaussianBlur"
(is (= :layer-blur (:type blur)))
(is (= 4.0 (:value blur))))
(testing "drop shadow derived from filter chain"
(is (= [{:style :drop-shadow
:offset-x 2.0
:offset-y 3.0
:blur 8.0
:spread 0
:hidden false
:color {:color "#000000" :opacity 1}}]
(map #(dissoc % :id) shadow))))
(testing "svg attrs remain intact"
(is (= "url(#simple-filter)" (get-in shape [:svg-attrs :filter]))))))
(deftest keeps-existing-native-filters
(let [existing {:blur {:id :existing :type :layer-blur :value 1.0}
:shadow [{:id :shadow :style :drop-shadow}]}
shape (svg-filters/apply-svg-filters (merge sample-filter-shape existing))]
(is (= (:blur existing) (:blur shape)))
(is (= (:shadow existing) (:shadow shape)))))
(deftest skips-when-no-filter-definition
(let [shape {:svg-attrs {:fill "#fff"}}
result (svg-filters/apply-svg-filters shape)]
(is (= shape result))))

View File

@@ -15,7 +15,7 @@
*/
export function addEventListeners(target, object, options) {
Object.entries(object).forEach(([type, listener]) =>
target.addEventListener(type, listener, options)
target.addEventListener(type, listener, options),
);
}
@@ -27,6 +27,6 @@ export function addEventListeners(target, object, options) {
*/
export function removeEventListeners(target, object) {
Object.entries(object).forEach(([type, listener]) =>
target.removeEventListener(type, listener)
target.removeEventListener(type, listener),
);
}

View File

@@ -664,8 +664,16 @@ export class TextEditor extends EventTarget {
* @param {boolean} allowHTMLPaste
* @returns {Root}
*/
export function createRootFromHTML(html, style = undefined, allowHTMLPaste = undefined) {
const fragment = mapContentFragmentFromHTML(html, style || undefined, allowHTMLPaste || undefined);
export function createRootFromHTML(
html,
style = undefined,
allowHTMLPaste = undefined,
) {
const fragment = mapContentFragmentFromHTML(
html,
style || undefined,
allowHTMLPaste || undefined,
);
const root = createRoot([], style);
root.replaceChildren(fragment);
resetInertElement();

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