Compare commits

..

105 Commits

Author SHA1 Message Date
Andrey Antukh
72ee0ba409 ⬆️ Update storybook and fix compatibility issues 2025-12-29 17:59:52 +01:00
Andrey Antukh
56d610ddfc 🔥 Remove npx prefix on package.json scripts 2025-12-29 14:29:25 +01:00
Andrey Antukh
7895b8579b 🔧 Add plugins runtime ci job 2025-12-29 14:23:32 +01:00
Andrey Antukh
4564a43bc4 🎉 Import penpot-plugins repository
As commit 819a549e4928d2b1fa98e52bee82d59aec0f70d8
2025-12-29 14:13:49 +01:00
Andrey Antukh
e01654ba43 Merge branch 'staging-render' into develop 2025-12-29 10:43:00 +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
Alejandro Alonso
a57011ec7b Merge remote-tracking branch 'origin/staging' into staging-render 2025-12-23 13:35:27 +01:00
Andrey Antukh
69c880d00e 🐛 Fix importmap usage on firefox 2025-12-23 13:10:58 +01:00
Andrey Antukh
9eebc467ef Preload default translations 2025-12-23 13:10:58 +01:00
Andrey Antukh
b77712ce73 Move frontend/vendor to frontend/packages 2025-12-23 13:10:58 +01:00
Andrey Antukh
3d3e81f314 Replace tubax with more modern tooling 2025-12-23 13:10:58 +01:00
Andrey Antukh
fe6441bb24 Replace hightlight.js internal bundle with direct npm use 2025-12-23 13:10:58 +01:00
Andrey Antukh
e15f0baf30 Replace direct draft-js usage with internal module 2025-12-23 13:10:58 +01:00
Andrey Antukh
c040cbb784 🔥 Remove old gulp related dependencies 2025-12-23 13:10:58 +01:00
Andrey Antukh
7f674b78a9 📎 Move all deps to dev-dependencies on frontend package.json
All they only needed for build process.
2025-12-23 13:10:58 +01:00
Andrey Antukh
099b78affd 📎 Update frontend yarn.lock file 2025-12-23 13:10:58 +01:00
Andrey Antukh
78cc3f0aa4 📎 Add immutable dependency to vendor/draft-js 2025-12-23 13:10:58 +01:00
Andrey Antukh
76f5f12808 ⬆️ Update dependencies on exporter 2025-12-23 13:10:58 +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
Andrey Antukh
047483a70a 🐛 Fix deleted files thumbnails generation 2025-12-22 20:20:43 +01:00
Alonso Torres
4000ec8762 🐛 Fix problem resizing auto size layouts (#7995) 2025-12-22 20:17:11 +01:00
Andrey Antukh
8cb2f27de8 ♻️ Move file permissions to binfile common ns 2025-12-22 20:16:41 +01:00
Andrey Antukh
0433336fc9 📎 Use correct criterium version on frontend deps 2025-12-22 20:16:41 +01:00
Andrey Antukh
ce234fbeda Allow get thumbnails for deleted files 2025-12-22 20:16:41 +01:00
Andrey Antukh
fc4d31eed7 Add minor efficiency improvements to deleted dashboard page 2025-12-22 20:16:41 +01:00
María Valderrama
c670aac339 🎉 Added deleted files to dashboard 2025-12-22 20:16:41 +01:00
Andrés Moya
1d3fb5434f Enable shadow tokens by default (#7996) 2025-12-22 18:17:29 +01:00
Andrey Antukh
f478399ae0 Merge remote-tracking branch 'origin/staging-render' into develop 2025-12-22 17:28:18 +01:00
Andrey Antukh
6a1854f180 Merge remote-tracking branch 'origin/staging' into staging-render 2025-12-22 17:28:01 +01:00
Andrés Moya
0858e297e5 🎉 Add composite tokens to plugins API (#7992) 2025-12-22 17:14:54 +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
Anonymous
105e1fe86c 🌐 Add translations for: Spanish
Currently translated at 97.1% (1940 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/es/
2025-12-22 16:34:43 +01:00
Yaron Shahrabani
3e0a916883 🌐 Add translations for: Hebrew
Currently translated at 99.3% (1985 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/he/
2025-12-22 16:34:42 +01:00
Ahmad HosseinBor
4f80238bc2 🌐 Add translations for: Persian
Currently translated at 39.2% (783 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fa/
2025-12-22 16:34:42 +01:00
Alejandro Alonso
5156cc5d9a 🌐 Add translations for: Yoruba
Currently translated at 58.8% (1176 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/yo/
2025-12-22 16:34:42 +01:00
Yessenia Villarte Vaca
42c46b6cfc 🌐 Add translations for: Spanish (Latin America)
Currently translated at 6.5% (131 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/es_419/
2025-12-22 16:34:41 +01:00
VKing9
8b3c40b35e 🌐 Add translations for: Hindi
Currently translated at 99.6% (1991 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/hi/
2025-12-22 16:34:41 +01:00
Andy Li
d3996e5fb1 🌐 Add translations for: Chinese (Traditional Han script)
Currently translated at 80.1% (1601 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/zh_Hant/
2025-12-22 16:34:40 +01:00
Anonymous
0c42bca866 🌐 Add translations for: German
Currently translated at 95.5% (1908 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/de/
2025-12-22 16:34:40 +01:00
Marius
e5685c1f1c 🌐 Add translations for: German
Currently translated at 95.5% (1908 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/de/
2025-12-22 16:34:40 +01:00
Anonymous
2784209bde 🌐 Add translations for: Turkish
Currently translated at 99.5% (1989 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/tr/
2025-12-22 16:34:40 +01:00
Alejandro Alonso
024f460e99 🌐 Add translations for: Igbo
Currently translated at 25.6% (512 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ig/
2025-12-22 16:34:39 +01:00
Anonymous
1d9b76b62a 🌐 Add translations for: Romanian
Currently translated at 97.2% (1942 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ro/
2025-12-22 16:34:39 +01:00
Shuaib Zahda
7e17a75b7d 🌐 Add translations for: Arabic
Currently translated at 56.4% (1127 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ar/
2025-12-22 16:34:39 +01:00
Anonymous
ca093d6fae 🌐 Add translations for: French
Currently translated at 96.8% (1934 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fr/
2025-12-22 16:34:38 +01:00
Alexandre Pawlak
0f0b7562b5 🌐 Add translations for: French
Currently translated at 96.8% (1934 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fr/
2025-12-22 16:34:38 +01:00
Anonymous
9cdc694697 🌐 Add translations for: Chinese (Simplified Han script)
Currently translated at 90.3% (1804 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/zh_Hans/
2025-12-22 16:34:37 +01:00
Dário
b972a4033b 🌐 Add translations for: Chinese (Simplified Han script)
Currently translated at 90.3% (1804 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/zh_Hans/
2025-12-22 16:34:37 +01:00
Anonymous
cbe9f4da51 🌐 Add translations for: Swedish
Currently translated at 99.3% (1985 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/sv/
2025-12-22 16:34:37 +01:00
Anonymous
c583bde9e3 🌐 Add translations for: Russian
Currently translated at 76.8% (1534 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ru/
2025-12-22 16:34:37 +01:00
Vint Prox
3911ebdc4e 🌐 Add translations for: Russian
Currently translated at 76.8% (1534 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ru/
2025-12-22 16:34:36 +01:00
Anonymous
3e3b18667b 🌐 Add translations for: Portuguese (Brazil)
Currently translated at 69.9% (1396 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/pt_BR/
2025-12-22 16:34:36 +01:00
Anonymous
ed81c9b8df 🌐 Add translations for: Dutch
Currently translated at 99.5% (1988 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/nl/
2025-12-22 16:34:36 +01:00
Sebastiaan Pasma
fbdf98d29c 🌐 Add translations for: Dutch
Currently translated at 99.5% (1988 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/nl/
2025-12-22 16:34:36 +01:00
Anonymous
e603825a55 🌐 Add translations for: Italian
Currently translated at 99.5% (1988 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/it/
2025-12-22 16:34:35 +01:00
Valentina Chapellu
1d724783e6 🌐 Add translations for: Italian
Currently translated at 99.5% (1988 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/it/
2025-12-22 16:34:35 +01:00
Hosted Weblate
e0abe7dcb5 🌐 Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/
2025-12-22 16:33:05 +01:00
Andrey Antukh
5c1bbf5be8 Merge remote-tracking branch 'weblate/develop' into develop 2025-12-22 16:32:30 +01:00
Pablo Alba
bbb0d58190 🐛 Fix "maximum call stack size exceeded" crash on variant 2025-12-22 16:27:10 +01:00
Andrey Antukh
88dcf9d1fe 🐛 Mark rpc calls as authenticated when shared key is used (#7901) 2025-12-22 12:18:36 +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
2acf15958b Merge branch 'staging-render' into develop 2025-12-22 09:24:04 +01:00
Andrey Antukh
08267de242 Merge remote-tracking branch 'origin/staging' into staging-render 2025-12-22 09:23:48 +01:00
Pablo Alba
35fb376a78 Add proxypass to caddyfile on devenv (#7985) 2025-12-22 09:21:22 +01:00
Dalai Felinto
13fcf3a9bb 💄 Set import Tokens default option to be Single JSON value (#7918)
This patches makes the default Tokens importing option to match the
current default Tokens exporting option (single JSON value). This way it
is more obvious and quick to export the tokens from a file and import
in new one,

---

While testing our design system we are often re-exporting and
re-importing the Tokens to the files using the design system components.

I'm aware that this may be addressed in the future so the Tokens are
brought in together with the library. Meanwhile (and even in the future)
I think it is sensible to have a symmetry between the export and import
defeault options.

Co-authored-by: Dalai Felinto <dalai@blender.org>
2025-12-19 10:44:05 +01:00
Andrey Antukh
83bb4bf221 🐛 Prefill storage object bucket if it comes nil on import binfile 2025-12-19 09:32:51 +01:00
Henrik Steffens
dba6ae2820 🌐 Add translations for: German
Currently translated at 95.8% (1911 of 1994 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/de/
2025-12-17 13:00:25 +01:00
Marius
ada101c236 🌐 Add translations for: German
Currently translated at 95.8% (1911 of 1994 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/de/
2025-12-17 13:00:24 +01:00
Marius
ea48fb5825 🌐 Add translations for: German
Currently translated at 90.4% (1804 of 1994 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/de/
2025-12-16 11:00:25 +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
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
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
Stephan Paternotte
b45bdd723f 🌐 Add translations for: Dutch
Currently translated at 99.8% (1991 of 1994 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/nl/
2025-12-09 22:00:21 +00:00
Ingrid Pigueron
8696044620 🌐 Add translations for: French
Currently translated at 97.1% (1937 of 1994 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fr/
2025-12-09 22:00:19 +00:00
Ingrid Pigueron
4f3ca6422c 🌐 Add translations for: French
Currently translated at 96.8% (1931 of 1994 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fr/
2025-12-08 19:40:14 +01:00
Nicola Bortoletto
1c03457fda 🌐 Add translations for: Italian
Currently translated at 99.8% (1991 of 1994 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/it/
2025-12-06 07:00:19 +00:00
VKing9
74d4b9b045 🌐 Add translations for: Hindi
Currently translated at 100.0% (1994 of 1994 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/hi/
2025-12-04 11:00:32 +01:00
Yaron Shahrabani
60df56caa3 🌐 Add translations for: Hebrew
Currently translated at 99.6% (1988 of 1994 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/he/
2025-12-02 21:00:32 +00:00
Nicola Bortoletto
34da754357 🌐 Add translations for: Italian
Currently translated at 98.9% (1973 of 1994 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/it/
2025-11-28 05:00:28 +00:00
Oğuz Ersen
39eafae251 🌐 Add translations for: Turkish
Currently translated at 99.8% (1991 of 1994 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/tr/
2025-11-25 16:51:25 +00:00
Edgars Andersons
e1e09b7f96 🌐 Add translations for: Latvian
Currently translated at 94.0% (1876 of 1994 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/lv/
2025-11-25 16:51:24 +00:00
Stephan Paternotte
3b39980f2f 🌐 Add translations for: Dutch
Currently translated at 99.8% (1991 of 1994 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/nl/
2025-11-25 16:51:23 +00:00
Anton Palmqvist
223b12d2c7 🌐 Add translations for: Swedish
Currently translated at 99.6% (1987 of 1994 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/sv/
2025-11-25 16:51:21 +00:00
3024 changed files with 56503 additions and 1262293 deletions

View File

@@ -51,6 +51,49 @@ jobs:
run: |
./scripts/test
test-plugins:
name: Plugins Runtime Linter & Tests
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Setup Node
id: setup-node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
- name: Install deps
working-directory: ./plugins
run: npm ci
shell: bash
- name: Run Lint
working-directory: ./plugins
run: npm run lint
- name: Run Format Check
working-directory: ./plugins
run: npm run format:check
- name: Run Test
working-directory: ./plugins
run: npm run test
- name: Build runtime
working-directory: ./plugins
run: npm run build
- name: Build plugins
working-directory: ./plugins
run: npm run build:plugins
- name: Build styles
working-directory: ./plugins
run: npm run build:styles-example
test-frontend:
name: "Frontend Tests"
runs-on: ubuntu-24.04
@@ -67,6 +110,8 @@ jobs:
- name: Component Tests
working-directory: ./frontend
env:
VITEST_BROWSER_TIMEOUT: 120000
run: |
./scripts/test-components

2
.nvmrc
View File

@@ -1 +1 @@
v22.19.0
v22.21.1

View File

@@ -12,7 +12,9 @@
### :sparkles: New features & Enhancements
- Add new Box Shadow Tokens [Taiga #10201](https://tree.taiga.io/project/penpot/us/10201)
- Make i18n translation files load on-demand [Taiga #11474](https://tree.taiga.io/project/penpot/us/11474)
- Add deleted files to dashboard [Taiga #8149](https://tree.taiga.io/project/penpot/us/8149)
### :bug: Bugs fixed
@@ -83,6 +85,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
@@ -116,6 +119,7 @@ example. It's still usable as before, we just removed the example.
- 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

@@ -331,6 +331,81 @@
(set/difference cfeat/backend-only-features))
#{}))))
(defn check-file-exists
[cfg id & {:keys [include-deleted?]
:or {include-deleted? false}
:as options}]
(db/get-with-sql cfg [sql:get-minimal-file id]
{:db/remove-deleted (not include-deleted?)}))
(def ^:private sql:file-permissions
"select fpr.is_owner,
fpr.is_admin,
fpr.can_edit
from file_profile_rel as fpr
inner join file as f on (f.id = fpr.file_id)
where fpr.file_id = ?
and fpr.profile_id = ?
union all
select tpr.is_owner,
tpr.is_admin,
tpr.can_edit
from team_profile_rel as tpr
inner join project as p on (p.team_id = tpr.team_id)
inner join file as f on (p.id = f.project_id)
where f.id = ?
and tpr.profile_id = ?
union all
select ppr.is_owner,
ppr.is_admin,
ppr.can_edit
from project_profile_rel as ppr
inner join file as f on (f.project_id = ppr.project_id)
where f.id = ?
and ppr.profile_id = ?")
(defn- get-file-permissions*
[conn profile-id file-id]
(when (and profile-id file-id)
(db/exec! conn [sql:file-permissions
file-id profile-id
file-id profile-id
file-id profile-id])))
(defn get-file-permissions
([conn profile-id file-id]
(let [rows (get-file-permissions* conn profile-id file-id)
is-owner (boolean (some :is-owner rows))
is-admin (boolean (some :is-admin rows))
can-edit (boolean (some :can-edit rows))]
(when (seq rows)
{:type :membership
:is-owner is-owner
:is-admin (or is-owner is-admin)
:can-edit (or is-owner is-admin can-edit)
:can-read true
:is-logged (some? profile-id)})))
([conn profile-id file-id share-id]
(let [perms (get-file-permissions conn profile-id file-id)
ldata (some-> (db/get* conn :share-link {:id share-id :file-id file-id})
(dissoc :flags)
(update :pages db/decode-pgarray #{}))]
;; NOTE: in a future when share-link becomes more powerful and
;; will allow us specify which parts of the app is available, we
;; will probably need to tweak this function in order to expose
;; this flags to the frontend.
(cond
(some? perms) perms
(some? ldata) {:type :share-link
:can-read true
:pages (:pages ldata)
:is-logged (some? profile-id)
:who-comment (:who-comment ldata)
:who-inspect (:who-inspect ldata)}))))
(defn get-project
[cfg project-id]
(db/get cfg :project {:id project-id}))

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

@@ -30,7 +30,7 @@
(defn- get-file-media-object
[pool id]
(db/get pool :file-media-object {:id id}))
(db/get pool :file-media-object {:id id} {::db/remove-deleted false}))
(defn- serve-object-from-s3
[{:keys [::sto/storage] :as cfg} obj]

View File

@@ -309,7 +309,7 @@
(fn [request]
(let [key (yreq/get-header request "x-shared-key")]
(if (= key shared-key)
(handler request)
(handler (assoc request ::http/auth-with-shared-key true))
{::yres/status 403}))))
(fn [_ _]
{::yres/status 403})))

View File

@@ -14,6 +14,7 @@
[app.common.spec :as us]
[app.common.time :as ct]
[app.common.uri :as u]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.http :as-alias http]
@@ -92,7 +93,11 @@
(let [handler-name (:type path-params)
etag (yreq/get-header request "if-none-match")
profile-id (or (::session/profile-id request)
(::actoken/profile-id request))
(::actoken/profile-id request)
(if (::http/auth-with-shared-key request)
uuid/zero
nil))
ip-addr (inet/parse-request request)
data (-> params

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

@@ -79,85 +79,14 @@
;; --- FILE PERMISSIONS
(def ^:private sql:file-permissions
"select fpr.is_owner,
fpr.is_admin,
fpr.can_edit
from file_profile_rel as fpr
inner join file as f on (f.id = fpr.file_id)
where fpr.file_id = ?
and fpr.profile_id = ?
and f.deleted_at is null
union all
select tpr.is_owner,
tpr.is_admin,
tpr.can_edit
from team_profile_rel as tpr
inner join project as p on (p.team_id = tpr.team_id)
inner join file as f on (p.id = f.project_id)
where f.id = ?
and tpr.profile_id = ?
and f.deleted_at is null
union all
select ppr.is_owner,
ppr.is_admin,
ppr.can_edit
from project_profile_rel as ppr
inner join file as f on (f.project_id = ppr.project_id)
where f.id = ?
and ppr.profile_id = ?
and f.deleted_at is null")
(defn get-file-permissions
[conn profile-id file-id]
(when (and profile-id file-id)
(db/exec! conn [sql:file-permissions
file-id profile-id
file-id profile-id
file-id profile-id])))
(defn get-permissions
([conn profile-id file-id]
(let [rows (get-file-permissions conn profile-id file-id)
is-owner (boolean (some :is-owner rows))
is-admin (boolean (some :is-admin rows))
can-edit (boolean (some :can-edit rows))]
(when (seq rows)
{:type :membership
:is-owner is-owner
:is-admin (or is-owner is-admin)
:can-edit (or is-owner is-admin can-edit)
:can-read true
:is-logged (some? profile-id)})))
([conn profile-id file-id share-id]
(let [perms (get-permissions conn profile-id file-id)
ldata (some-> (db/get* conn :share-link {:id share-id :file-id file-id})
(dissoc :flags)
(update :pages db/decode-pgarray #{}))]
;; NOTE: in a future when share-link becomes more powerful and
;; will allow us specify which parts of the app is available, we
;; will probably need to tweak this function in order to expose
;; this flags to the frontend.
(cond
(some? perms) perms
(some? ldata) {:type :share-link
:can-read true
:pages (:pages ldata)
:is-logged (some? profile-id)
:who-comment (:who-comment ldata)
:who-inspect (:who-inspect ldata)}))))
(def has-edit-permissions?
(perms/make-edition-predicate-fn get-permissions))
(perms/make-edition-predicate-fn bfc/get-file-permissions))
(def has-read-permissions?
(perms/make-read-predicate-fn get-permissions))
(perms/make-read-predicate-fn bfc/get-file-permissions))
(def has-comment-permissions?
(perms/make-comment-predicate-fn get-permissions))
(perms/make-comment-predicate-fn bfc/get-file-permissions))
(def check-edition-permissions!
(perms/make-check-fn has-edit-permissions?))
@@ -170,7 +99,7 @@
(defn check-comment-permissions!
[conn profile-id file-id share-id]
(let [perms (get-permissions conn profile-id file-id share-id)
(let [perms (bfc/get-file-permissions conn profile-id file-id share-id)
can-read (has-read-permissions? perms)
can-comment (has-comment-permissions? perms)]
(when-not (or can-read can-comment)
@@ -222,7 +151,7 @@
(defn- get-minimal-file-with-perms
[cfg {:keys [:id ::rpc/profile-id]}]
(let [mfile (get-minimal-file cfg id)
perms (get-permissions cfg profile-id id)]
perms (bfc/get-file-permissions cfg profile-id id)]
(assoc mfile :permissions perms)))
(defn get-file-etag
@@ -248,7 +177,7 @@
;; will be already prefetched and we just reuse them instead
;; of making an additional database queries.
(let [perms (or (:permissions (::cond/object params))
(get-permissions conn profile-id id))]
(bfc/get-file-permissions conn profile-id id))]
(check-read-permissions! perms)
(let [team (teams/get-team conn
@@ -311,7 +240,7 @@
::sm/result schema:file-fragment}
[cfg {:keys [::rpc/profile-id file-id fragment-id share-id]}]
(db/run! cfg (fn [cfg]
(let [perms (get-permissions cfg profile-id file-id share-id)]
(let [perms (bfc/get-file-permissions cfg profile-id file-id share-id)]
(check-read-permissions! perms)
(-> (get-file-fragment cfg file-id fragment-id)
(rph/with-http-cache long-cache-duration))))))
@@ -456,8 +385,7 @@
:code :params-validation
:hint "page-id is required when object-id is provided"))
(let [perms (get-permissions conn profile-id file-id share-id)
(let [perms (bfc/get-file-permissions conn profile-id file-id share-id)
file (bfc/get-file cfg file-id :read-only? true)
proj (db/get conn :project {:id (:project-id file)})
@@ -688,11 +616,10 @@
"Get libraries used by the specified file."
{::doc/added "1.17"
::sm/params schema:get-file-libraries}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id]}]
(dm/with-open [conn (db/open pool)]
(check-read-permissions! conn profile-id file-id)
(bfc/get-file-libraries conn file-id)))
[cfg {:keys [::rpc/profile-id file-id]}]
(bfc/check-file-exists cfg file-id)
(check-read-permissions! cfg profile-id file-id)
(bfc/get-file-libraries cfg file-id))
;; --- COMMAND QUERY: Files that use this File library
@@ -785,8 +712,7 @@
FROM file AS f
INNER JOIN project AS p ON (p.id = f.project_id)
LEFT JOIN file_thumbnail AS ft on (ft.file_id = f.id
AND ft.revn = f.revn
AND ft.deleted_at is null)
AND ft.revn = f.revn)
WHERE p.team_id = ?
AND (p.deleted_at > ?::timestamptz OR
f.deleted_at > ?::timestamptz)

View File

@@ -199,15 +199,13 @@
[cfg {:keys [::rpc/profile-id file-id strip-frames-with-thumbnails] :as params}]
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-read-permissions! conn profile-id file-id)
(let [team (teams/get-team conn
:profile-id profile-id
:file-id file-id)
file (bfc/get-file cfg file-id
:include-deleted? true
:realize? true
:read-only? true)
strip-frames-with-thumbnails
(or (nil? strip-frames-with-thumbnails) ;; if not present, default to true
(true? strip-frames-with-thumbnails))]
@@ -333,12 +331,16 @@
;; --- MUTATION COMMAND: create-file-thumbnail
(defn- create-file-thumbnail!
[{:keys [::db/conn ::sto/storage]} {:keys [file-id revn props media] :as params}]
(defn- create-file-thumbnail
[{:keys [::db/conn ::sto/storage] :as cfg} {:keys [file-id revn props media] :as params}]
(media/validate-media-type! media)
(media/validate-media-size! media)
(let [props (db/tjson (or props {}))
(let [file (bfc/get-file cfg file-id
:include-deleted? true
:load-data? false)
props (db/tjson (or props {}))
path (:path media)
mtype (:mtype media)
hash (sto/calculate-hash path)
@@ -367,7 +369,7 @@
(db/update! conn :file-thumbnail
{:media-id (:id media)
:deleted-at nil
:deleted-at (:deleted-at file)
:updated-at tnow
:props props}
{:file-id file-id
@@ -378,6 +380,7 @@
:revn revn
:created-at tnow
:updated-at tnow
:deleted-at (:deleted-at file)
:props props
:media-id (:id media)}))
@@ -402,6 +405,8 @@
::rtry/when rtry/conflict-exception?
::sm/params schema:create-file-thumbnail}
;; FIXME: do not run the thumbnail upload inside a transaction
[cfg {:keys [::rpc/profile-id file-id] :as params}]
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
;; TODO For now we check read permissions instead of write,
@@ -409,6 +414,6 @@
;; review this approach on the future.
(files/check-read-permissions! conn profile-id file-id)
(when-not (db/read-only? conn)
(let [media (create-file-thumbnail! cfg params)]
(let [media (create-file-thumbnail cfg params)]
{:uri (files/resolve-public-uri (:id media))
:id (:id media)})))))

View File

@@ -6,6 +6,7 @@
(ns app.rpc.commands.fonts
(:require
[app.binfile.common :as bfc]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.schema :as sm]
@@ -66,7 +67,7 @@
(uuid? file-id)
(let [file (db/get-by-id conn :file file-id {:columns [:id :project-id]})
project (db/get-by-id conn :project (:project-id file) {:columns [:id :team-id]})
perms (files/get-permissions conn profile-id file-id share-id)]
perms (bfc/get-file-permissions conn profile-id file-id share-id)]
(files/check-read-permissions! perms)
(db/query conn :team-font-variant
{:team-id (:team-id project)

View File

@@ -13,7 +13,6 @@
[app.config :as cf]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.rpc.commands.files :as files]
[app.rpc.commands.teams :as teams]
[app.rpc.cond :as-alias cond]
[app.rpc.doc :as-alias doc]
@@ -121,7 +120,7 @@
[system {:keys [::rpc/profile-id file-id share-id] :as params}]
(db/run! system
(fn [{:keys [::db/conn] :as system}]
(let [perms (files/get-permissions conn profile-id file-id share-id)
(let [perms (bfc/get-file-permissions conn profile-id file-id share-id)
params (-> params
(assoc ::perms perms)
(assoc :profile-id profile-id))]

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

@@ -29,8 +29,7 @@
java-http-clj/java-http-clj {:mvn/version "0.4.3"}
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

@@ -56,7 +56,6 @@
"text-editor/v2-html-paste"
"text-editor/v2"
"render-wasm/v1"
"graph-wasm/v1"
"variants/v1"})
;; A set of features enabled by default
@@ -80,8 +79,7 @@
"text-editor/v2-html-paste"
"text-editor/v2"
"tokens/numeric-input"
"render-wasm/v1"
"graph-wasm/v1"})
"render-wasm/v1"})
;; Features that are mainly backend only or there are a proper
;; fallback when frontend reports no support for it
@@ -130,7 +128,6 @@
:feature-text-editor-v2 "text-editor/v2"
:feature-text-editor-v2-html-paste "text-editor/v2-html-paste"
:feature-render-wasm "render-wasm/v1"
:feature-graph-wasm "graph-wasm/v1"
:feature-variants "variants/v1"
:feature-token-input "tokens/numeric-input"
nil))

View File

@@ -169,6 +169,7 @@
:enable-component-thumbnails
:enable-render-wasm-dpr
:enable-token-color
:enable-token-shadow
:enable-inspect-styles
:enable-feature-fdata-objects-map])

View File

@@ -340,7 +340,7 @@
(dfn-diff t2 t1)))
#?(:cljs
(defn set-default-locale!
(defn set-default-locale
[locale]
(when-let [locale (unchecked-get locales locale)]
(dfn-set-default-options #js {:locale locale}))))

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

@@ -59,6 +59,7 @@
:dimensions "dimension"
:font-family "fontFamilies"
:font-size "fontSizes"
:font-weight "fontWeights"
:letter-spacing "letterSpacing"
:number "number"
:opacity "opacity"
@@ -70,7 +71,6 @@
:stroke-width "borderWidth"
:text-case "textCase"
:text-decoration "textDecoration"
:font-weight "fontWeights"
:typography "typography"})
(def dtcg-token-type->token-type

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 [_]
@@ -1545,7 +1545,7 @@ Will return a value that matches this schema:
(and (not (contains? decoded-json "$metadata"))
(not (contains? decoded-json "$themes"))))
(defn- convert-dtcg-font-family
(defn convert-dtcg-font-family
"Convert font-family token value from DTCG format to internal format.
- If value is a string, split it into a collection of font families
- If value is already an array, keep it as is
@@ -1556,7 +1556,7 @@ Will return a value that matches this schema:
(sequential? value) value
:else value))
(defn- convert-dtcg-typography-composite
(defn convert-dtcg-typography-composite
"Convert typography token value keys from DTCG format to internal format."
[value]
(if (map? value)
@@ -1568,7 +1568,7 @@ Will return a value that matches this schema:
;; Reference value
value))
(defn- convert-dtcg-shadow-composite
(defn convert-dtcg-shadow-composite
"Convert shadow token value from DTCG format to internal format."
[value]
(let [process-shadow (fn [shadow]

View File

@@ -10,3 +10,7 @@ localhost:3449 {
http://localhost:3450 {
reverse_proxy localhost:4449
}
http://penpot-devenv-main:3450 {
reverse_proxy localhost:4449
}

View File

@@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC",
"private": true,
"packageManager": "yarn@4.10.3+sha512.c38cafb5c7bb273f3926d04e55e1d8c9dfa7d9c3ea1f36a4868fa028b9e5f72298f0b7f401ad5eb921749eb012eb1c3bb74bf7503df3ee43fd600d14a018266f",
"packageManager": "yarn@4.12.0+sha512.f45ab632439a67f8bc759bf32ead036a1f413287b9042726b7cc4818b7b49e14e9423ba49b18f9e06ea4941c1ad062385b1d8760a8d5091a1a31e5f6219afca8",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"
@@ -16,9 +16,9 @@
"date-fns": "^4.1.0",
"generic-pool": "^3.9.0",
"inflation": "^2.1.0",
"ioredis": "^5.8.1",
"playwright": "^1.55.1",
"raw-body": "^3.0.1",
"ioredis": "^5.8.2",
"playwright": "^1.57.0",
"raw-body": "^3.0.2",
"source-map-support": "^0.5.21",
"svgo": "penpot/svgo#v3.1",
"undici": "^7.16.0",

View File

@@ -243,7 +243,7 @@ __metadata:
languageName: node
linkType: hard
"bytes@npm:3.1.2":
"bytes@npm:~3.1.2":
version: 3.1.2
resolution: "bytes@npm:3.1.2"
checksum: 10c0/76d1c43cbd602794ad8ad2ae94095cddeb1de78c5dddaa7005c51af10b0176c69971a6d88e805a90c2b6550d76636e43c40d8427a808b8645ede885de4a0358e
@@ -442,7 +442,7 @@ __metadata:
languageName: node
linkType: hard
"depd@npm:2.0.0, depd@npm:~2.0.0":
"depd@npm:~2.0.0":
version: 2.0.0
resolution: "depd@npm:2.0.0"
checksum: 10c0/58bd06ec20e19529b06f7ad07ddab60e504d9e0faca4bd23079fac2d279c3594334d736508dc350e06e510aba5e22e4594483b3a6562ce7c17dd797f4cc4ad2c
@@ -577,9 +577,9 @@ __metadata:
date-fns: "npm:^4.1.0"
generic-pool: "npm:^3.9.0"
inflation: "npm:^2.1.0"
ioredis: "npm:^5.8.1"
playwright: "npm:^1.55.1"
raw-body: "npm:^3.0.1"
ioredis: "npm:^5.8.2"
playwright: "npm:^1.57.0"
raw-body: "npm:^3.0.2"
source-map-support: "npm:^0.5.21"
svgo: "penpot/svgo#v3.1"
undici: "npm:^7.16.0"
@@ -683,16 +683,16 @@ __metadata:
languageName: node
linkType: hard
"http-errors@npm:2.0.0":
version: 2.0.0
resolution: "http-errors@npm:2.0.0"
"http-errors@npm:~2.0.1":
version: 2.0.1
resolution: "http-errors@npm:2.0.1"
dependencies:
depd: "npm:2.0.0"
inherits: "npm:2.0.4"
setprototypeof: "npm:1.2.0"
statuses: "npm:2.0.1"
toidentifier: "npm:1.0.1"
checksum: 10c0/fc6f2715fe188d091274b5ffc8b3657bd85c63e969daa68ccb77afb05b071a4b62841acb7a21e417b5539014dff2ebf9550f0b14a9ff126f2734a7c1387f8e19
depd: "npm:~2.0.0"
inherits: "npm:~2.0.4"
setprototypeof: "npm:~1.2.0"
statuses: "npm:~2.0.2"
toidentifier: "npm:~1.0.1"
checksum: 10c0/fb38906cef4f5c83952d97661fe14dc156cb59fe54812a42cd448fa57b5c5dfcb38a40a916957737bd6b87aab257c0648d63eb5b6a9ca9f548e105b6072712d4
languageName: node
linkType: hard
@@ -716,15 +716,6 @@ __metadata:
languageName: node
linkType: hard
"iconv-lite@npm:0.7.0":
version: 0.7.0
resolution: "iconv-lite@npm:0.7.0"
dependencies:
safer-buffer: "npm:>= 2.1.2 < 3.0.0"
checksum: 10c0/2382400469071c55b6746c531eed5fa4d033e5db6690b7331fb2a5f59a30d7a9782932e92253db26df33c1cf46fa200a3fbe524a2a7c62037c762283f188ec2f
languageName: node
linkType: hard
"iconv-lite@npm:^0.6.2":
version: 0.6.3
resolution: "iconv-lite@npm:0.6.3"
@@ -734,6 +725,15 @@ __metadata:
languageName: node
linkType: hard
"iconv-lite@npm:~0.7.0":
version: 0.7.0
resolution: "iconv-lite@npm:0.7.0"
dependencies:
safer-buffer: "npm:>= 2.1.2 < 3.0.0"
checksum: 10c0/2382400469071c55b6746c531eed5fa4d033e5db6690b7331fb2a5f59a30d7a9782932e92253db26df33c1cf46fa200a3fbe524a2a7c62037c762283f188ec2f
languageName: node
linkType: hard
"ieee754@npm:^1.2.1":
version: 1.2.1
resolution: "ieee754@npm:1.2.1"
@@ -755,16 +755,16 @@ __metadata:
languageName: node
linkType: hard
"inherits@npm:2.0.4, inherits@npm:~2.0.3":
"inherits@npm:~2.0.3, inherits@npm:~2.0.4":
version: 2.0.4
resolution: "inherits@npm:2.0.4"
checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2
languageName: node
linkType: hard
"ioredis@npm:^5.8.1":
version: 5.8.1
resolution: "ioredis@npm:5.8.1"
"ioredis@npm:^5.8.2":
version: 5.8.2
resolution: "ioredis@npm:5.8.2"
dependencies:
"@ioredis/commands": "npm:1.4.0"
cluster-key-slot: "npm:^1.1.0"
@@ -775,7 +775,7 @@ __metadata:
redis-errors: "npm:^1.2.0"
redis-parser: "npm:^3.0.0"
standard-as-callback: "npm:^2.1.0"
checksum: 10c0/4ed66444017150da027bce940a24bf726994691e2a7b3aa11d52f8aeb37f258068cc171af4d9c61247acafc28eb086fa8a7c79420b8e8d2907d2f74f39584465
checksum: 10c0/305e385f811d49908899e32c2de69616cd059f909afd9e0a53e54f596b1a5835ee3449bfc6a3c49afbc5a2fd27990059e316cc78f449c94024957bd34c826d88
languageName: node
linkType: hard
@@ -1106,27 +1106,27 @@ __metadata:
languageName: node
linkType: hard
"playwright-core@npm:1.55.1":
version: 1.55.1
resolution: "playwright-core@npm:1.55.1"
"playwright-core@npm:1.57.0":
version: 1.57.0
resolution: "playwright-core@npm:1.57.0"
bin:
playwright-core: cli.js
checksum: 10c0/39837a8c1232ec27486eac8c3fcacc0b090acc64310f7f9004b06715370fc426f944e3610fe8c29f17cd3d68280ed72c75f660c02aa5b5cf0eb34bab0031308f
checksum: 10c0/798e35d83bf48419a8c73de20bb94d68be5dde68de23f95d80a0ebe401e3b83e29e3e84aea7894d67fa6c79d2d3d40cc5bcde3e166f657ce50987aaa2421b6a9
languageName: node
linkType: hard
"playwright@npm:^1.55.1":
version: 1.55.1
resolution: "playwright@npm:1.55.1"
"playwright@npm:^1.57.0":
version: 1.57.0
resolution: "playwright@npm:1.57.0"
dependencies:
fsevents: "npm:2.3.2"
playwright-core: "npm:1.55.1"
playwright-core: "npm:1.57.0"
dependenciesMeta:
fsevents:
optional: true
bin:
playwright: cli.js
checksum: 10c0/b84a97b0d764403df512f5bbb10c7343974e151a28202cc06f90883a13e8a45f4491a0597f0ae5fb03a026746cbc0d200f0f32195bfaa381aee5ca5770626771
checksum: 10c0/ab03c99a67b835bdea9059f516ad3b6e42c21025f9adaa161a4ef6bc7ca716dcba476d287140bb240d06126eb23f889a8933b8f5f1f1a56b80659d92d1358899
languageName: node
linkType: hard
@@ -1161,15 +1161,15 @@ __metadata:
languageName: node
linkType: hard
"raw-body@npm:^3.0.1":
version: 3.0.1
resolution: "raw-body@npm:3.0.1"
"raw-body@npm:^3.0.2":
version: 3.0.2
resolution: "raw-body@npm:3.0.2"
dependencies:
bytes: "npm:3.1.2"
http-errors: "npm:2.0.0"
iconv-lite: "npm:0.7.0"
unpipe: "npm:1.0.0"
checksum: 10c0/892f4fbd21ecab7e2fed0f045f7af9e16df7e8050879639d4e482784a2f4640aaaa33d916a0e98013f23acb82e09c2e3c57f84ab97104449f728d22f65a7d79a
bytes: "npm:~3.1.2"
http-errors: "npm:~2.0.1"
iconv-lite: "npm:~0.7.0"
unpipe: "npm:~1.0.0"
checksum: 10c0/d266678d08e1e7abea62c0ce5864344e980fa81c64f6b481e9842c5beaed2cdcf975f658a3ccd67ad35fc919c1f6664ccc106067801850286a6cbe101de89f29
languageName: node
linkType: hard
@@ -1270,7 +1270,7 @@ __metadata:
languageName: node
linkType: hard
"setprototypeof@npm:1.2.0":
"setprototypeof@npm:~1.2.0":
version: 1.2.0
resolution: "setprototypeof@npm:1.2.0"
checksum: 10c0/68733173026766fa0d9ecaeb07f0483f4c2dc70ca376b3b7c40b7cda909f94b0918f6c5ad5ce27a9160bdfb475efaa9d5e705a11d8eaae18f9835d20976028bc
@@ -1368,10 +1368,10 @@ __metadata:
languageName: node
linkType: hard
"statuses@npm:2.0.1":
version: 2.0.1
resolution: "statuses@npm:2.0.1"
checksum: 10c0/34378b207a1620a24804ce8b5d230fea0c279f00b18a7209646d5d47e419d1cc23e7cbf33a25a1e51ac38973dc2ac2e1e9c647a8e481ef365f77668d72becfd0
"statuses@npm:~2.0.2":
version: 2.0.2
resolution: "statuses@npm:2.0.2"
checksum: 10c0/a9947d98ad60d01f6b26727570f3bcceb6c8fa789da64fe6889908fe2e294d57503b14bf2b5af7605c2d36647259e856635cd4c49eab41667658ec9d0080ec3f
languageName: node
linkType: hard
@@ -1500,7 +1500,7 @@ __metadata:
languageName: node
linkType: hard
"toidentifier@npm:1.0.1":
"toidentifier@npm:~1.0.1":
version: 1.0.1
resolution: "toidentifier@npm:1.0.1"
checksum: 10c0/93937279934bd66cc3270016dd8d0afec14fb7c94a05c72dc57321f8bd1fa97e5bea6d1f7c89e728d077ca31ea125b78320a616a6c6cd0e6b9cb94cb864381c1
@@ -1539,7 +1539,7 @@ __metadata:
languageName: node
linkType: hard
"unpipe@npm:1.0.0":
"unpipe@npm:~1.0.0":
version: 1.0.0
resolution: "unpipe@npm:1.0.0"
checksum: 10c0/193400255bd48968e5c5383730344fbb4fa114cdedfab26e329e50dd2d81b134244bb8a72c6ac1b10ab0281a58b363d06405632c9d49ca9dfd5e90cbd7d0f32c

View File

@@ -1,3 +1,5 @@
import { defineConfig } from 'vite';
/** @type { import('@storybook/react-vite').StorybookConfig } */
const config = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
@@ -5,18 +7,38 @@ const config = {
addons: [
"@storybook/addon-themes",
"@storybook/addon-docs",
"@storybook/addon-vitest"
"@storybook/addon-vitest",
],
core: {
builder: "@storybook/builder-vite",
options: {
viteConfigPath: "../vite.config.js",
},
},
framework: {
name: "@storybook/react-vite",
options: {},
options: {
// fastRefresh: false,
}
},
docs: {},
async viteFinal(config) {
return defineConfig({
...config,
plugins: [
...(config.plugins ?? []),
{
name: 'force-full-reload-always',
apply: 'serve',
enforce: 'post',
handleHotUpdate(ctx) {
ctx.server.ws.send({
type: 'full-reload',
path: '*',
});
// returning [] tells Vite: “no modules handled”
return [];
},
}
]
});
}
};
export default config;

View File

@@ -1,6 +1,5 @@
import { withThemeByClassName } from "@storybook/addon-themes";
import Components from "@target/components";
import translations from "@public/translation.en.js";
Components.setDefaultTranslations(translations);

View File

@@ -8,6 +8,11 @@
metosin/reitit-core {:mvn/version "0.9.1"}
funcool/okulary {:mvn/version "2022.04.11-16"}
funcool/tubax
{:git/tag "v2025.11.28"
:git/sha "2d9a986"
:git/url "https://github.com/funcool/tubax.git"}
funcool/potok2
{:git/tag "v2.2"
:git/sha "0f7e15a"
@@ -45,7 +50,7 @@
{thheller/shadow-cljs {:mvn/version "3.2.2"}
com.bhauman/rebel-readline {:mvn/version "RELEASE"}
org.clojure/tools.namespace {:mvn/version "RELEASE"}
criterium/criterium {:mvn/version "RELEASE"}
criterium/criterium {:mvn/version "0.4.6"}
cider/cider-nrepl {:mvn/version "0.57.0"}}}
:shadow-cljs

View File

@@ -53,83 +53,76 @@
"watch:storybook:assets": "node ./scripts/watch-storybook.js"
},
"devDependencies": {
"@playwright/test": "1.52.0",
"@storybook/addon-docs": "10.0.4",
"@storybook/addon-themes": "10.0.4",
"@storybook/addon-vitest": "10.0.4",
"@storybook/react-vite": "10.0.4",
"@types/node": "^22.15.21",
"@vitest/browser": "3.2.4",
"@vitest/coverage-v8": "3.2.4",
"@penpot/draft-js": "portal:./packages/draft-js",
"@penpot/mousetrap": "portal:./packages/mousetrap",
"@penpot/plugins-runtime": "1.3.2",
"@penpot/svgo": "penpot/svgo#v3.2",
"@penpot/text-editor": "portal:./text-editor",
"@playwright/test": "1.57.0",
"@storybook/addon-docs": "10.1.11",
"@storybook/addon-themes": "10.1.11",
"@storybook/addon-vitest": "10.1.11",
"@storybook/react-vite": "10.1.11",
"@tokens-studio/sd-transforms": "1.2.11",
"@types/node": "^22.19.3",
"@vitest/browser": "4.0.16",
"@vitest/browser-playwright": "^4.0.16",
"@vitest/coverage-v8": "4.0.16",
"@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",
"autoprefixer": "^10.4.21",
"compression": "^1.8.1",
"concurrently": "^9.2.1",
"date-fns": "^4.1.0",
"esbuild": "^0.25.9",
"eventsource-parser": "^3.0.6",
"express": "^5.1.0",
"fancy-log": "^2.0.0",
"getopts": "^2.3.0",
"gettext-parser": "^8.0.0",
"gulp-concat": "^2.6.1",
"gulp-gzip": "^1.4.2",
"gulp-mustache": "^5.0.0",
"gulp-postcss": "^10.0.0",
"gulp-rename": "^2.0.0",
"gulp-sourcemaps": "^3.0.0",
"gulp-svg-sprite": "^2.0.3",
"jsdom": "^27.0.0",
"highlight.js": "^11.10.0",
"js-beautify": "^1.15.4",
"jsdom": "^27.4.0",
"lodash": "^4.17.21",
"lodash.debounce": "^4.0.8",
"map-stream": "0.0.7",
"marked": "^15.0.12",
"mkdirp": "^3.0.1",
"mustache": "^4.2.0",
"nodemon": "^3.1.10",
"npm-run-all": "^4.1.5",
"opentype.js": "^1.3.4",
"p-limit": "^6.2.0",
"playwright": "1.56.1",
"postcss": "^8.5.4",
"postcss-clean": "^1.2.2",
"postcss-modules": "^6.0.1",
"prettier": "3.5.3",
"pretty-time": "^1.1.0",
"prop-types": "^15.8.1",
"rimraf": "^6.0.1",
"sass": "^1.89.0",
"sass-embedded": "^1.89.0",
"storybook": "10.0.4",
"svg-sprite": "^2.0.4",
"typescript": "^5.9.2",
"vite": "^6.3.5",
"vitest": "^3.2.0",
"wasm-pack": "^0.13.1",
"watcher": "^2.3.1",
"workerpool": "^9.3.2"
},
"dependencies": {
"@penpot/draft-js": "portal:./vendor/draft-js",
"@penpot/hljs": "portal:./vendor/hljs",
"@penpot/mousetrap": "portal:./vendor/mousetrap",
"@penpot/plugins-runtime": "1.3.2",
"@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",
"compression": "^1.8.1",
"date-fns": "^4.1.0",
"eventsource-parser": "^3.0.6",
"js-beautify": "^1.15.4",
"lodash": "^4.17.21",
"lodash.debounce": "^4.0.8",
"opentype.js": "^1.3.4",
"postcss-modules": "^6.0.1",
"randomcolor": "^0.6.2",
"react": "19.1.1",
"react-dom": "19.1.1",
"react-error-boundary": "^6.0.0",
"react-virtualized": "^9.22.6",
"rimraf": "^6.0.1",
"rxjs": "8.0.0-alpha.14",
"sass": "^1.89.0",
"sass-embedded": "^1.89.0",
"sax": "^1.4.1",
"source-map-support": "^0.5.21",
"storybook": "10.1.11",
"style-dictionary": "5.0.0-rc.1",
"svg-sprite": "^2.0.4",
"tdigest": "^0.1.2",
"tinycolor2": "^1.6.0",
"typescript": "^5.9.2",
"ua-parser-js": "2.0.5",
"vite": "^7.3.0",
"vitest": "^4.0.16",
"wait-on": "^9.0.3",
"wasm-pack": "^0.13.1",
"watcher": "^2.3.1",
"workerpool": "^9.3.2",
"xregexp": "^5.1.2"
}
}

View File

@@ -16,7 +16,9 @@ export const {
RichTextEditorUtil,
SelectionState,
convertFromRaw,
convertToRaw
convertToRaw,
EditorBlock,
Editor
} = pkg;
import DraftPasteProcessor from 'draft-js/lib/DraftPasteProcessor.js';

View File

@@ -8,7 +8,8 @@
"author": "Andrey Antukh",
"license": "MPL-2.0",
"dependencies": {
"draft-js": "penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0"
"draft-js": "penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0",
"immutable": "^5.1.4"
},
"peerDependencies": {
"react": ">=0.17.0",

View File

@@ -173,12 +173,13 @@ __metadata:
languageName: node
linkType: hard
"@penpot/draft-js-wrapper@workspace:.":
"@penpot/draft-js@workspace:.":
version: 0.0.0-use.local
resolution: "@penpot/draft-js-wrapper@workspace:."
resolution: "@penpot/draft-js@workspace:."
dependencies:
draft-js: "penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0"
esbuild: "npm:^0.24.0"
immutable: "npm:^5.1.4"
peerDependencies:
react: ">=0.17.0"
react-dom: ">=0.17.0"
@@ -320,6 +321,13 @@ __metadata:
languageName: node
linkType: hard
"immutable@npm:^5.1.4":
version: 5.1.4
resolution: "immutable@npm:5.1.4"
checksum: 10c0/f1c98382e4cde14a0b218be3b9b2f8441888da8df3b8c064aa756071da55fbed6ad696e5959982508456332419be9fdeaf29b2e58d0eadc45483cc16963c0446
languageName: node
linkType: hard
"immutable@npm:~3.7.4":
version: 3.7.6
resolution: "immutable@npm:3.7.6"

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1,47 @@
[
{
"~:id": "~uc7ce0794-0992-8105-8004-38e630f41234",
"~:revn": 1,
"~:vern": 1,
"~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b",
"~:created-at": "~m1705307400000",
"~:modified-at": "~m1732111500000",
"~:deleted-at": "~m1732111500000",
"~:name": "Deleted Design File 1",
"~:is-shared": false,
"~:will-be-deleted-at": "~m1732716300000",
"~:thumbnail-id": null,
"~:row-num": 1,
"~:team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d"
},
{
"~:id": "~uc7ce0794-0992-8105-8004-38e630f41235",
"~:revn": 2,
"~:vern": 2,
"~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b",
"~:created-at": "~m1704875700000",
"~:modified-at": "~m1732025400000",
"~:deleted-at": "~m1732025400000",
"~:name": "Deleted Design File 2",
"~:is-shared": true,
"~:will-be-deleted-at": "~m1732630200000",
"~:thumbnail-id": null,
"~:row-num": 2,
"~:team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d"
},
{
"~:id": "~uc7ce0794-0992-8105-8004-38e630f41236",
"~:revn": 3,
"~:vern": 3,
"~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920c",
"~:created-at": "~m1706792400000",
"~:modified-at": "~m1731939600000",
"~:deleted-at": "~m1731939600000",
"~:name": "Old Project Design",
"~:is-shared": false,
"~:will-be-deleted-at": "~m1732544400000",
"~:thumbnail-id": null,
"~:row-num": 3,
"~:team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d"
}
]

View File

@@ -106,6 +106,13 @@ export class DashboardPage extends BaseWebSocketPage {
);
}
async setupDeletedFiles() {
await this.mockRPC(
"get-team-deleted-files?team-id=*",
"dashboard/get-team-deleted-files.json",
);
}
async setupDrafts() {
await this.mockRPC(
"get-project-files?project-id=*",
@@ -160,6 +167,10 @@ export class DashboardPage extends BaseWebSocketPage {
});
await this.mockRPC("search-files", "dashboard/search-files.json");
await this.mockRPC("get-teams", "logged-in-user/get-teams-complete.json");
await this.mockRPC(
"get-team-deleted-files?team-id=*",
"dashboard/get-team-deleted-files.json",
);
}
async setupAccessTokensEmpty() {
@@ -289,6 +300,13 @@ export class DashboardPage extends BaseWebSocketPage {
await expect(this.mainHeading).toHaveText("Libraries");
}
async goToDeleted() {
await this.page.goto(
`#/dashboard/deleted?team-id=${DashboardPage.anyTeamId}`,
);
await expect(this.mainHeading).toHaveText("Projects");
}
async openProfileMenu() {
await this.userAccount.click();
}

View File

@@ -0,0 +1,31 @@
import { test, expect } from "@playwright/test";
import DashboardPage from "../pages/DashboardPage";
test.beforeEach(async ({ page }) => {
await DashboardPage.init(page);
await DashboardPage.mockRPC(
page,
"get-profile",
"logged-in-user/get-profile-logged-in-no-onboarding.json",
);
});
test.describe("Dashboard Deleted Page", () => {
test("User can navigate to deleted page", async ({ page }) => {
const dashboardPage = new DashboardPage(page);
// Setup mock for deleted files API
await dashboardPage.setupDeletedFiles();
// Navigate directly to deleted page
await dashboardPage.goToDeleted();
// Check for the restore all and clear trash buttons
await expect(
page.getByRole("button", { name: "Restore All" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Clear trash" }),
).toBeVisible();
});
});

View File

@@ -24,6 +24,8 @@
<link rel="icon" href="images/favicon.png" />
<script type="importmap">{{& manifest.importmap }}</script>
<script type="module">
globalThis.penpotVersion = "{{& version}}";
globalThis.penpotBuildDate = "{{& build_date}}";
@@ -33,7 +35,6 @@
{{# manifest}}
<script src="{{& config}}"></script>
<script src="{{& polyfills}}"></script>
<script type="importmap">{{& importmap }}</script>
{{/manifest}}
<!--cookie-consent-->
@@ -49,7 +50,9 @@
<script type="module" src="{{& libs}}"></script>
<script type="module">
import { init } from "{{& app_main}}";
init();
import defaultTranslations from "{{& default_translations}}";
init({defaultTranslations});
</script>
{{/manifest}}
</body>

View File

@@ -1,23 +0,0 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
</head>
<body>
<script type="module">
import initWasmModule from '/js/graph-wasm.js';
let Module = null;
function init(moduleInstance) {
Module = moduleInstance;
}
console.log("Loading module");
initWasmModule().then(Module => {
init(Module);
Module._hello();
});
</script>
</body>
</html>

View File

@@ -187,7 +187,7 @@ async function readManifestFile(resource) {
return JSON.parse(content);
}
async function readShadowManifest() {
async function generateManifest() {
const index = {
app_main: "./js/main.js",
render_main: "./js/render.js",
@@ -197,6 +197,7 @@ async function readShadowManifest() {
polyfills: "./js/polyfills.js?version=" + CURRENT_VERSION,
libs: "./js/libs.js?version=" + CURRENT_VERSION,
worker_main: "./js/worker/main.js?version=" + CURRENT_VERSION,
default_translations: "./js/translation.en.js?version=" + CURRENT_VERSION,
importmap: JSON.stringify({
"imports": {
@@ -276,6 +277,7 @@ export async function compileTranslations() {
"id",
"ru",
"tr",
"hi",
"zh_CN",
"zh_Hant",
"hr",
@@ -391,7 +393,7 @@ async function generateTemplates() {
const isDebug = process.env.NODE_ENV !== "production";
await fs.mkdir("./resources/public/", { recursive: true });
const manifest = await readShadowManifest();
const manifest = await generateManifest();
let content;
const iconsSprite = await fs.readFile(

View File

@@ -7,7 +7,4 @@ yarn install;
yarn run playwright install chromium --with-deps;
yarn run build:storybook
exec npx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \
"npx http-server storybook-static --port 6006 --silent" \
"npx wait-on tcp:6006 && yarn test:storybook"
yarn run test:storybook

View File

@@ -92,7 +92,7 @@
{:main
{:entries [app.worker]
:web-worker true
:prepend-js "importScripts('./render.js', './graph-wasm-worker.js');"
:prepend-js "importScripts('./render.js');"
:depends-on #{}}}
:js-options
@@ -121,24 +121,22 @@
:storybook
{:target :esm
:output-dir "target/storybook/"
:devtools {:enabled false}
:devtools {:enabled false
:console-support false}
:js-options
{:js-provider :import
:entry-keys ["module" "browser" "main"]
:export-conditions ["module" "import", "browser" "require" "default"]}
:modules
{:base
{:entries []}
:components
{:components
{:exports {default app.main.ui.ds/default
helpers app.main.ui.ds.helpers/default}
:prepend-js ";(globalThis.goog.provide = globalThis.goog.constructNamespace_);(globalThis.goog.require = globalThis.goog.module.get);"
:depends-on #{:base}}}
:depends-on #{}}}
:compiler-options
{:output-feature-set :es2020
{:output-feature-set :es-next
:output-wrapper false
:warnings {:fn-deprecated false}}}

View File

@@ -1,12 +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.graph-wasm
"A WASM based render API"
(:require
[app.graph-wasm.api :as wasm.api]))
(def module wasm.api/module)

View File

@@ -1,91 +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.graph-wasm.api
(:require
[app.common.data.macros :as dm]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.graph-wasm.wasm :as wasm]
[app.render-wasm.helpers :as h]
[app.render-wasm.serializers :as sr]
[app.util.modules :as mod]
[promesa.core :as p]))
(defn hello []
(h/call wasm/internal-module "_hello"))
(defn init []
(h/call wasm/internal-module "_init"))
(defn use-shape
[id]
(let [buffer (uuid/get-u32 id)]
(println "use-shape" id)
(h/call wasm/internal-module "_use_shape"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3))))
(defn set-shape-parent-id
[id]
(let [buffer (uuid/get-u32 id)]
(h/call wasm/internal-module "_set_shape_parent"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3))))
(defn set-shape-type
[type]
(h/call wasm/internal-module "_set_shape_type" (sr/translate-shape-type type)))
(defn set-shape-selrect
[selrect]
(h/call wasm/internal-module "_set_shape_selrect"
(dm/get-prop selrect :x1)
(dm/get-prop selrect :y1)
(dm/get-prop selrect :x2)
(dm/get-prop selrect :y2)))
(defn set-object
[shape]
(let [id (dm/get-prop shape :id)
type (dm/get-prop shape :type)
parent-id (get shape :parent-id)
selrect (get shape :selrect)
children (get shape :shapes)]
(use-shape id)
(set-shape-type type)
(set-shape-parent-id parent-id)
(set-shape-selrect selrect)))
(defn set-objects
[objects]
(doseq [shape (vals objects)]
(set-object shape)))
(defn init-wasm-module
[module]
(let [default-fn (unchecked-get module "default")
href (cf/resolve-href "js/graph-wasm.wasm")]
(default-fn #js {:locateFile (constantly href)})))
(defonce module
(delay
(if (exists? js/dynamicImport)
(let [uri (cf/resolve-href "js/graph-wasm.js")]
(->> (mod/import uri)
(p/mcat init-wasm-module)
(p/fmap (fn [default]
(set! wasm/internal-module default)
true))
(p/merr
(fn [cause]
(js/console.error cause)
(p/resolved false)))))
(p/resolved false))))

View File

@@ -1,9 +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.graph-wasm.wasm)
(defonce internal-module #js {})

View File

@@ -90,7 +90,10 @@
(rx/map #(ws/initialize)))))))
(defn ^:export init
[]
[options]
(some-> (unchecked-get options "defaultTranslations")
(i18n/set-default-translations))
(mw/init!)
(i18n/init)
(cur/init-styles)

View File

@@ -386,3 +386,21 @@
(rx/of ::dps/force-persist
(rt/nav :viewer params options))))))
(defn go-to-dashboard-deleted
[& {:keys [team-id] :as options}]
(ptk/reify ::go-to-dashboard-deleted
ptk/WatchEvent
(watch [_ state _]
(let [profile (get state :profile)
team-id (cond
(= :default team-id)
(:default-team-id profile)
(uuid? team-id)
team-id
:else
(:current-team-id state))
params {:team-id team-id}]
(rx/of (modal/hide)
(rt/nav :dashboard-deleted params options))))))

View File

@@ -21,6 +21,7 @@
[app.main.data.modal :as modal]
[app.main.data.websocket :as dws]
[app.main.repo :as rp]
[app.main.store :as st]
[app.util.i18n :as i18n :refer [tr]]
[app.util.sse :as sse]
[beicon.v2.core :as rx]
@@ -76,7 +77,8 @@
ptk/UpdateEvent
(update [_ state]
(reduce (fn [state {:keys [id] :as project}]
(update-in state [:projects id] merge project))
;; Replace completely instead of merge to ensure deleted-at is removed
(assoc-in state [:projects id] project))
state
projects))))
@@ -152,6 +154,34 @@
(->> (rp/cmd! :get-builtin-templates)
(rx/map builtin-templates-fetched)))))
;; --- EVENT: deleted-files
(defn- deleted-files-fetched
[files]
(ptk/reify ::deleted-files-fetched
ptk/UpdateEvent
(update [_ state]
(let [now (ct/now)
filtered-files (filterv (fn [file]
(let [will-be-deleted-at (:will-be-deleted-at file)]
(or (nil? will-be-deleted-at)
(ct/is-after? will-be-deleted-at now))))
files)
files (d/index-by :id filtered-files)]
(-> state
(assoc :deleted-files files)
(update :files d/merge files))))))
(defn fetch-deleted-files
([] (fetch-deleted-files nil))
([team-id]
(ptk/reify ::fetch-deleted-files
ptk/WatchEvent
(watch [_ state _]
(when-let [team-id (or team-id (:current-team-id state))]
(->> (rp/cmd! :get-team-deleted-files {:team-id team-id})
(rx/map deleted-files-fetched)))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Data Selection
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -460,6 +490,7 @@
(-> state
(d/update-in-when [:files file-id] assoc :thumbnail-id thumbnail-id)
(d/update-in-when [:recent-files file-id] assoc :thumbnail-id thumbnail-id)
(d/update-in-when [:deleted-files file-id] assoc :thumbnail-id thumbnail-id)
(d/update-when :dashboard-search-result update-search-files))))))
;; --- EVENT: create-file
@@ -656,3 +687,156 @@
:team-role-change (handle-change-team-role msg)
:team-membership-change (dcm/team-membership-change msg)
nil))
;; --- Delete files immediately
(defn delete-files-immediately
[{:keys [team-id ids] :as params}]
(assert (uuid? team-id))
(assert (set? ids))
(assert (every? uuid? ids))
(ptk/reify ::delete-files-immediately
ev/Event
(-data [_]
{:team-id team-id
:num-files (count ids)})
ptk/WatchEvent
(watch [_ _ _]
(let [{:keys [on-success on-error]
:or {on-success identity
on-error rx/throw}} (meta params)]
(->> (rp/cmd! :permanently-delete-team-files {:team-id team-id :ids ids})
(rx/tap on-success)
(rx/catch on-error))))))
;; --- Restore deleted files immediately
(defn- initialize-restore-status
[files]
(ptk/reify ::init-restore-status
ptk/UpdateEvent
(update [_ state]
(let [restore-state {:in-progress true
:healthy? true
:error false
:progress 0
:widget-visible true
:detail-visible true
:files files
:last-update (ct/now)
:cmd :restore-files}]
(assoc state :restore restore-state)))))
(defn- update-restore-status
[{:keys [index total] :as data}]
(ptk/reify ::upd-restore-status
ptk/UpdateEvent
(update [_ state]
(let [time-diff (ct/diff-ms (get-in state [:restore :last-update]) (ct/now))
healthy? (< time-diff 6000)]
(update state :restore assoc
:progress index
:total total
:last-update (ct/now)
:healthy? healthy?)))))
(defn- complete-restore-status
[]
(ptk/reify ::comp-restore-status
ptk/UpdateEvent
(update [_ state]
(let [total (get-in state [:restore :total])]
(update state :restore assoc
:in-progress false
:progress total ; Ensure progress equals total on completion
:last-update (ct/now))))))
(defn- error-restore-status
[error]
(ptk/reify ::err-restore-status
ptk/UpdateEvent
(update [_ state]
(update state :restore assoc
:in-progress false
:error error
:last-update (ct/now)
:healthy? false))))
(defn toggle-restore-detail-visibility
[]
(ptk/reify ::toggle-restore-detail
ptk/UpdateEvent
(update [_ state]
(update-in state [:restore :detail-visible] not))))
(defn retry-last-restore
[]
(ptk/reify ::retry-restore
ptk/UpdateEvent
(update [_ state]
;; Reset restore state for retry - actual retry will be handled by UI
(if (get state :restore)
(update state :restore assoc :error false :in-progress false)
state))))
(defn clear-restore-state
[]
(ptk/reify ::clear-restore
ptk/UpdateEvent
(update [_ state]
(dissoc state :restore))))
(defn- projects-restored
[team-id]
(ptk/reify ::projects-restored
ptk/WatchEvent
(watch [_ _ _]
;; Refetch projects to get the updated state without deleted-at
(rx/of (fetch-projects team-id)))))
(defn restore-files-immediately
[{:keys [team-id ids] :as params}]
(dm/assert! (uuid? team-id))
(dm/assert! (set? ids))
(dm/assert! (every? uuid? ids))
(ptk/reify ::restore-files-immediately
ev/Event
(-data [_]
{:team-id team-id
:num-files (count ids)})
ptk/WatchEvent
(watch [_ _ _]
(let [{:keys [on-success on-error]
:or {on-success identity
on-error rx/throw}} (meta params)
files (mapv #(hash-map :id %) ids)]
(rx/merge
(rx/of (initialize-restore-status files))
(->> (rp/cmd! ::sse/restore-deleted-team-files {:team-id team-id :ids ids})
(rx/tap (fn [event]
(let [payload (sse/get-payload event)
type (sse/get-type event)]
(when (and payload (= type "progress"))
(let [{:keys [index total]} payload]
(when (and index total)
;; Dispatch progress update
(st/emit! (update-restore-status {:index index :total total}))))))))
(rx/filter sse/end-of-stream?)
(rx/map sse/get-payload)
(rx/tap on-success)
(rx/mapcat (fn [_]
(rx/of (complete-restore-status)
(projects-restored team-id))))
(rx/catch (fn [error]
(rx/concat
(rx/of (error-restore-status (ex-message error)))
(on-error error)))))
(rx/of (ptk/data-event ::restore-start {:total (count ids)})))))))

View File

@@ -98,9 +98,7 @@
(def context
(atom (d/without-nils (collect-context))))
(add-watch i18n/state "events"
(fn [_ _ _ v]
(swap! context assoc :locale (get v :locale))))
(add-watch i18n/locale "events" #(swap! context assoc :locale %4))
;; --- EVENT TRANSLATION

View File

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

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

@@ -1,288 +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
;;
;; High level helpers to turn a shape subtree into a component and
;; replace equivalent subtrees by instances of that component.
(ns app.main.data.workspace.componentize
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.changes-builder :as pcb]
[app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt]
[app.common.logic.libraries :as cll]
[app.common.logic.shapes :as cls]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
[app.main.data.changes :as dch]
[app.main.data.helpers :as dsh]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.undo :as dwu]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
;; NOTE: We keep this separate from `workspace.libraries` to avoid
;; introducing more complexity in that already big namespace.
(def ^:private instance-structural-keys
"Keys we do NOT want to copy from the original shape when creating a
new component instance. These are identity / structural / component
metadata keys that must be managed by the component system itself."
#{:id
:parent-id
:frame-id
:shapes
;; Component metadata
:component-id
:component-file
:component-root
:main-instance
:remote-synced
:shape-ref
:touched})
(def ^:private instance-geometry-keys
"Geometry-related keys that we *do* want to override per instance when
copying props from an existing subtree to a component instance."
#{:x
:y
:width
:height
:rotation
:flip-x
:flip-y
:selrect
:points
:proportion
:proportion-lock
:transform
:transform-inverse})
(defn- instantiate-similar-subtrees
"Internal helper. Given an atom `id-ref` that will contain the
`component-id`, replace each subtree rooted at the ids in
`similar-ids` by an instance of that component.
The operation is performed in a single undo transaction:
- Instantiate the component once per similar id, roughly at the same
top-left position as the original root.
- Delete the original subtrees.
- Select the main instance plus all the new instances."
[id-ref root-id similar-ids]
(ptk/reify ::instantiate-similar-subtrees
ptk/WatchEvent
(watch [it state _]
(let [component-id @id-ref
similar-ids (vec (or similar-ids []))]
(if (or (uuid/zero? component-id)
(empty? similar-ids))
(rx/empty)
(let [file-id (:current-file-id state)
page (dsh/lookup-page state)
page-id (:id page)
objects (:objects page)
libraries (dsh/lookup-libraries state)
fdata (dsh/lookup-file-data state file-id)
;; Reference subtree: shapes used to build the component.
;; We'll compute per-shape deltas against this subtree so
;; that we only override attributes that actually differ.
ref-subtree-ids (cfh/get-children-ids objects root-id)
ref-all-ids (into [root-id] ref-subtree-ids)
undo-id (js/Symbol)
;; 1) Instantiate component at each similar root position,
;; preserving per-instance overrides (geometry, style, etc.)
[changes new-root-ids]
(reduce
(fn [[changes acc] sid]
(if-let [shape (get objects sid)]
(let [position (gpt/point (:x shape) (:y shape))
;; Remember original parent and index so we can keep
;; the same ordering among the parent's children.
orig-root (get objects sid)
orig-parent-id (:parent-id orig-root)
orig-index (when orig-parent-id
(cfh/get-position-on-parent objects sid))
;; Instantiate a new component instance at the same position
[new-shape changes']
(cll/generate-instantiate-component
(or changes
(-> (pcb/empty-changes it page-id)
(pcb/with-objects objects)))
objects
file-id
component-id
position
page
libraries)
;; Build a structural mapping between the original subtree
;; (rooted at `sid`) and the new instance subtree.
;; NOTE 1: instantiating a component can introduce an extra
;; wrapper frame, so we try to align the original root
;; with the "equivalent" root inside the instance.
;; NOTE 2: by default the instance may be created *inside*
;; the original shape (because of layout / hit-testing).
;; We explicitly move the new instance to the same parent
;; and index as the original root, so that later deletes of
;; the original subtree don't remove the new instances and
;; the ordering among siblings is preserved.
changes' (cond-> changes'
(some? orig-parent-id)
(pcb/change-parent orig-parent-id [new-shape] orig-index
{:allow-altering-copies true
:ignore-touched true}))
objects' (pcb/get-objects changes')
orig-root (get objects sid)
new-root new-shape
orig-type (:type orig-root)
new-type (:type new-root)
;; Full original subtree (root + descendants)
orig-subtree-ids (cfh/get-children-ids objects sid)
orig-all-ids (into [sid] orig-subtree-ids)
;; Try to find an inner instance root matching the original type
;; when the outer instance root type differs (e.g. rect -> frame+rect).
direct-new-children (cfh/get-children-ids objects' (:id new-root))
candidate-instance-root
(when (and orig-type (not= orig-type new-type))
(let [cands (->> direct-new-children
(filter (fn [nid]
(when-let [s (get objects' nid)]
(= (:type s) orig-type)))))]
(when (= 1 (count cands))
(first cands))))
instance-root-id (or candidate-instance-root (:id new-root))
instance-root (get objects' instance-root-id)
new-subtree-ids (cfh/get-children-ids objects' instance-root-id)
new-all-ids (into [instance-root-id] new-subtree-ids)
id-pairs (map vector orig-all-ids new-all-ids)
changes''
;; Compute per-shape deltas against the reference
;; subtree (root-id) and apply only the differences
;; to the new instance subtree, so we don't blindly
;; overwrite attributes that are the same.
(reduce
(fn [ch [idx orig-id new-id]]
(let [ref-id (nth ref-all-ids idx nil)
ref-shape (get objects ref-id)
orig-shape (get objects orig-id)]
(if (and ref-shape orig-shape)
(let [;; Style / layout / text props (see `extract-props`)
ref-style (cts/extract-props ref-shape)
orig-style (cts/extract-props orig-shape)
style-delta (reduce (fn [m k]
(let [rv (get ref-style k ::none)
ov (get orig-style k ::none)]
(if (= rv ov)
m
(assoc m k ov))))
{}
(keys orig-style))
;; Geometry props
ref-geom (select-keys ref-shape instance-geometry-keys)
orig-geom (select-keys orig-shape instance-geometry-keys)
geom-delta (reduce (fn [m k]
(let [rv (get ref-geom k ::none)
ov (get orig-geom k ::none)]
(if (= rv ov)
m
(assoc m k ov))))
{}
(keys orig-geom))
;; Text content: if the subtree reference and the
;; original differ in `:content`, treat the whole
;; content tree as an override for this instance.
content-delta? (not= (:content ref-shape) (:content orig-shape))]
(-> ch
;; First patch style/text/layout props using the
;; canonical helpers so we don't touch structural ids.
(cond-> (seq style-delta)
(pcb/update-shapes
[new-id]
(fn [s objs] (cts/patch-props s style-delta objs))
{:with-objects? true}))
;; Then patch geometry directly on the instance.
(cond-> (seq geom-delta)
(pcb/update-shapes
[new-id]
(d/patch-object geom-delta)))
;; Finally, if text content differs between the
;; reference subtree and the similar subtree,
;; override the instance content with the original.
(cond-> content-delta?
(pcb/update-shapes
[new-id]
#(assoc % :content (:content orig-shape))))))
ch)))
changes'
(map-indexed (fn [idx [orig-id new-id]]
[idx orig-id new-id])
id-pairs))]
[changes'' (conj acc (:id new-shape))])
;; If the shape does not exist we just skip it
[changes acc]))
[nil []]
similar-ids)
changes (or changes
(-> (pcb/empty-changes it page-id)
(pcb/with-objects objects)))
;; 2) Delete original similar subtrees
;; NOTE: `d/ordered-set` with a single arg treats it as a single
;; element, so we must use `into` when we already have a collection.
ids-to-delete (into (d/ordered-set) similar-ids)
[all-parents changes]
(cls/generate-delete-shapes
changes
fdata
page
objects
ids-to-delete
{:allow-altering-copies true})
;; 3) Select main instance + new instances
;; Root id is kept as-is; add all new roots.
sel-ids (into (d/ordered-set) (cons root-id new-root-ids))]
(rx/of
(dwu/start-undo-transaction undo-id)
(dch/commit-changes changes)
(ptk/data-event :layout/update {:ids all-parents})
(dwu/commit-undo-transaction undo-id))))))))
(defn componentize-similar-subtrees
"Turn the subtree rooted at `root-id` into a component, then replace
the subtrees rooted at `similar-ids` with instances of that component.
This is implemented in two phases:
1) Use the existing `dwl/add-component` flow to create a component
from `root-id` (and obtain its `component-id`).
2) Using the new `component-id`, instantiate the component once per
entry in `similar-ids` and delete the old subtrees."
[root-id similar-ids]
(dm/assert!
"expected valid uuid for `root-id`"
(uuid? root-id))
(let [similar-ids (vec (or similar-ids []))]
(ptk/reify ::componentize-similar-subtrees
ptk/WatchEvent
(watch [_ _ _]
(let [id-ref (atom uuid/zero)]
(rx/concat
;; 1) Create component using the existing pipeline
(rx/of (dwl/add-component id-ref (d/ordered-set root-id)))
;; 2) Replace similar subtrees by instances of the new component
(rx/of (instantiate-similar-subtrees id-ref root-id similar-ids))))))))

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

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

@@ -636,3 +636,6 @@
(def persistence-state
(l/derived (comp :status :persistence) st/state))
(def restore
(l/derived :restore st/state))

View File

@@ -87,6 +87,9 @@
{:stream? true
:form-data? true}
::sse/restore-deleted-team-files
{:stream? true}
:export-binfile {:response-type :blob}
:retrieve-list-of-builtin-templates {:query-params :all}})

View File

@@ -224,7 +224,8 @@
:dashboard-members
:dashboard-invitations
:dashboard-webhooks
:dashboard-settings)
:dashboard-settings
:dashboard-deleted)
(let [params (get params :query)
team-id (some-> params :team-id uuid/parse*)
project-id (some-> params :project-id uuid/parse*)

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

@@ -20,6 +20,7 @@
[app.main.router :as rt]
[app.main.store :as st]
[app.main.ui.context :as ctx]
[app.main.ui.dashboard.deleted :refer [deleted-section*]]
[app.main.ui.dashboard.files :refer [files-section*]]
[app.main.ui.dashboard.fonts :refer [fonts-page* font-providers-page*]]
[app.main.ui.dashboard.import]
@@ -29,6 +30,7 @@
[app.main.ui.dashboard.sidebar :refer [sidebar*]]
[app.main.ui.dashboard.team :refer [team-settings-page* team-members-page* team-invitations-page* webhooks-page*]]
[app.main.ui.dashboard.templates :refer [templates-section*]]
[app.main.ui.exports.assets :refer [progress-widget]]
[app.main.ui.hooks :as hooks]
[app.main.ui.modal :refer [modal-container*]]
[app.main.ui.workspace.plugins]
@@ -84,6 +86,9 @@
[:div {:class (stl/css :dashboard-content)
:on-click clear-selected-fn
:ref container}
[:& progress-widget {:operation :restore}]
(case section
:dashboard-recent
(when (seq projects)
@@ -140,6 +145,11 @@
:dashboard-settings
[:> team-settings-page* {:team team :profile profile}]
:dashboard-deleted
[:> deleted-section* {:team team
:projects projects
:profile profile}]
nil)]))
(def ref:dashboard-initialized

View File

@@ -0,0 +1,331 @@
;; 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.dashboard.deleted
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.geom.point :as gpt]
[app.main.data.common :as dcm]
[app.main.data.dashboard :as dd]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.context-menu-a11y :refer [context-menu*]]
[app.main.ui.dashboard.grid :refer [grid*]]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]]
[app.main.ui.hooks :as hooks]
[app.main.ui.icons :as deprecated-icon]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[okulary.core :as l]
[rumext.v2 :as mf]))
(def ^:private menu-icon
(deprecated-icon/icon-xref :menu (stl/css :menu-icon)))
(mf/defc header*
{::mf/props :obj
::mf/private true}
[]
[:header {:class (stl/css :dashboard-header) :data-testid "dashboard-header"}
[:div#dashboard-deleted-title {:class (stl/css :dashboard-title)}
[:h1 (tr "dashboard.projects-title")]]])
(mf/defc deleted-project-menu*
[{:keys [project files team-id show on-close top left]}]
(let [top (d/nilv top 0)
left (d/nilv left 0)
file-ids
(mf/with-memo [files]
(into #{} d/xf:map-id files))
restore-fn
(fn [_]
(st/emit! (dd/restore-files-immediately
(with-meta {:team-id team-id :ids file-ids}
{:on-success #(st/emit! (ntf/success (tr "restore-modal.success-restore-immediately" (:name project)))
(dd/fetch-projects team-id)
(dd/fetch-deleted-files team-id))
:on-error #(st/emit! (ntf/error (tr "restore-modal.error-restore-project" (:name project))))}))))
on-restore-project
(fn []
(st/emit!
(modal/show {:type :confirm
:title (tr "restore-modal.restore-project.title")
:message (tr "restore-modal.restore-project.description" (:name project))
:accept-style :primary
:accept-label (tr "labels.continue")
:on-accept restore-fn})))
delete-fn
(fn [_]
(st/emit! (ntf/success (tr "delete-forever-modal.success-delete-immediately" (:name project)))
(dd/delete-files-immediately
{:team-id team-id
:ids file-ids})
(dd/fetch-projects team-id)
(dd/fetch-deleted-files team-id)))
on-delete-project
(fn []
(st/emit!
(modal/show {:type :confirm
:title (tr "delete-forever-modal.title")
:message (tr "delete-forever-modal.delete-project.description" (:name project))
:accept-label (tr "dashboard.deleted.delete-forever")
:on-accept delete-fn})))
options
[{:name (tr "dashboard.deleted.restore-project")
:id "project-restore"
:handler on-restore-project}
{:name (tr "dashboard.deleted.delete-project")
:id "project-delete"
:handler on-delete-project}]]
[:> context-menu*
{:on-close on-close
:show show
:fixed (or (not= top 0) (not= left 0))
:min-width true
:top top
:left left
:options options}]))
(mf/defc deleted-project-item*
{::mf/props :obj
::mf/private true}
[{:keys [project team files]}]
(let [project-files (filterv #(= (:project-id %) (:id project)) files)
empty? (empty? project-files)
selected-files (mf/deref refs/selected-files)
dstate (mf/deref refs/dashboard-local)
edit-id (:project-for-edit dstate)
local (mf/use-state
#(do {:menu-open false
:menu-pos nil
:edition (= (:id project) edit-id)}))
[rowref limit] (hooks/use-dynamic-grid-item-width)
on-menu-click
(mf/use-fn
(fn [event]
(dom/prevent-default event)
(let [client-position (dom/get-client-position event)
position (if (and (nil? (:y client-position)) (nil? (:x client-position)))
(let [target-element (dom/get-target event)
points (dom/get-bounding-rect target-element)
y (:top points)
x (:left points)]
(gpt/point x y))
client-position)]
(swap! local assoc
:menu-open true
:menu-pos position))))
on-menu-close
(mf/use-fn #(swap! local assoc :menu-open false))
handle-menu-click
(mf/use-callback
(mf/deps on-menu-click)
(fn [event]
(when (kbd/enter? event)
(dom/stop-propagation event)
(on-menu-click event))))]
[:article {:class (stl/css-case :dashboard-project-row true)}
[:header {:class (stl/css :project)}
[:div {:class (stl/css :project-name-wrapper)}
[:h2 {:class (stl/css :project-name)
:title (:name project)}
(:name project)]
(when (:deleted-at project)
[:div {:class (stl/css :info-wrapper)}
[:div {:class (stl/css-case :project-actions true)}
[:button {:class (stl/css :options-btn)
:on-click on-menu-click
:title (tr "dashboard.options")
:aria-label (tr "dashboard.options")
:data-testid "project-options"
:on-key-down handle-menu-click}
menu-icon]]
(when (:menu-open @local)
[:> deleted-project-menu*
{:project project
:files project-files
:team-id (:id team)
:show (:menu-open @local)
:left (+ 24 (:x (:menu-pos @local)))
:top (:y (:menu-pos @local))
:on-close on-menu-close}])])]]
[:div {:class (stl/css :grid-container) :ref rowref}
(if ^boolean empty?
[:> empty-placeholder* {:title (tr "dashboard.empty-placeholder-files-title")
:class (stl/css :placeholder-placement)
:type 1
:subtitle (tr "dashboard.empty-placeholder-files-subtitle")}]
[:> grid*
{:project project
:files project-files
:origin :deleted
:can-edit false
:can-restore true
:limit limit
:selected-files selected-files}])]]))
(def ^:private ref:deleted-files
(l/derived :deleted-files st/state))
(mf/defc deleted-section*
[{:keys [team projects]}]
(let [deleted-map
(mf/deref ref:deleted-files)
projects
(mf/with-memo [projects deleted-map]
(->> projects
(filter (fn [project]
(or (:deleted-at project)
(when deleted-map
(some #(= (:id project) (:project-id %))
(vals deleted-map))))))
(filter (fn [project]
(when deleted-map
(some #(= (:id project) (:project-id %))
(vals deleted-map)))))
(sort-by :modified-at)
(reverse)))
team-id
(get team :id)
;; Calculate deletion days based on team subscription
deletion-days
(let [subscription (get team :subscription)
sub-type (get subscription :type)
sub-status (get subscription :status)
canceled? (contains? #{"canceled" "unpaid"} sub-status)]
(cond
(and (= "unlimited" sub-type) (not canceled?)) 30
(and (= "enterprise" sub-type) (not canceled?)) 90
:else 7))
on-clear
(mf/use-fn
(mf/deps team-id deleted-map)
(fn []
(when deleted-map
(let [file-ids (into #{} (keys deleted-map))]
(when (seq file-ids)
(st/emit!
(modal/show {:type :confirm
:title (tr "delete-forever-modal.title")
:message (tr "delete-forever-modal.delete-all.description" (count file-ids))
:accept-label (tr "dashboard.deleted.delete-forever")
:on-accept #(st/emit!
(dd/delete-files-immediately
{:team-id team-id
:ids file-ids})
(dd/fetch-projects team-id)
(dd/fetch-deleted-files team-id))})))))))
restore-fn
(fn [file-ids]
(st/emit! (dd/restore-files-immediately
(with-meta {:team-id team-id :ids file-ids}
{:on-success #(st/emit! (dd/fetch-projects team-id)
(dd/fetch-deleted-files team-id))
:on-error #(st/emit! (ntf/error (tr "restore-modal.error-restore-files")))}))))
on-restore-all
(mf/use-fn
(mf/deps team-id deleted-map)
(fn []
(when deleted-map
(let [file-ids (into #{} (keys deleted-map))]
(when (seq file-ids)
(st/emit!
(modal/show {:type :confirm
:title (tr "restore-modal.restore-all.title")
:message (tr "restore-modal.restore-all.description" (count file-ids))
:accept-label (tr "labels.continue")
:accept-style :primary
:on-accept #(restore-fn file-ids)})))))))
on-recent-click
(mf/use-fn
(mf/deps team-id)
(fn []
(st/emit! (dcm/go-to-dashboard-recent :team-id team-id))))]
(mf/with-effect [team-id]
(st/emit! (dd/fetch-projects team-id)
(dd/fetch-deleted-files team-id)
(dd/clear-selected-files)))
[:*
[:> header* {:team team}]
[:section {:class (stl/css :dashboard-container :no-bg)}
[:*
[:div {:class (stl/css :no-bg)}
[:div {:class (stl/css :nav-options)}
[:> button* {:variant "ghost"
:data-testid "recent-tab"
:type "button"
:on-click on-recent-click}
(tr "dashboard.labels.recent")]
[:div {:class (stl/css :selected)
:data-testid "deleted-tab"}
(tr "dashboard.labels.deleted")]]
[:div {:class (stl/css :deleted-content)}
[:div {:class (stl/css :deleted-info)}
[:div
(tr "dashboard.deleted.info-text")
[:span {:class (stl/css :info-text-highlight)}
(tr "dashboard.deleted.info-days" deletion-days)]
(tr "dashboard.deleted.info-text2")]
[:div
(tr "dashboard.deleted.restore-text")]]
[:div {:class (stl/css :deleted-options)}
[:> button* {:variant "ghost"
:type "button"
:on-click on-restore-all}
(tr "dashboard.deleted.restore-all")]
[:> button* {:variant "destructive"
:type "button"
:icon "delete"
:on-click on-clear}
(tr "dashboard.deleted.clear")]]]
(when (seq projects)
(for [{:keys [id] :as project} projects]
(let [files (when deleted-map
(->> (vals deleted-map)
(filterv #(= id (:project-id %)))
(sort-by :modified-at #(compare %2 %1))))]
[:> deleted-project-item* {:project project
:team team
:files files
:key id}])))]]]]))

View File

@@ -0,0 +1,125 @@
// 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 "common/refactor/common-dashboard";
@use "../ds/typography.scss" as t;
@use "../ds/_borders.scss" as *;
@use "../ds/spacing.scss" as *;
@use "../ds/_sizes.scss" as *;
@use "../ds/z-index.scss" as *;
.dashboard-container {
flex: 1 0 0;
width: 100%;
margin-inline-end: var(--sp-l);
border-top: $b-1 solid var(--panel-border-color);
overflow-y: auto;
padding-block-end: var(--sp-xxxl);
}
.deleted-content {
display: flex;
gap: var(--sp-l);
justify-content: space-between;
margin-inline-start: var(--sp-l);
margin-block-start: var(--sp-xxl);
}
.deleted-info {
@include t.use-typography("body-medium");
color: var(--color-foreground-secondary);
}
.info-text-highlight {
color: var(--color-accent-primary);
}
.deleted-options {
display: flex;
gap: 5px;
flex-shrink: 0;
}
.nav-options {
display: flex;
gap: var(--sp-l);
justify-content: space-between;
border-bottom: $b-1 solid var(--panel-border-color);
padding-inline-start: var(--sp-l);
background: var(--color-background-default);
position: sticky;
top: 0;
z-index: var(--z-index-panels);
}
.selected {
@include t.use-typography("headline-small");
display: flex;
align-items: center;
justify-content: center;
color: var(--color-foreground-primary);
border: $b-1 solid transparent;
border-bottom: $b-1 solid var(--color-foreground-primary);
padding: 0 var(--sp-m);
}
.project {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: var(--sp-s);
width: 99%;
max-height: $sz-40;
padding: var(--sp-s) var(--sp-s) var(--sp-s) var(--sp-l);
margin-block-start: var(--sp-l);
border-radius: $br-4;
}
.project-name-wrapper {
display: flex;
align-items: center;
justify-content: flex-start;
width: 100%;
min-height: var(--sp-xxxl);
margin-inline-start: var(--sp-s);
}
.project-name {
@include t.use-typography("body-large");
width: fit-content;
margin-inline-end: var(--sp-m);
line-height: 0.8;
color: var(--title-foreground-color-hover);
height: var(--sp-l);
}
.project-actions {
display: flex;
opacity: var(--actions-opacity);
margin-inline-start: var(--sp-xxxl);
}
.add-file-btn,
.options-btn {
@extend .button-tertiary;
height: var(--sp-xxxl);
width: var(--sp-xxxl);
margin: 0 var(--sp-s);
padding: var(--sp-s);
}
.info-wrapper {
display: flex;
align-items: center;
gap: var(--sp-s);
}
.add-icon,
.menu-icon {
@extend .button-icon;
stroke: var(--icon-foreground);
}

View File

@@ -55,7 +55,7 @@
projects))
(mf/defc file-menu*
[{:keys [files on-edit on-close top left navigate origin parent-id can-edit]}]
[{:keys [files on-edit on-close top left navigate origin parent-id can-edit can-restore]}]
(assert (seq files) "missing `files` prop")
(assert (fn? on-edit) "missing `on-edit` prop")
@@ -187,7 +187,46 @@
on-export-binary-files
(fn []
(st/emit! (-> (fexp/open-export-dialog files)
(with-meta {::ev/origin "dashboard"}))))]
(with-meta {::ev/origin "dashboard"}))))
restore-fn
(fn [_]
(st/emit! (dd/restore-files-immediately
(with-meta {:team-id (:id current-team)
:ids #{(:id file)}}
{:on-success #(st/emit! (ntf/success (tr "restore-modal.success-restore-immediately" (:name file)))
(dd/fetch-projects (:id current-team))
(dd/fetch-deleted-files (:id current-team)))
:on-error #(st/emit! (ntf/error (tr "restore-modal.error-restore-file" (:name file))))}))))
on-restore-immediately
(fn []
(st/emit!
(modal/show {:type :confirm
:title (tr "restore-modal.restore-file.title")
:message (tr "restore-modal.restore-file.description" (:name file))
:accept-label (tr "labels.continue")
:accept-style :primary
:on-accept restore-fn})))
delete-fn
(fn [_]
(st/emit! (ntf/success (tr "delete-forever-modal.success-delete-immediately" (:name file)))
(dd/delete-files-immediately
{:team-id (:id current-team)
:ids #{(:id file)}})
(dd/fetch-projects (:id current-team))
(dd/fetch-deleted-files (:id current-team))))
on-delete-immediately
(fn []
(st/emit!
(modal/show {:type :confirm
:title (tr "delete-forever-modal.title")
:message (tr "delete-forever-modal.delete-file.description" (:name file))
:accept-label (tr "delete-forever-modal.title")
:on-accept delete-fn})))]
(mf/with-effect []
(->> (rp/cmd! :get-all-projects)
@@ -227,76 +266,85 @@
(:id sub-project))})})}]))
options
(if multi?
[(when can-edit
{:name (tr "dashboard.duplicate-multi" file-count)
:id "duplicate-multi"
:handler on-duplicate})
(if can-restore
[(when can-restore
{:name (tr "dashboard.restore-file")
:id "restore-file"
:handler on-restore-immediately})
(when can-restore
{:name (tr "dashboard.delete-file")
:id "delete-file"
:handler on-delete-immediately})]
(if multi?
[(when can-edit
{:name (tr "dashboard.duplicate-multi" file-count)
:id "duplicate-multi"
:handler on-duplicate})
(when (and (or (seq current-projects) (seq other-teams)) can-edit)
{:name (tr "dashboard.move-to-multi" file-count)
:id "file-move-multi"
:options sub-options})
(when (and (or (seq current-projects) (seq other-teams)) can-edit)
{:name (tr "dashboard.move-to-multi" file-count)
:id "file-move-multi"
:options sub-options})
{:name (tr "dashboard.export-binary-multi" file-count)
:id "file-binary-export-multi"
:handler on-export-binary-files}
{:name (tr "dashboard.export-binary-multi" file-count)
:id "file-binary-export-multi"
:handler on-export-binary-files}
(when (and (:is-shared file) can-edit)
{:name (tr "labels.unpublish-multi-files" file-count)
:id "file-unpublish-multi"
:handler on-del-shared})
(when (and (:is-shared file) can-edit)
{:name (tr "labels.unpublish-multi-files" file-count)
:id "file-unpublish-multi"
:handler on-del-shared})
(when (and (not is-lib-page?) can-edit)
{:name :separator}
{:name (tr "labels.delete-multi-files" file-count)
:id "file-delete-multi"
:handler on-delete})]
(when (and (not is-lib-page?) can-edit)
{:name :separator}
{:name (tr "labels.delete-multi-files" file-count)
:id "file-delete-multi"
:handler on-delete})]
[{:name (tr "dashboard.open-in-new-tab")
:id "file-open-new-tab"
:handler on-new-tab}
(when (and (not is-search-page?) can-edit)
{:name (tr "labels.rename")
:id "file-rename"
:handler on-edit})
[{:name (tr "dashboard.open-in-new-tab")
:id "file-open-new-tab"
:handler on-new-tab}
(when (and (not is-search-page?) can-edit)
{:name (tr "labels.rename")
:id "file-rename"
:handler on-edit})
(when (and (not is-search-page?) can-edit)
{:name (tr "dashboard.duplicate")
:id "file-duplicate"
:handler on-duplicate})
(when (and (not is-search-page?) can-edit)
{:name (tr "dashboard.duplicate")
:id "file-duplicate"
:handler on-duplicate})
(when (and (not is-lib-page?)
(not is-search-page?)
(or (seq current-projects) (seq other-teams))
can-edit)
{:name (tr "dashboard.move-to")
:id "file-move-to"
:options sub-options})
(when (and (not is-lib-page?)
(not is-search-page?)
(or (seq current-projects) (seq other-teams))
can-edit)
{:name (tr "dashboard.move-to")
:id "file-move-to"
:options sub-options})
(when (and (not is-search-page?)
can-edit)
(if (:is-shared file)
{:name (tr "dashboard.unpublish-shared")
:id "file-del-shared"
:handler on-del-shared}
{:name (tr "dashboard.add-shared")
:id "file-add-shared"
:handler on-add-shared}))
(when (and (not is-search-page?)
can-edit)
(if (:is-shared file)
{:name (tr "dashboard.unpublish-shared")
:id "file-del-shared"
:handler on-del-shared}
{:name (tr "dashboard.add-shared")
:id "file-add-shared"
:handler on-add-shared}))
{:name :separator}
{:name :separator}
{:name (tr "dashboard.download-binary-file")
:id "download-binary-file"
:handler on-export-binary-files}
{:name (tr "dashboard.download-binary-file")
:id "download-binary-file"
:handler on-export-binary-files}
(when (and (not is-lib-page?) (not is-search-page?) can-edit)
{:name :separator})
(when (and (not is-lib-page?) (not is-search-page?) can-edit)
{:name :separator})
(when (and (not is-lib-page?) (not is-search-page?) can-edit)
{:name (tr "labels.delete")
:id "file-delete"
:handler on-delete})])]
(when (and (not is-lib-page?) (not is-search-page?) can-edit)
{:name (tr "labels.delete")
:id "file-delete"
:handler on-delete})]))]
[:> context-menu*
{:on-close on-close

View File

@@ -86,7 +86,7 @@
(mf/defc grid-item-thumbnail*
{::mf/props :obj
::mf/private true}
[{:keys [can-edit file]}]
[{:keys [can-edit file can-restore]}]
(let [file-id (get file :id)
revn (get file :revn)
thumbnail-id (get file :thumbnail-id)
@@ -109,7 +109,8 @@
:message (ex-message cause)))))]
(partial rx/dispose! subscription))))
[:div {:class (stl/css :grid-item-th)
[:div {:class (stl/css-case :grid-item-th true
:deleted-item can-restore)
:style {:background-color bg-color}
:ref container}
(when visible?
@@ -131,13 +132,15 @@
(mf/defc grid-item-library*
{::mf/props :obj}
[{:keys [file]}]
[{:keys [file can-restore]}]
(mf/with-effect [file]
(when file
(let [font-ids (map :font-id (get-in file [:library-summary :typographies :sample] []))]
(run! fonts/ensure-loaded! font-ids))))
[:div {:class (stl/css :grid-item-th :library)}
[:div {:class (stl/css-case :grid-item-th true
:library true
:deleted-item can-restore)}
(if (nil? file)
[:> loader* {:class (stl/css :grid-loader)
:overlay true
@@ -250,7 +253,7 @@
counter-el))
(mf/defc grid-item*
[{:keys [file origin can-edit selected-files]}]
[{:keys [file origin can-edit selected-files can-restore]}]
(let [file-id (get file :id)
state (mf/deref refs/dashboard-local)
@@ -289,12 +292,13 @@
on-navigate
(mf/use-fn
(mf/deps file-id)
(mf/deps file-id can-restore)
(fn [event]
(let [menu-icon (mf/ref-val menu-ref)
target (dom/get-target event)]
(when-not (dom/child? target menu-icon)
(st/emit! (dcm/go-to-workspace :file-id file-id))))))
(when-not can-restore
(let [menu-icon (mf/ref-val menu-ref)
target (dom/get-target event)]
(when-not (dom/child? target menu-icon)
(st/emit! (dcm/go-to-workspace :file-id file-id)))))))
on-drag-start
(mf/use-fn
@@ -412,8 +416,8 @@
[:div {:class (stl/css :overlay)}]
(if ^boolean is-library-view?
[:> grid-item-library* {:file file}]
[:> grid-item-thumbnail* {:file file :can-edit can-edit}])
[:> grid-item-library* {:file file :can-restore can-restore}]
[:> grid-item-thumbnail* {:file file :can-edit can-edit :can-restore can-restore}])
(when (and (:is-shared file) (not is-library-view?))
[:div {:class (stl/css :item-badge)} deprecated-icon/library])
@@ -451,11 +455,12 @@
:on-edit on-edit
:on-close on-menu-close
:origin origin
:parent-id (dm/str file-id "-action-menu")}]])]]]]]))
:parent-id (dm/str file-id "-action-menu")
:can-restore can-restore}]])]]]]]))
(mf/defc grid*
{::mf/props :obj}
[{:keys [files project origin limit create-fn can-edit selected-files]}]
[{:keys [files project origin limit create-fn can-edit selected-files can-restore]}]
(let [dragging? (mf/use-state false)
project-id (get project :id)
team-id (get project :team-id)
@@ -535,7 +540,8 @@
:key (dm/str (:id item))
:origin origin
:selected-files selected-files
:can-edit can-edit}])])
:can-edit can-edit
:can-restore can-restore}])])
:else
[:> empty-grid-placeholder*
@@ -548,7 +554,7 @@
:on-finish-import on-finish-import}])]))
(mf/defc line-grid-row
[{:keys [files selected-files dragging? limit can-edit] :as props}]
[{:keys [files selected-files dragging? limit can-edit can-restore] :as props}]
(let [elements limit
limit (if dragging? (dec limit) limit)]
[:ul {:class (stl/css :grid-row :no-wrap)
@@ -563,10 +569,11 @@
:file item
:selected-files selected-files
:can-edit can-edit
:key (dm/str (:id item))}])]))
:key (dm/str (:id item))
:can-restore can-restore}])]))
(mf/defc line-grid
[{:keys [project team files limit create-fn can-edit] :as props}]
[{:keys [project team files limit create-fn can-edit can-restore] :as props}]
(let [dragging? (mf/use-state false)
project-id (:id project)
team-id (:id team)
@@ -664,7 +671,8 @@
:selected-files selected-files
:dragging? @dragging?
:can-edit can-edit
:limit limit}]
:limit limit
:can-restore can-restore}]
:else
[:> empty-grid-placeholder*

View File

@@ -375,3 +375,7 @@ $thumbnail-default-height: deprecated.$s-168; // Default width
.grid-loader {
--icon-width: calc(var(--th-width, #{$thumbnail-default-width}) * 0.25);
}
.deleted-item {
opacity: 0.5;
}

View File

@@ -21,6 +21,7 @@
[app.main.ui.dashboard.inline-edition :refer [inline-edition]]
[app.main.ui.dashboard.pin-button :refer [pin-button*]]
[app.main.ui.dashboard.project-menu :refer [project-menu*]]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]]
[app.main.ui.hooks :as hooks]
[app.main.ui.icons :as deprecated-icon]
@@ -342,7 +343,13 @@
(fn []
(reset! show-team-hero* false)
(st/emit! (ptk/data-event ::ev/event {::ev/name "dont-show-team-up-hero"
::ev/origin "dashboard"}))))]
::ev/origin "dashboard"}))))
on-deleted-click
(mf/use-fn
(mf/deps team-id)
(fn []
(st/emit! (dcm/go-to-dashboard-deleted :team-id team-id))))]
(mf/with-effect [show-team-hero?]
(swap! storage/global assoc ::show-team-hero show-team-hero?))
@@ -376,6 +383,15 @@
(not is-defalt-team?)
show-team-hero?
can-invite))}
[:div {:class (stl/css :nav-options)}
[:div {:class (stl/css :selected)
:data-testid "recent-tab"}
(tr "dashboard.labels.recent")]
[:> button* {:variant "ghost"
:type "button"
:data-testid "deleted-tab"
:on-click on-deleted-click}
(tr "dashboard.labels.deleted")]]
(for [{:keys [id] :as project} projects]
;; FIXME: refactor this, looks inneficient
(let [files (when recent-map

View File

@@ -4,16 +4,21 @@
//
// Copyright (c) KALEIDOS INC
@use "refactor/common-refactor.scss" as deprecated;
@use "common/refactor/common-refactor.scss" as deprecated;
@use "common/refactor/common-dashboard";
@use "../ds/typography.scss" as t;
@use "../ds/_borders.scss" as *;
@use "../ds/spacing.scss" as *;
@use "../ds/_sizes.scss" as *;
@use "../ds/z-index.scss" as *;
.dashboard-container {
flex: 1 0 0;
width: 100%;
margin-right: deprecated.$s-16;
border-top: deprecated.$s-1 solid var(--panel-border-color);
margin-inline-end: var(--sp-l);
border-top: $b-1 solid var(--panel-border-color);
overflow-y: auto;
padding-bottom: deprecated.$s-32;
padding-bottom: var(--sp-xxxl);
}
.dashboard-projects {
@@ -27,16 +32,16 @@
.dashboard-shared {
width: calc(100vw - deprecated.$s-320);
margin-right: deprecated.$s-52;
margin-inline-end: deprecated.$s-52;
}
.search {
margin-top: deprecated.$s-12;
margin-block-start: var(--sp-m);
}
.dashboard-project-row {
--actions-opacity: 0;
margin-bottom: deprecated.$s-24;
margin-block-end: var(--sp-xxl);
position: relative;
&:hover,
@@ -60,12 +65,12 @@
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: deprecated.$s-8;
gap: var(--sp-s);
width: 99%;
max-height: deprecated.$s-40;
padding: deprecated.$s-8 deprecated.$s-8 deprecated.$s-8 deprecated.$s-16;
margin-top: deprecated.$s-16;
border-radius: deprecated.$br-4;
max-height: $sz-40;
padding: var(--sp-s) var(--sp-s) var(--sp-s) var(--sp-l);
margin-block-start: var(--sp-l);
border-radius: $br-4;
}
.project-name-wrapper {
@@ -73,30 +78,29 @@
align-items: center;
justify-content: flex-start;
width: 100%;
min-height: deprecated.$s-32;
margin-left: deprecated.$s-8;
min-height: var(--sp-xxxl);
margin-inline-start: var(--sp-s);
}
.project-name {
@include deprecated.bodyLargeTypography;
@include deprecated.textEllipsis;
@include t.use-typography("body-large");
width: fit-content;
margin-right: deprecated.$s-12;
margin-inline-end: var(--sp-m);
line-height: 0.8;
color: var(--title-foreground-color-hover);
cursor: pointer;
height: deprecated.$s-16;
height: var(--sp-l);
}
.info-wrapper {
display: flex;
align-items: center;
gap: deprecated.$s-8;
gap: var(--sp-s);
}
.info,
.recent-files-row-title-info {
@include deprecated.bodyMediumTypography;
@include t.use-typography("body-medium");
color: var(--title-foreground-color);
@media (max-width: 760px) {
display: none;
@@ -106,16 +110,16 @@
.project-actions {
display: flex;
opacity: var(--actions-opacity);
margin-left: deprecated.$s-32;
margin-inline-start: var(--sp-xxxl);
}
.add-file-btn,
.options-btn {
@extend .button-tertiary;
height: deprecated.$s-32;
width: deprecated.$s-32;
margin: 0 deprecated.$s-8;
padding: deprecated.$s-8;
height: var(--sp-xxxl);
width: var(--sp-xxxl);
margin: 0 var(--sp-s);
padding: var(--sp-s);
}
.add-icon,
@@ -126,24 +130,24 @@
.grid-container {
width: 100%;
padding: 0 deprecated.$s-4;
padding: 0 var(--sp-xs);
}
.placeholder-placement {
margin: deprecated.$s-16 deprecated.$s-32;
margin: var(--sp-l) var(--sp-xxxl);
}
.show-more {
--show-more-color: var(--button-secondary-foreground-color-rest);
@include deprecated.buttonStyle;
@include deprecated.bodyMediumTypography;
@include t.use-typography("body-medium");
position: absolute;
top: deprecated.$s-8;
top: var(--sp-s);
right: deprecated.$s-52;
display: flex;
align-items: center;
justify-content: space-between;
column-gap: deprecated.$s-12;
column-gap: var(--sp-m);
color: var(--show-more-color);
&:hover {
@@ -152,8 +156,8 @@
}
.show-more-icon {
height: deprecated.$s-16;
width: deprecated.$s-16;
height: var(--sp-l);
width: var(--sp-l);
fill: none;
stroke: var(--show-more-color);
}
@@ -164,13 +168,13 @@
border-radius: deprecated.$br-8;
border: none;
display: flex;
margin: deprecated.$s-16;
padding: deprecated.$s-8;
margin: var(--sp-l);
padding: var(--sp-s);
position: relative;
img {
border-radius: deprecated.$br-4;
height: deprecated.$s-200;
border-radius: $br-4;
height: var(--sp-xl) 0;
width: auto;
@media (max-width: 1200px) {
@@ -185,18 +189,18 @@
flex-direction: column;
align-items: flex-start;
flex-grow: 1;
padding: deprecated.$s-20 deprecated.$s-20;
padding: var(--sp-xl) var(--sp-xl);
}
.title {
font-size: deprecated.$fs-24;
font-size: $sz-24;
color: var(--color-foreground-primary);
font-weight: deprecated.$fw400;
}
.info {
flex: 1;
font-size: deprecated.$fs-16;
font-size: $sz-16;
span {
color: var(--color-foreground-secondary);
display: block;
@@ -204,15 +208,15 @@
a {
color: var(--color-accent-primary);
}
padding: deprecated.$s-8 0;
padding: var(--sp-s) 0;
}
.close {
--close-icon-foreground-color: var(--icon-foreground);
position: absolute;
top: deprecated.$s-20;
right: deprecated.$s-24;
width: deprecated.$s-24;
top: var(--sp-xl);
right: var(--sp-xxl);
width: var(--sp-xxl);
background-color: transparent;
border: none;
cursor: pointer;
@@ -227,7 +231,7 @@
}
.invite {
height: deprecated.$s-32;
height: var(--sp-xxxl);
width: deprecated.$s-180;
}
@@ -235,8 +239,8 @@
display: flex;
align-items: center;
justify-content: center;
width: deprecated.$s-200;
height: deprecated.$s-200;
width: var(--sp-xl) 0;
height: var(--sp-xl) 0;
overflow: hidden;
border-radius: deprecated.$br-4;
@media (max-width: 1200px) {
@@ -244,3 +248,26 @@
width: 0;
}
}
.nav-options {
display: flex;
gap: var(--sp-l);
justify-content: space-between;
border-bottom: $b-1 solid var(--panel-border-color);
padding-inline-start: var(--sp-l);
background: var(--color-background-default);
position: sticky;
top: 0;
z-index: var(--z-index-panels);
}
.selected {
@include t.use-typography("headline-small");
display: flex;
align-items: center;
justify-content: center;
color: var(--color-foreground-primary);
border: $b-1 solid transparent;
border-bottom: $b-1 solid var(--color-foreground-primary);
padding: 0 var(--sp-m);
}

View File

@@ -27,11 +27,11 @@
[app.main.ui.dashboard.comments :refer [comments-icon* comments-section]]
[app.main.ui.dashboard.inline-edition :refer [inline-edition]]
[app.main.ui.dashboard.project-menu :refer [project-menu*]]
[app.main.ui.dashboard.subscription :refer [subscription-sidebar*
[app.main.ui.dashboard.subscription :refer [dashboard-cta*
get-subscription-type
menu-team-icon*
dashboard-cta*
show-subscription-dashboard-banner?
get-subscription-type]]
subscription-sidebar*]]
[app.main.ui.dashboard.team-form]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.icons :as deprecated-icon]

View File

@@ -12,6 +12,7 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.types.color :as clr]
[app.main.data.dashboard :as dd]
[app.main.data.exports.assets :as de]
[app.main.data.modal :as modal]
[app.main.refs :as refs]
@@ -205,10 +206,13 @@
:cmd :export-frames
:origin origin}]))
(mf/defc export-progress-widget
(mf/defc progress-widget
{::mf/wrap [mf/memo]}
[]
(let [state (mf/deref refs/export)
[{:keys [operation] :or {operation :export}}]
(let [state (mf/deref (case operation
:export refs/export
:restore refs/restore
refs/export))
profile (mf/deref refs/profile)
theme (or (:theme profile) theme/default)
is-default-theme? (= theme/default theme)
@@ -217,11 +221,14 @@
detail-visible? (:detail-visible state)
widget-visible? (:widget-visible state)
progress (:progress state)
exports (:exports state)
total (count exports)
items (case operation
:export (:exports state)
:restore (:files state)
[])
total (or (:total state) (count items))
complete? (= progress total)
circ (* 2 Math/PI 12)
pct (- circ (* circ (/ progress total)))
pct (if (zero? total) circ (- circ (* circ (/ progress total))))
pwidth
(if error?
@@ -243,19 +250,43 @@
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"))
error? (case operation
:export (tr "workspace.options.exporting-object-error")
:restore (tr "workspace.options.restoring-object-error")
(tr "workspace.options.processing-object-error"))
complete? (case operation
:export (tr "workspace.options.exporting-complete")
:restore (tr "workspace.options.restoring-complete")
(tr "workspace.options.processing-complete"))
healthy? (case operation
:export (tr "workspace.options.exporting-object")
:restore (tr "workspace.options.restoring-object")
(tr "workspace.options.processing-object"))
(not healthy?) (case operation
:export (tr "workspace.options.exporting-object-slow")
:restore (tr "workspace.options.restoring-object-slow")
(tr "workspace.options.processing-object-slow")))
retry-last-export
(mf/use-fn #(st/emit! (de/retry-last-export)))
retry-last-operation
(mf/use-fn
(mf/deps operation)
(fn []
(case operation
:export (st/emit! (de/retry-last-export))
:restore (st/emit! (dd/retry-last-restore))
nil)))
toggle-detail-visibility
(mf/use-fn #(st/emit! (de/toggle-detail-visibililty)))]
(mf/use-fn
(mf/deps operation)
(fn []
(case operation
:export (st/emit! (de/toggle-detail-visibililty))
:restore (st/emit! (dd/toggle-restore-detail-visibility))
nil)))]
[:*
(when widget-visible?
(when (and widget-visible? (= operation :export))
[:div {:class (stl/css :export-progress-widget)
:on-click toggle-detail-visibility}
[:svg {:width "24" :height "24"}
@@ -283,11 +314,11 @@
error-icon
neutral-icon)
[:p {:class (stl/css :export-progress-title)}
title
[:div {:class (stl/css :export-progress-title)}
[:div {:class (stl/css :title-text)} title]
(if error?
[:button {:class (stl/css :retry-btn)
:on-click retry-last-export}
:on-click retry-last-operation}
(tr "workspace.options.retry")]
[:span {:class (stl/css :progress)}

View File

@@ -64,7 +64,8 @@
["/fonts" :dashboard-fonts]
["/fonts/providers" :dashboard-font-providers]
["/libraries" :dashboard-libraries]
["/files" :dashboard-files]]
["/files" :dashboard-files]
["/deleted" :dashboard-deleted]]
["/dashboard/team/:team-id"
["/members" :dashboard-legacy-team-members]

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

@@ -14,7 +14,7 @@
[app.main.data.viewer.shortcuts :as sc]
[app.main.store :as st]
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.exports.assets :refer [export-progress-widget]]
[app.main.ui.exports.assets :refer [progress-widget]]
[app.main.ui.formats :as fmt]
[app.main.ui.icons :as deprecated-icon]
[app.main.ui.viewer.comments :refer [comments-menu]]
@@ -167,7 +167,7 @@
(open-share-dialog)))
[:div {:class (stl/css :options-zone)}
[:& export-progress-widget]
[:& progress-widget {:operation :export}]
(case section
:interactions [:*

View File

@@ -22,7 +22,7 @@
[app.main.ui.dashboard.team]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.exports.assets :refer [export-progress-widget]]
[app.main.ui.exports.assets :refer [progress-widget]]
[app.main.ui.formats :as fmt]
[app.main.ui.icons :as deprecated-icon]
[app.main.ui.workspace.presence :refer [active-sessions]]
@@ -200,7 +200,7 @@
[:div {:class (stl/css :users-section)}
[:& active-sessions]]
[:& export-progress-widget]
[:& progress-widget {:operation :export}]
[:div {:class (stl/css :separator)}]

View File

@@ -6,7 +6,7 @@
(ns app.main.ui.workspace.shapes.text.editor
(:require
["draft-js" :as draft]
["@penpot/draft-js" :as draft]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.geom.point :as gpt]

View File

@@ -271,8 +271,10 @@
:font-variant-id new-variant-id
:font-weight (:weight variant)
:font-style (:style variant)}))
(dom/blur! (dom/get-target new-variant-id)))))
;; 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
@@ -306,7 +308,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)

View File

@@ -268,8 +268,8 @@
:on-click modal/hide!}
(tr "labels.cancel")]
[:> import-type-dropdown*
{:options [{:label (tr "workspace.tokens.import-menu-zip-option") :value :zip}
{:label (tr "workspace.tokens.import-menu-json-option") :value :file}
{:options [{:label (tr "workspace.tokens.import-menu-json-option") :value :file}
{:label (tr "workspace.tokens.import-menu-zip-option") :value :zip}
{:label (tr "workspace.tokens.import-menu-folder-option") :value :folder}]
:on-click handle-import-action
:text-render render-button-text

View File

@@ -13,16 +13,11 @@
[app.common.geom.shapes :as gsh]
[app.common.types.color :as clr]
[app.common.types.component :as ctk]
[app.common.types.container :as ctn]
[app.common.types.path :as path]
[app.common.types.shape :as cts]
[app.common.types.shape-tree :as ctt]
[app.common.types.shape.layout :as ctl]
[app.config :as cf]
[app.graph-wasm.api :as graph-wasm.api]
[app.main.data.workspace.componentize :as dwc]
[app.main.data.workspace.modifiers :as dwm]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.variants :as dwv]
[app.main.features :as features]
[app.main.refs :as refs]
@@ -62,11 +57,8 @@
[app.main.ui.workspace.viewport.utils :as utils]
[app.main.ui.workspace.viewport.viewport-ref :refer [create-viewport-ref]]
[app.main.ui.workspace.viewport.widgets :as widgets]
[app.main.worker :as worker]
[app.util.debug :as dbg]
[app.util.modules :as mod]
[beicon.v2.core :as rx]
[promesa.core :as p]
[rumext.v2 :as mf]))
;; --- Viewport
@@ -142,7 +134,6 @@
mod? (mf/use-state false)
space? (mf/use-state false)
z? (mf/use-state false)
g? (mf/use-state false)
cursor (mf/use-state #(utils/get-cursor :pointer-inner))
hover-ids (mf/use-state nil)
hover (mf/use-state nil)
@@ -311,79 +302,12 @@
(mf/use-fn
(mf/deps first-shape)
#(st/emit!
(dwv/add-new-variant (:id first-shape))))
graph-wasm-enabled? (features/use-feature "graph-wasm/v1")]
(mf/with-effect [page-id]
(when graph-wasm-enabled?
;; Initialize graph-wasm in the worker to avoid blocking main thread
(let [subscription
(->> (worker/ask! {:cmd :graph-wasm/init})
(rx/filter #(= (:status %) :ok))
(rx/take 1)
(rx/merge-map (fn [_]
(worker/ask! {:cmd :graph-wasm/set-objects
:objects base-objects}))))]
(rx/subscribe subscription
(fn [result]
(when (= (:status result) :ok)
(js/console.debug "Graph WASM initialized in worker"
(select-keys result [:processed]))))
(fn [error]
(js/console.error "Error initializing graph-wasm in worker:" error))
(fn []
(js/console.debug "Graph WASM worker operations completed"))))))
(mf/with-effect [selected @g?]
(when graph-wasm-enabled?
;; Search for similar shapes when selection changes or when
;; the user presses the \"c\" key while having a single
;; selection.
(when (and @g?
(some? selected)
(= (count selected) 1))
(let [selected-id (first selected)
selected-shape (get base-objects selected-id)
;; Skip shapes that already belong to a component
non-component? (and (some? selected-shape)
(not (ctn/in-any-component? base-objects selected-shape)))]
(println selected-shape)
(println (ctn/in-any-component? base-objects selected-shape))
(when non-component?
(let [subscription
(worker/ask! {:cmd :graph-wasm/search-similar-shapes
:shape-id selected-id})]
(rx/subscribe subscription
(fn [result]
(when (= (:status result) :ok)
(let [raw-similar-shapes (:similar-shapes result)
;; Filter out shapes that already belong to some component
;; (main instance, instance head or inside a component copy).
similar-shapes (->> raw-similar-shapes
(remove (fn [sid]
(when-let [s (get base-objects sid)]
(ctn/in-any-component? base-objects s))))
(into []))]
(when (d/not-empty? similar-shapes)
;; Transform the selected subtree into a component and
;; replace similar subtrees by instances of that component.
(st/emit! (dwc/componentize-similar-subtrees
selected-id
similar-shapes))))))
(fn [error]
(js/console.error "Error searching similar shapes:" error))
(fn []
(js/console.debug "Similar shapes search completed")))))))))
(dwv/add-new-variant (:id first-shape))))]
(hooks/setup-dom-events zoom disable-paste-ref in-viewport-ref read-only? drawing-tool path-drawing?)
(hooks/setup-viewport-size vport viewport-ref)
(hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? z? read-only?)
(hooks/setup-keyboard alt? mod? space? z? shift? g?)
(hooks/setup-keyboard alt? mod? space? z? shift?)
(hooks/setup-hover-shapes page-id move-stream base-objects transform selected mod? hover measure-hover
hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures?)
(hooks/setup-viewport-modifiers modifiers base-objects)

View File

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

View File

@@ -124,7 +124,7 @@
(reset! cursor new-cursor))))))
(defn setup-keyboard
[alt* mod* space* z* shift* g*]
[alt* mod* space* z* shift*]
(let [kbd-zoom-s
(mf/with-memo []
(->> ms/keyboard
@@ -151,22 +151,12 @@
(rx/filter kbd/z?)
(rx/filter (complement kbd/editing-event?))
(rx/map kbd/key-down-event?)
(rx/pipe (rxo/distinct-contiguous))))
kbd-g-s
(mf/with-memo []
(let [c-pred (kbd/is-key-ignore-case? "g")]
(->> ms/keyboard
(rx/filter c-pred)
(rx/filter (complement kbd/editing-event?))
(rx/map kbd/key-down-event?)
(rx/pipe (rxo/distinct-contiguous)))))]
(rx/pipe (rxo/distinct-contiguous))))]
(hooks/use-stream ms/keyboard-alt (partial reset! alt*))
(hooks/use-stream ms/keyboard-space (partial reset! space*))
(hooks/use-stream kbd-z-s (partial reset! z*))
(hooks/use-stream kbd-shift-s (partial reset! shift*))
(hooks/use-stream kbd-g-s (partial reset! g*))
(hooks/use-stream ms/keyboard-mod
(fn [value]
(reset! mod* value)

View File

@@ -122,7 +122,6 @@
mod? (mf/use-state false)
space? (mf/use-state false)
z? (mf/use-state false)
c? (mf/use-state false)
cursor (mf/use-state (utils/get-cursor :pointer-inner))
hover-ids (mf/use-state nil)
hover (mf/use-state nil)
@@ -361,7 +360,7 @@
(hooks/setup-dom-events zoom disable-paste-ref in-viewport-ref read-only? drawing-tool path-drawing?)
(hooks/setup-viewport-size vport viewport-ref)
(hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? z? read-only?)
(hooks/setup-keyboard alt? mod? space? z? shift? c?)
(hooks/setup-keyboard alt? mod? space? z? shift?)
(hooks/setup-hover-shapes page-id move-stream base-objects transform selected mod? hover measure-hover
hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures?)
(hooks/setup-shortcuts path-editing? path-drawing? text-editing? grid-editing?)

View File

@@ -54,6 +54,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]))
@@ -1195,7 +1196,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

@@ -194,7 +194,12 @@
:addToken
(fn [type-str name value]
(let [type (cto/dtcg-token-type->token-type type-str)]
(let [type (cto/dtcg-token-type->token-type type-str)
value (case type
:font-family (ctob/convert-dtcg-font-family (js->clj value))
:typography (ctob/convert-dtcg-typography-composite (js->clj value))
:shadow (ctob/convert-dtcg-shadow-composite (js->clj value))
(js->clj value))]
(cond
(nil? type)
(u/display-not-valid :addTokenType type-str)

View File

@@ -18,6 +18,7 @@
[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.refs :as refs]
@@ -964,8 +965,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)
@@ -1411,20 +1412,23 @@
(get span))
text (subs (:text element) start-pos end-pos)]
{: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}))))]
(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)))

View File

@@ -233,8 +233,8 @@
(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

View File

@@ -6,11 +6,11 @@
(ns app.util.code-highlight
(:require
["@penpot/hljs" :as hljs]
["highlight.js" :as hljs]
[app.util.dom :as dom]))
(defn highlight!
{:lazy-loadable true}
[node]
(dom/set-data! node "highlighted" nil)
(hljs/highlightElement node))
(.highlightElement hljs/default node))

View File

@@ -135,8 +135,18 @@
(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]
[internal-state rerender-fn wrap-update-fn opts]
(reify
IDeref
(-deref [_]
@@ -145,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))
@@ -176,26 +189,20 @@
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 schema validators]
(mf/with-memo [schema validators]
(let [mutator (create-form-mutator internal-state rerender-fn wrap-update-schema-fn
initial
(select-keys opts [:schema :validators]))]
(swap! mutator identity)
mutator))]
(mf/with-effect [initial]
(mf/set-ref-val! internal-state initial))
;; Initialize internal state once
(mf/with-layout-effect []
(mf/with-effect []
(mf/set-ref-val! internal-state initial))
(mf/with-effect [initial]

View File

@@ -50,6 +50,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"}
@@ -69,7 +70,6 @@
(-> (.-language globals/navigator)
(parse-locale))))
;; Set initial translation loading state as globaly stored variable;
;; this facilitates hot reloading
(when-not (exists? (unchecked-get globals/global "penpotTranslations"))
@@ -93,14 +93,8 @@
(def ^:dynamic *current-locale*
(get-current))
(defonce state
(l/atom {:render 0 :locale *current-locale*}))
(defn- assign-current-locale
[state locale]
(-> state
(update :render inc)
(assoc :locale locale)))
(defonce locale
(l/atom *current-locale*))
(defn- get-translations
"Get globaly stored mutable object with all loaded translations"
@@ -114,6 +108,10 @@
(unchecked-set translations locale data)
nil))
(defn set-default-translations
[data]
(set-translations cf/default-language data))
(defn- load
[locale]
(let [path (str "./translation." locale ".js?version=" (:full cf/version))]
@@ -122,15 +120,14 @@
(p/fnly (fn [data cause]
(if cause
(js/console.error "unexpected error on fetching locale" cause)
(do
(set! *current-locale* locale)
(set-translations locale data)
(swap! state assign-current-locale locale))))))))
(set-translations locale data)))))))
(defn init
"Initialize the i18n module"
[]
(load *current-locale*))
(load *current-locale*)
(when-not (= *current-locale* cf/default-language)
(load cf/default-language)))
(defn set-locale
[lname]
@@ -145,7 +142,10 @@
(recur (rest locales)))
cf/default-language))))]
(load lname)))
(->> (load lname)
(p/fnly (fn [_r _c]
(set! *current-locale* lname)
(reset! locale lname))))))
(deftype C [val]
IDeref
@@ -206,9 +206,7 @@
:className class
:on-click on-click}]))
(add-watch state "common.time"
(add-watch locale "common.time"
(fn [_ _ pv cv]
(let [pv (get pv :locale)
cv (get cv :locale)]
(when (not= pv cv)
(ct/set-default-locale! cv)))))
(when (not= pv cv)
(ct/set-default-locale cv))))

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

@@ -11,7 +11,6 @@
[app.common.schema :as sm]
[app.common.types.objects-map]
[app.util.object :as obj]
[app.worker.graph-wasm]
[app.worker.impl :as impl]
[app.worker.import]
[app.worker.index]

View File

@@ -1,181 +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.worker.graph-wasm
"Graph WASM operations within the worker."
(:require
[app.common.data.macros :as dm]
[app.common.logging :as log]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.graph-wasm.wasm :as wasm]
[app.render-wasm.helpers :as h]
[app.render-wasm.serializers :as sr]
[app.worker.impl :as impl]
[beicon.v2.core :as rx]
[promesa.core :as p]))
(log/set-level! :info)
(defn- use-shape
[module id]
(let [buffer (uuid/get-u32 id)]
(h/call module "_use_shape"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3))))
(defn- set-shape-parent-id
[module id]
(let [buffer (uuid/get-u32 id)]
(h/call module "_set_shape_parent"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3))))
(defn- set-shape-type
[module type]
(h/call module "_set_shape_type" (sr/translate-shape-type type)))
(defn- set-shape-selrect
[module selrect]
(h/call module "_set_shape_selrect"
(dm/get-prop selrect :x1)
(dm/get-prop selrect :y1)
(dm/get-prop selrect :x2)
(dm/get-prop selrect :y2)))
(defn- set-object
[module shape]
(let [id (dm/get-prop shape :id)
type (dm/get-prop shape :type)
parent-id (get shape :parent-id)
selrect (get shape :selrect)]
(use-shape module id)
(set-shape-type module type)
(set-shape-parent-id module parent-id)
(set-shape-selrect module selrect)))
(defonce ^:private graph-wasm-module
(delay
(let [module (unchecked-get js/globalThis "GraphWasmModule")
init-fn (unchecked-get module "default")
href (cf/resolve-href "js/graph-wasm.wasm")]
(->> (init-fn #js {:locateFile (constantly href)})
(p/fnly (fn [module cause]
(if cause
(js/console.error cause)
(set! wasm/internal-module module))))))))
(defmethod impl/handler :graph-wasm/init
[message transfer]
(rx/create
(fn [subs]
(-> @graph-wasm-module
(p/then (fn [module]
(if module
(try
(h/call module "_init")
(rx/push! subs {:status :ok})
(rx/end! subs)
(catch :default cause
(log/error :hint "Error in graph-wasm/init" :cause cause)
(rx/error! subs cause)
(rx/end! subs)))
(do
(log/warn :hint "Graph WASM module not available")
(rx/push! subs {:status :error :message "Module not available"})
(rx/end! subs)))))
(p/catch (fn [cause]
(log/error :hint "Error loading graph-wasm module" :cause cause)
(rx/error! subs cause)
(rx/end! subs))))
nil)))
(defmethod impl/handler :graph-wasm/set-objects
[message transfer]
(let [objects (:objects message)]
(rx/create
(fn [subs]
(-> @graph-wasm-module
(p/then (fn [module]
(if module
(try
(doseq [shape (vals objects)]
(set-object module shape))
(h/call module "_generate_db")
(rx/push! subs {:status :ok :processed (count objects)})
(rx/end! subs)
(catch :default cause
(log/error :hint "Error in graph-wasm/set-objects" :cause cause)
(rx/error! subs cause)
(rx/end! subs)))
(do
(log/warn :hint "Graph WASM module not available")
(rx/push! subs {:status :error :message "Module not available"})
(rx/end! subs)))))
(p/catch (fn [cause]
(log/error :hint "Error loading graph-wasm module" :cause cause)
(rx/error! subs cause)
(rx/end! subs))))
nil))))
(defmethod impl/handler :graph-wasm/search-similar-shapes
[message transfer]
(let [shape-id (:shape-id message)]
(rx/create
(fn [subs]
(-> @graph-wasm-module
(p/then (fn [module]
(if module
(try
(let [buffer (uuid/get-u32 shape-id)
ptr-raw (h/call module "_search_similar_shapes"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3))
;; Convert pointer to unsigned 32-bit (handle negative numbers from WASM)
;; Use unsigned right shift to convert signed to unsigned 32-bit
ptr (unsigned-bit-shift-right ptr-raw 0)
heapu8 (unchecked-get module "HEAPU8")
;; Read count (first 4 bytes, little-endian u32)
count (bit-or (aget heapu8 ptr)
(bit-shift-left (aget heapu8 (+ ptr 1)) 8)
(bit-shift-left (aget heapu8 (+ ptr 2)) 16)
(bit-shift-left (aget heapu8 (+ ptr 3)) 24))
;; Read UUIDs (16 bytes each, starting at offset 4)
similar-shapes (loop [offset (+ ptr 4)
remaining count
result []]
(if (zero? remaining)
result
(let [uuid-bytes (.slice heapu8 offset (+ offset 16))]
(recur (+ offset 16)
(dec remaining)
(conj result (uuid/from-bytes uuid-bytes))))))]
;; Free the buffer
(h/call module "_free_similar_shapes_buffer")
(rx/push! subs {:status :ok :similar-shapes similar-shapes})
(rx/end! subs))
(catch :default cause
(log/error :hint "Error in graph-wasm/search-similar-shapes" :cause cause)
(rx/error! subs cause)
(rx/end! subs)))
(do
(log/warn :hint "Graph WASM module not available")
(rx/push! subs {:status :error :message "Module not available"})
(rx/end! subs)))))
(p/catch (fn [cause]
(log/error :hint "Error loading graph-wasm module" :cause cause)
(rx/error! subs cause)
(rx/end! subs))))
nil))))

View File

@@ -47,6 +47,3 @@
result (svg-filters/apply-svg-filters shape)]
(is (= shape result))))

View File

@@ -1,7 +1,7 @@
msgid ""
msgstr ""
"PO-Revision-Date: 2025-11-22 10:51+0000\n"
"Last-Translator: jonnysemon <jonnysemon@users.noreply.hosted.weblate.org>\n"
"PO-Revision-Date: 2025-12-22 15:34+0000\n"
"Last-Translator: Shuaib Zahda <shuaib.zahda@gmail.com>\n"
"Language-Team: Arabic <https://hosted.weblate.org/projects/penpot/frontend/"
"ar/>\n"
"Language: ar\n"
@@ -10,7 +10,7 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 "
"&& n%100<=10 ? 3 : n%100>=11 ? 4 : 5;\n"
"X-Generator: Weblate 5.15-dev\n"
"X-Generator: Weblate 5.15.1\n"
#: src/app/main/ui/auth/register.cljs:215, src/app/main/ui/static.cljs:159, src/app/main/ui/viewer/login.cljs:100
msgid "auth.already-have-account"
@@ -402,8 +402,10 @@ msgstr ""
"أصولهم*؟"
#: src/app/main/ui/exports/files.cljs:164
#, fuzzy
msgid "dashboard.export.options.all.message"
msgstr "سيتم ادراج الملفات التي لها مكتبات مشتركة في التصدير، مع الحفاظ على روابطهم."
msgstr ""
"سيتم ادراج الملفات التي لها مكتبات مشتركة في التصدير، مع الحفاظ على روابطهم."
#: src/app/main/ui/exports/files.cljs:165
msgid "dashboard.export.options.all.title"

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