Compare commits

...

67 Commits

Author SHA1 Message Date
alonso.torres
baa90ea8bf WIP 2025-12-10 09:45:06 +01:00
Aitor Moreno
7d36bc4025 Merge pull request #7907 from penpot/alotor-fix-export-text
🐛 Fix problem when exporting texts
2025-12-09 11:28:47 +01:00
Belén Albeza
7be8ac3fd7 🐛 Fix internal error while importing a library 2025-12-09 11:10:32 +01:00
Elena Torro
9216d965ef 🔧 Update rendering settings to smooth render 2025-12-09 10:43:33 +01:00
alonso.torres
520e979363 🐛 Fix problem when exporting texts 2025-12-04 17:32:54 +01:00
Andrey Antukh
a38f425dd3 Merge remote-tracking branch 'origin/staging' into staging-render 2025-12-04 11:06:48 +01:00
Xaviju
75a2331edf 💄 Set low-emphasis color for both light/dark modes (#7884) 2025-12-04 11:04:07 +01:00
Alejandro Alonso
c2b4c9907d Merge pull request #7886 from penpot/niwinz-staging-bugfix-3
🐛 Fix casing on a translation of export files modal option
2025-12-04 10:59:51 +01:00
Alejandro Alonso
bd5bbcae26 Merge pull request #7894 from penpot/niwinz-staging-bugfix-4
🐛 Fix incorrect interaction betwen hower and scroll on assets sidebar
2025-12-04 10:58:54 +01:00
Andrey Antukh
84273508ad 🐛 Fix incorrect interaction betwen hower and scroll on assets sidebar 2025-12-04 10:56:29 +01:00
Andrey Antukh
9245ba6bc2 💄 Adapt component style for assets-local-library on sidebar assets 2025-12-04 10:55:57 +01:00
Andrey Antukh
4be046406d Pass direct args instead of a vector to toggle-values on sidebar assets 2025-12-04 10:55:57 +01:00
Alejandro Alonso
84c747cd31 Merge pull request #7883 from penpot/niwinz-staging-bugfix
🐛 Fix exception on paste text on comments input
2025-12-04 10:32:07 +01:00
Alejandro Alonso
0036a9a0cd Merge pull request #7865 from penpot/niwinz-staging-audit
 Add minor improvements to the audit module
2025-12-04 10:04:00 +01:00
Alejandro Alonso
2105c3a68c Merge pull request #7866 from penpot/niwinz-staging-fix-emails
🐛 Change internal ordering on how email parts are assembled
2025-12-04 09:56:22 +01:00
Belén Albeza
38efa88460 🐛 Fix unpublish library modal not scrolling file list (#7892)
* 🐛 Fix unpublish library modal not scrolling when the linked files list is too long

* 💄 Remove deprecated tokens in unpublish library modal

* 🔧 Update CHANGELOG
2025-12-03 22:41:20 +01:00
Pablo Alba
6e254c2cf4 🐛 Fix change of library on swap (#7898) 2025-12-03 22:40:23 +01:00
Andrey Antukh
6251fa6b22 🐛 Close other open context menus on open a context menu (#7895) 2025-12-03 18:50:00 +01:00
alonso.torres
aedd8cc11e 🐛 Fix problem when renaming variants in plugins 2025-12-03 17:42:17 +01:00
Alonso Torres
2f0853f5cc 🐛 Fix problem with variant plugins api (#7890) 2025-12-03 13:27:32 +01:00
Juan de la Cruz
648e660bcf 🎉 Add new content and images for the slides of 2.12 (#7874)
* 🎉 Add new slide's content

* 🎉 Add new slides images

* 📎 Fix clj fmt

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2025-12-03 13:26:55 +01:00
Slava "nerfur" Voronzoff
bee2f70bfa 🐛 Add missing amd64 option on arch detection code on mange.sh script
Signed-off-by: Slava "nerfur" Voronzoff <nerfur@gmail.com>
2025-12-03 13:09:49 +01:00
Pablo Alba
00f8eac8fa 🐛 Fix can't delete unsaved variant prop (#7878) 2025-12-03 13:03:17 +01:00
Dalai Felinto
df7caacb45 🐛 Fix crash in token grid view due to tooltip validation (#7887)
The color tokens in grid view have a tooltip which is a map.
This is done so the frontend can render:

```
Name: foo
Resolved value: #000000
```

However the validation scheme for tooltips was only accepting functions
and strings.

---

How to reproduce the original (unreported) crash:
* Create a color token
* Create a shape, add a fill
* Pick a color, chose the Token options
* Click on the Grid View

Crash: `{:hint "invalid props on component tooltip*\n\n  -> 'content'
    should be a string\n"}`

Signed-off-by: Dalai Felinto <dalai@blender.org>
Co-authored-by: Dalai Felinto <dalai@blender.org>
2025-12-03 13:01:36 +01:00
Marina López
49bbdfb257 🐛 Fix U and E icon displayed in project list (#7875)
* 🐛 Fix U and E icon displayed in project lis

* 🐛 Fix U and E icon displayed in project list
2025-12-03 12:50:51 +01:00
Aitor Moreno
4e84deca44 Merge pull request #7879 from penpot/elenatorro-12797-fix-update-spans
🐛 Fix paragraph with text spans with multiple styles
2025-12-03 11:30:17 +01:00
Aitor Moreno
0d21e52068 🐛 Fix applyStylesTo entire selection 2025-12-03 11:07:33 +01:00
alonso.torres
1b29e9a50f 🐛 Fix race condition with fix fonts patch 2025-12-03 10:39:05 +01:00
Andrey Antukh
94af978be8 🐛 Fix casing on a translation of export files modal option 2025-12-03 10:22:45 +01:00
Elena Torro
9f567c3bf4 🐛 Fix italic variant 2025-12-03 08:59:25 +01:00
Elena Torro
1ba15e5d10 🐛 Do not merge fill styles 2025-12-03 08:55:11 +01:00
Andrey Antukh
57fcec5afc 🐛 Make from-synthetic-clipboard-event function return always a stream
Causes an execption on steam processing when it returns nil
2025-12-03 08:32:38 +01:00
Andrey Antukh
58f82da61e 🐛 Fix exception on paste text on comments input 2025-12-03 08:20:58 +01:00
Andrey Antukh
a28c5b61ca 💄 Adapt viewport paste code codestyle
And remove some not necessary constructions
2025-12-03 08:09:13 +01:00
Andrey Antukh
9123d199b7 🐛 Fix scripts/fmt 2025-12-02 17:43:21 +01:00
alonso.torres
37e45a8bbf 🐛 Fix race condition with text and type 2025-12-02 17:28:20 +01:00
alonso.torres
3471d40f46 🐛 Fix problem with boolean shapes updates 2025-12-02 17:28:20 +01:00
Elena Torro
c6b64a8e39 🐛 Fix selectAll on mixed span styles 2025-12-02 16:50:48 +01:00
Elena Torro
511e80c948 🐛 Fix merge fill styles when there are multiple fills 2025-12-02 16:50:04 +01:00
Elena Torró
f5a640d104 Merge pull request #7876 from penpot/ladybenko-12805-slow-loading
🐛 Fix viewport not being fully drawn on first load until a mouse …
2025-12-02 15:31:43 +01:00
Belén Albeza
3ae7c514e4 🐛 Fix viewport not being fully drawn on first load until a mouse hover 2025-12-02 15:06:28 +01:00
Andrey Antukh
eeaf28bb25 📎 Disable caddy logging 2025-12-02 13:27:09 +01:00
alonso.torres
fad9ed1c48 🐛 Fix problem with reordering layers 2025-12-02 12:27:00 +01:00
alonso.torres
0caaefefea 🐛 Fix outline with single click text creation 2025-12-02 11:08:58 +01:00
Elena Torro
b179aa79b1 🐛 Fix create empty text on click regression 2025-12-02 11:08:58 +01:00
Andrey Antukh
6b8091bb90 Make devenv https and http2 capable (#7871)
Making it more similar on how it runs on production
environments and improves large amount of files loading
thanks to http2.
2025-12-02 10:49:37 +01:00
Aitor Moreno
405ddb60d8 🐛 Fix letter spacing applied to paragraph 2025-12-02 10:45:19 +01:00
Madalena Melo
bba02473d5 📚 Update subtitles in the new user guide cards (#7823)
Co-authored-by: Andres Gonzalez <andres.gonzalez79@gmail.com>
2025-12-02 09:21:05 +01:00
Elena Torró
95c0d42d5b Merge pull request #7868 from penpot/alotor-fix-flex-tools
🐛 Fix visual feedback on padding/margin/gaps modified
2025-12-01 17:51:44 +01:00
alonso.torres
721b337511 🐛 Fix visual feedback on padding/margin/gaps modified 2025-12-01 16:31:15 +01:00
Elena Torró
359379be09 Merge pull request #7867 from penpot/azazeln28-add-text-editor-v2-tests-to-staging
 Add text editor v2 integration tests
2025-12-01 16:11:25 +01:00
Aitor Moreno
876d5783cf Add text editor v2 integration tests 2025-12-01 15:56:52 +01:00
Elena Torro
786f73767b 🔧 Normalize font attributes to support old formats 2025-12-01 14:59:24 +01:00
Andrey Antukh
95b7784a42 🐛 Change internal ordering on how email parts are assembled
This fixes the html email rendering on gmail. Other clients (like proton,
emailcatcher) properly renders html independently of the order of parts
on the multipart email structure but gmail requires that html should be
the last one.
2025-12-01 14:27:21 +01:00
Andrey Antukh
4690f740b9 Add minor improvements to the audit module 2025-12-01 13:57:55 +01:00
Andrey Antukh
4282cdcd2c Merge remote-tracking branch 'origin/staging' into staging-render 2025-12-01 10:11:06 +01:00
Alejandro Alonso
e889413f26 🐛 Fix nested shadows clipping 2025-12-01 09:22:23 +01:00
Elena Torró
115273b478 Merge pull request #7852 from penpot/alotor-flex-issues
🐛 Fix flex problems in new render
2025-11-28 14:10:42 +01:00
Elena Torró
fdddd3284a Merge pull request #7859 from penpot/ladybenko-12801-fix-mismatched-fonts
🐛 Fix mismatch between fonts for rendered and selected text when no fallback fonts apply
2025-11-28 14:10:17 +01:00
Belén Albeza
51385a04a0 🐛 Fix mismatch between fonts for rendered and selected text when no fallback fonts apply 2025-11-28 13:54:17 +01:00
Belén Albeza
f96ed8ccd6 Fix playwright tests 2025-11-28 13:25:13 +01:00
Belén Albeza
bda5de5c1b 🔧 Update google fonts list 2025-11-28 13:25:13 +01:00
alonso.torres
59f3b4db4c 🐛 Fix problem with auto-size and element margins 2025-11-28 12:12:19 +01:00
alonso.torres
7ee03ad911 🐛 Fix problem with grid layout editor 2025-11-28 12:12:09 +01:00
alonso.torres
130b8c8214 🐛 Fix problems with flex layout in new render 2025-11-28 10:49:55 +01:00
alonso.torres
0198d41757 🐛 Fix crash when cleanup 2025-11-28 10:44:54 +01:00
alonso.torres
567a955151 🐛 Fix problem with change gap/margin/padding 2025-11-28 10:44:38 +01:00
134 changed files with 17662 additions and 12649 deletions

View File

@@ -61,6 +61,7 @@ example. It's still usable as before, we just removed the example.
### :heart: Community contributions (Thank you!)
- 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)
### :sparkles: New features & Enhancements
@@ -88,6 +89,9 @@ example. It's still usable as before, we just removed the example.
- Fix input confirmation behavior is not uniform [Taiga #12294](https://tree.taiga.io/project/penpot/issue/12294)
- Fix copy/pasting application/transit+json [Taiga #12721](https://tree.taiga.io/project/penpot/issue/12721)
- Fix problem with plugins content attribute [Plugins #209](https://github.com/penpot/penpot-plugins/issues/209)
- Fix U and E icon displayed in project list [Taiga #12806](https://tree.taiga.io/project/penpot/issue/12806)
- Fix unpublish library modal not scrolling a long file list [Taiga #12285](https://tree.taiga.io/project/penpot/issue/12285)
- Fix incorrect interaction betwen hower and scroll on assets sidebar [Taiga #12389](https://tree.taiga.io/project/penpot/issue/12389)
## 2.11.1

View File

@@ -3,6 +3,7 @@
export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
export PENPOT_SECRET_KEY=super-secret-devenv-key
export PENPOT_HOST=devenv
export PENPOT_PUBLIC_URI=https://localhost:3449
export PENPOT_FLAGS="\
$PENPOT_FLAGS \

View File

@@ -106,17 +106,17 @@
(let [content-part (MimeBodyPart.)
alternative-mpart (MimeMultipart. "alternative")]
(when-let [content (get body "text/plain")]
(let [text-part (MimeBodyPart.)]
(.setText text-part ^String content ^String charset)
(.addBodyPart alternative-mpart text-part)))
(when-let [content (get body "text/html")]
(let [html-part (MimeBodyPart.)]
(.setContent html-part ^String content
(str "text/html; charset=" charset))
(.addBodyPart alternative-mpart html-part)))
(when-let [content (get body "text/plain")]
(let [text-part (MimeBodyPart.)]
(.setText text-part ^String content ^String charset)
(.addBodyPart alternative-mpart text-part)))
(.setContent content-part alternative-mpart)
(.addBodyPart mixed-mpart content-part))

View File

@@ -79,18 +79,6 @@
(remove #(contains? reserved-props (key %))))
props))
(defn event-from-rpc-params
"Create a base event skeleton with pre-filled some important
data that can be extracted from RPC params object"
[params]
(let [context {:external-session-id (::rpc/external-session-id params)
:external-event-origin (::rpc/external-event-origin params)
:triggered-by (::rpc/handler-name params)}]
{::type "action"
::profile-id (::rpc/profile-id params)
::ip-addr (::rpc/ip-addr params)
::context (d/without-nils context)}))
(defn get-external-session-id
[request]
(when-let [session-id (yreq/get-header request "x-external-session-id")]
@@ -99,13 +87,24 @@
(str/blank? session-id))
session-id)))
(defn- get-external-event-origin
(defn- get-client-event-origin
[request]
(when-let [origin (yreq/get-header request "x-event-origin")]
(when-not (or (> (count origin) 256)
(= origin "null")
(when-not (or (= origin "null")
(str/blank? origin))
origin)))
(str/prune origin 200))))
(defn get-client-user-agent
[request]
(when-let [user-agent (yreq/get-header request "user-agent")]
(str/prune user-agent 500)))
(defn- get-client-version
[request]
(when-let [origin (yreq/get-header request "x-frontend-version")]
(when-not (or (= origin "null")
(str/blank? origin))
(str/prune origin 100))))
;; --- SPECS
@@ -134,6 +133,33 @@
(def ^:private check-event
(sm/check-fn schema:event))
(defn- prepare-context-from-request
[request]
(let [client-event-origin (get-client-event-origin request)
client-version (get-client-version request)
client-user-agent (get-client-user-agent request)
session-id (get-external-session-id request)
token-id (::actoken/id request)]
(d/without-nils
{:external-session-id session-id
:access-token-id (some-> token-id str)
:client-event-origin client-event-origin
:client-user-agent client-user-agent
:client-version client-version
:version (:full cf/version)})))
(defn event-from-rpc-params
"Create a base event skeleton with pre-filled some important
data that can be extracted from RPC params object"
[params]
(let [context (some-> params meta ::http/request prepare-context-from-request)
event {::type "action"
::profile-id (or (::rpc/profile-id params) uuid/zero)
::ip-addr (::rpc/ip-addr params)}]
(cond-> event
(some? context)
(assoc ::context context))))
(defn prepare-event
[cfg mdata params result]
(let [resultm (meta result)
@@ -148,18 +174,10 @@
(merge (::props resultm))
(dissoc :profile-id)
(dissoc :type)))
(clean-props))
token-id (::actoken/id request)
context (-> (::context resultm)
(assoc :external-session-id
(get-external-session-id request))
(assoc :external-event-origin
(get-external-event-origin request))
(assoc :access-token-id (some-> token-id str))
(d/without-nils))
context (merge (::context resultm)
(prepare-context-from-request request))
ip-addr (inet/parse-request request)]
{::type (or (::type resultm)

View File

@@ -234,16 +234,15 @@
"Calculate the boolean content from shape and objects. Returns a
packed PathData instance"
[shape objects]
(let [content (if (fn? wasm:calc-bool-content)
(wasm:calc-bool-content (get shape :bool-type)
(get shape :shapes))
(calc-bool-content* shape objects))]
(let [content (calc-bool-content* shape objects)]
(impl/path-data content)))
(defn update-bool-shape
"Calculates the selrect+points for the boolean shape"
[shape objects]
(let [content (calc-bool-content shape objects)
(let [content (if (fn? wasm:calc-bool-content)
(wasm:calc-bool-content shape objects)
(calc-bool-content shape objects))
shape (assoc shape :content content)]
(update-geometry shape)))

View File

@@ -101,6 +101,38 @@ RUN set -eux; \
corepack enable; \
rm -rf /tmp/nodejs.tar.gz;
################################################################################
## CADDYSERVER SETUP
################################################################################
FROM base AS setup-caddy
ENV CADDY_VERSION=2.10.2
RUN set -eux; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
aarch64|arm64) \
BINARY_URL="https://github.com/caddyserver/caddy/releases/download/v${CADDY_VERSION}/caddy_${CADDY_VERSION}_linux_arm64.tar.gz"; \
;; \
amd64|x86_64) \
BINARY_URL="https://github.com/caddyserver/caddy/releases/download/v${CADDY_VERSION}/caddy_${CADDY_VERSION}_linux_amd64.tar.gz"; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; \
exit 1; \
;; \
esac; \
curl -LfsSo /tmp/caddy.tar.gz ${BINARY_URL}; \
mkdir -p /tmp/caddy; \
cd /tmp/caddy; \
tar -xf /tmp/caddy.tar.gz; \
chown -R root /tmp/caddy; \
mv /tmp/caddy/caddy /usr/bin/; \
rm -rf /tmp/caddy.tar.gz; \
rm -rf /tmp/caddy;
################################################################################
## JVM SETUP
################################################################################
@@ -393,6 +425,7 @@ COPY --from=setup-utils /opt/utils /opt/utils
COPY --from=setup-rust /opt/cargo /opt/cargo
COPY --from=setup-rust /opt/rustup /opt/rustup
COPY --from=setup-rust /opt/emsdk /opt/emsdk
COPY --from=setup-caddy /usr/bin/caddy /usr/bin/caddy
COPY files/nginx.conf /etc/nginx/nginx.conf
COPY files/nginx-mime.types /etc/nginx/mime.types
@@ -403,6 +436,9 @@ COPY files/vimrc /root/.vimrc
COPY files/tmux.conf /root/.tmux.conf
COPY files/sudoers /etc/sudoers
COPY files/Caddyfile /home/
COPY files/selfsigned.crt /home/
COPY files/selfsigned.key /home/
COPY files/start-tmux.sh /home/start-tmux.sh
COPY files/start-tmux-back.sh /home/start-tmux-back.sh
COPY files/entrypoint.sh /home/entrypoint.sh

View File

@@ -33,6 +33,8 @@ services:
- 3447:3447
- 3448:3448
- 3449:3449
- 3449:3449/udp
- 3450:3450
- 6006:6006
- 6060:6060
- 6061:6061

View File

@@ -0,0 +1,12 @@
{
auto_https off
}
localhost:3449 {
reverse_proxy localhost:4449
tls /home/selfsigned.crt /home/selfsigned.key
}
http://localhost:3450 {
reverse_proxy localhost:4449
}

View File

@@ -2,4 +2,5 @@
set -e
nginx
tail -f /dev/null
caddy start -c /home/Caddyfile
tail -f /dev/null;

View File

@@ -12,7 +12,7 @@ http {
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 0;
keepalive_timeout 100;
types_hash_max_size 2048;
server_tokens off;
@@ -55,7 +55,7 @@ http {
proxy_cache_key "$host$request_uri";
server {
listen 3449 default_server;
listen 4449 default_server;
server_name _;
client_max_body_size 300M;
@@ -231,7 +231,6 @@ http {
}
add_header Cache-Control "no-store";
add_header Connection close always;
# This header is what we need to use on prod
# add_header Cache-Control "public, must-revalidate, max-age=0";
try_files $uri /index.html$is_args$args /index.html =404;

View File

@@ -0,0 +1,22 @@
-----BEGIN CERTIFICATE-----
MIIDuzCCAqOgAwIBAgIUa3THJQSn1+ErK65g1jDL0tjUkBYwDQYJKoZIhvcNAQEL
BQAwXzELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBUxvY2FsMQ4wDAYDVQQHDAVMb2Nh
bDEOMAwGA1UECgwFTG9jYWwxDDAKBgNVBAsMA0RldjESMBAGA1UEAwwJbG9jYWxo
b3N0MB4XDTI1MTIwMjA4MjUyM1oXDTI2MTIwMjA4MjUyM1owXzELMAkGA1UEBhMC
VVMxDjAMBgNVBAgMBUxvY2FsMQ4wDAYDVQQHDAVMb2NhbDEOMAwGA1UECgwFTG9j
YWwxDDAKBgNVBAsMA0RldjESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG
9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyVIlfpIPE+QyL/q7IQOilEA7wEOZ6wbsh2Fr
59H1gSLFvgoCxI6RVUkQ/MFRnw/r1ZbAqRpc2xAl5a9Ml14q20Zlj6dAHsWX6O2J
EwNsD18dQmX3BncnjV3yCZM2iQcMFKuXG4KQNdIQNNvdIgtlrHYp0ohS9s3XC7cj
KxNrm/pW9EAXfn9AYDd/qER090L2E4ipP9m/5l3MjinNc4l2kpH9rLOgb79H0RLt
PK3/KP8ErZhAvzdmDBAdM5Z5K37b+TfB/kSVNUKL6qyw5CCjlShERLhBNprlnRfz
tHNIQ1RHq3qJJN19ZnJrLqICuQ5ztvj7hBDiOSV0LnmyKgXr6wIDAQABo28wbTAd
BgNVHQ4EFgQUPL8WGf6z/wB8TimJBx1zybsIeikwHwYDVR0jBBgwFoAUPL8WGf6z
/wB8TimJBx1zybsIeikwDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2Nh
bGhvc3SHBH8AAAEwDQYJKoZIhvcNAQELBQADggEBACMMVyR3kbNxnzuUc2lahKH4
cPXVWOsvCvnDtjzm41XmKjUJTbtjn3p5d/ZmLbZ4zzIQULfWXO3XG/HevkvVo0g6
6pJXTXc6C6ZhFG0rIYMcPPzmGmalDV5n+lUaCVx5XbFFxvRQ7893auwhRATdwGs+
xiMyYbE2w9otKqyDItmJZJ5nW6vmXJ42YHxlXF18u9U88xqtOSMd5xZahbsmw7Gg
A4/o4TPoAX5QfA306sL443WaczsF7bmsTf9qcYa/3xxQkP5Seyqx8ePWpS22qysE
jG6XPpymxb6sb2mVaFBAzhEMb/eBvE9nRAopxmB7uV4TbqC51K/U3uo6jFX4Jbw=
-----END CERTIFICATE-----

View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDJUiV+kg8T5DIv
+rshA6KUQDvAQ5nrBuyHYWvn0fWBIsW+CgLEjpFVSRD8wVGfD+vVlsCpGlzbECXl
r0yXXirbRmWPp0AexZfo7YkTA2wPXx1CZfcGdyeNXfIJkzaJBwwUq5cbgpA10hA0
290iC2WsdinSiFL2zdcLtyMrE2ub+lb0QBd+f0BgN3+oRHT3QvYTiKk/2b/mXcyO
Kc1ziXaSkf2ss6Bvv0fREu08rf8o/wStmEC/N2YMEB0zlnkrftv5N8H+RJU1Qovq
rLDkIKOVKEREuEE2muWdF/O0c0hDVEereokk3X1mcmsuogK5DnO2+PuEEOI5JXQu
ebIqBevrAgMBAAECggEABqtE+LNn8nW9v98jcc2IBjc2g4D5yVJaZYWxqGVJJ7T6
Lfhw7Qf4AoZAHM9en9FMM7Ahw7hO2SboynoLJHyHGOp1FNQqiJptFNdBkjKr0rqI
4pk0HK+3zLQO/4gz50gne0vP3qZtlorV5Jpf8e/Et3jWm9XOQcTB2e6AKL4k827B
dv4Tld+/7PoZVXjahfrUWuIZr5mzyF1eUkD8sPOpdr3HJxSueqsOMjbG8XMRqCQ+
5eCWWSW5yPQlMr7M7cXM+a0k73Xn1sKl7fP3/9byji25zxGUaMu5RA1kw0Oqseid
RXuRxGphGZgnx1aFxDAPg3FtmGch7/Cc6WfqboOL0QKBgQD4GZO1gGaE8cg4lvuo
ZUX2YJu6UJuNOmuhfvG3ui4WO9PHy3btc2q+3kutSuBcyIjhi+qbXasBcX/QOOJF
udyTZc5PopNkJojS4JdXAZCiu5sKI3lp4DIt9qNISlXGgrJgdxGUO+DzarBctXdn
BSwXFw5hcjJjl7wsPGQl1tBTQwKBgQDPuz5MEM5ZeUe9CT5sQDq/ld0u4aL5AHmx
aaA2gzDgd9l2R5wHX6wLzjoVWXOmeqaYzJopt2JN4iXrtbjWkyePgZeZMyWoyJ/v
clW9bi8HM9f9EpPr7czSj9sLUnsjd9cuTD+JuXK//jRGbRpw7r7nWtLHImjj6d2v
APZRq0v2OQKBgBcESG/OObSbubeGSlKVEqiIzem7ELNJeDLDVCl3XE8zvbILbj0Z
OA39EYhCKg5xjEFgeaNwTS0VGoZ2wIc3dv81sq4wpvvjl035CBFKU+DFBt0p7Vml
MwKQnxVV0B9agLHyWe8mnvf2LeZr72ffUvfRa8QelA4pRYvVDnV0OF+BAoGAW6rM
+tQPuvwB5DFIEozlX9XKHP4E5MyI5vktceDCmMtKcx92gup9CVif2Pv4ROaqzZK8
FNyPzL6W7UTrpASb2H/fXgNsAudFbGyP2V/d8Ne34D1qeRoe4GwKxRxIqoYftpZ/
E096i66pcsqCeINiSsWRbb6JesmgwbEzAScOBkECgYEA6O/Dibc9PaqRpaiE6Qut
S3W/Rr1Pd1jbN4rOVI2TFCgMJQmc6jOdq2fCntR9acsa8HPx+djOlXTUBPKBZ/Ae
p8umRdXVWcNMnwWVWHt7tsEuR/gYkxQ5xjXeS1VDPnEre9+EaevMBuVs8HdRsKQO
uzvNGeAFEfqwIqn7CFQ+ndU=
-----END PRIVATE KEY-----

View File

@@ -10,19 +10,19 @@ desc: Begin with the Penpot user guide! Get quickstarts, shortcuts, and tutorial
<li>
<a href="/user-guide/account-teams/your-account">
<h2>Your account →</h2>
<p>Ways to start with Penpot</p>
<p>Access your account settings and manage personal access tokens</p>
</a>
</li>
<li>
<a href="/user-guide/account-teams/teams">
<h2>Teams →</h2>
<p>Info of interest about Penpot</p>
<p>Create and manage your teams</p>
</a>
</li>
<li>
<a href="/user-guide/account-teams/comments/">
<h2>Comments →</h2>
<p>Info of interest about Penpot</p>
<p>Give and receive feedback right over your designs</p>
</a>
</li>
</ul>

View File

@@ -10,31 +10,31 @@ desc: Begin with the Penpot user guide! Get quickstarts, shortcuts, and tutorial
<li>
<a href="/user-guide/design-systems/assets">
<h2>Assets →</h2>
<p>Ways to start with Penpot</p>
<p>Store elements and styles to easily reuse them</p>
</a>
</li>
<li>
<a href="/user-guide/design-systems/libraries">
<h2>Libraries →</h2>
<p>Info of interest about Penpot</p>
<p>Organize and manage your stored elements with Libraries</p>
</a>
</li>
<li>
<a href="/user-guide/design-systems/components">
<h2>Components →</h2>
<p>Speed your design workflow</p>
<p>Speed your design workflow with reusable components</p>
</a>
</li>
<li>
<a href="/user-guide/design-systems/variants">
<h2>Variants →</h2>
<p>Info of interest about Penpot</p>
<p>Group components into a single, customizable one</p>
</a>
</li>
<li>
<a href="/user-guide/design-systems/design-tokens">
<h2>Design Tokens →</h2>
<p>Info of interest about Penpot</p>
<p>Synchronize visual elements across your designs</p>
</a>
</li>
</ul>

View File

@@ -5,7 +5,7 @@ desc: Use Penpot's libraries for reusable design elements! Learn to create, mana
---
<h1 id="libraries">Libraries</h1>
<p class="main-paragraph">Libraries may include components, graphics, colors and typographies. Learn how to create and manage them to better organize the pieces of your designs and speed your workflow.</p>
<p class="main-paragraph">Libraries may include components, colors and typographies. Learn how to create and manage them to better organize the pieces of your designs and speed your workflow.</p>
<h3 id="file-libraries">File libraries</h3>
<p>Each file has its own file library which is where the assets that belong to this file are stored.</p>

View File

@@ -10,31 +10,31 @@ desc: Begin with the Penpot user guide! Get quickstarts, shortcuts, and tutorial
<li>
<a href="/user-guide/designing/workspace-basics">
<h2>Workspace basics →</h2>
<p>Workspace basics</p>
<p>Get to know the Workspace, where designs are created</p>
</a>
</li>
<li>
<a href="/user-guide/designing/layers">
<h2>Layers →</h2>
<p>Info of interest about Penpot</p>
<p>Objects available in Penpot and how to get the most of them</p>
</a>
</li>
<li>
<a href="/user-guide/designing/color-stroke/">
<h2>Color & Strokes→</h2>
<p>Info of interest about Penpot</p>
<p>Styling options available for each layer</p>
</a>
</li>
<li>
<a href="/user-guide/designing/text-typo">
<h2>Text & Typography→</h2>
<p>Info of interest about Penpot</p>
<p>Styling text content & using custom fonts</p>
</a>
</li>
<li>
<a href="/user-guide/designing/flexible-layouts">
<h2>Flexible layouts →</h2>
<p>Info of interest about Penpot</p>
<p>Create designs that adapt automatically</p>
</a>
</li>
</ul>

View File

@@ -10,13 +10,13 @@ desc: Begin with the Penpot user guide! Get quickstarts, shortcuts, and tutorial
<li>
<a href="/user-guide/export-import/export-import-files/">
<h2>Export/Import Penpot files →</h2>
<p>Ways to start with Penpot</p>
<p>How to export and import your Penpot files</p>
</a>
</li>
<li>
<a href="/user-guide/export-import/exporting-layers/">
<h2>Exporting layers →</h2>
<p>Exporting layers</p>
<p>How to export elements from your design into different file formats</p>
</a>
</li>
</ul>

View File

@@ -16,7 +16,7 @@ desc: Begin with the Penpot user guide! Get quickstarts, shortcuts, and tutorial
<li>
<a href="/user-guide/first-steps/the-interface">
<h2>Interface tour →</h2>
<p>Info of interest about Penpot</p>
<p>Take a tour of Penpot's main areas</p>
</a>
</li>
<li>
@@ -28,7 +28,7 @@ desc: Begin with the Penpot user guide! Get quickstarts, shortcuts, and tutorial
<li>
<a href="/user-guide/first-steps/info">
<h2>Tutorials & info →</h2>
<p>Info of interest about Penpot</p>
<p>Useful resources to better understand Penpot</p>
</a>
</li>
</ul>

View File

@@ -22,49 +22,49 @@ eleventyNavigation:
<li>
<a href="/user-guide/designing/layers/">
<h2>Layers</h2>
<p>Ways to start with Penpot</p>
<p>Objects available in Penpot and how to get the most of them</p>
</a>
</li>
<li>
<a href="/user-guide/designing/flexible-layouts/">
<h2>Flexible layouts</h2>
<p>Create designs that adapt automatically.</p>
<p>Create designs that adapt automatically</p>
</a>
</li>
<li>
<a href="/user-guide/design-systems/components/">
<h2>Components</h2>
<p>Ways to start with Penpot</p>
<p>Speed your design workflow with reusable components</p>
</a>
</li>
<li>
<a href="/user-guide/design-systems/variants/">
<h2>Variants</h2>
<p>Penpot's main areas and features</p>
<p>Group components into a single, customizable one</p>
</a>
</li>
<li>
<a href="/user-guide/design-systems/design-tokens/">
<h2>Design Tokens</h2>
<p>Penpot's main areas and features</p>
<p>Synchronize visual elements across your designs</p>
</a>
</li>
<li>
<a href="/user-guide/dev-tools/#inspect-design">
<h2>Inspect design</h2>
<p>Ways to start with Penpot</p>
<p>Get production-ready code</p>
</a>
</li>
<li>
<a href="/user-guide/prototyping-testing/prototyping/">
<h2>Prototyping</h2>
<p>Ways to start with Penpot</p>
<p>Build interactive prototypes to mimic your product behaviour</p>
</a>
</li>
<li>
<a href="/user-guide/design-systems/libraries/">
<h2>Libraries</h2>
<p>Ways to start with Penpot</p>
<p>Organize and manage your stored elements with Libraries</p>
</a>
</li>
</ul>

View File

@@ -10,13 +10,13 @@ desc: Begin with the Penpot user guide! Get quickstarts, shortcuts, and tutorial
<li>
<a href="/user-guide/prototyping-testing/prototyping">
<h2>Prototyping →</h2>
<p>Ways to start with Penpot</p>
<p>Build interactive prototypes to mimic your product behaviour</p>
</a>
</li>
<li>
<a href="/user-guide/prototyping-testing/testing-view-mode">
<h2>Testing: View mode →</h2>
<p>Info of interest about Penpot</p>
<p>Test your designs and play the interactions</p>
</a>
</li>
</ul>

View File

@@ -0,0 +1,58 @@
{
"~:features": {
"~#set": [
"layout/grid",
"styles/v2",
"fdata/pointer-map",
"fdata/objects-map",
"components/v2",
"fdata/shape-data-type"
]
},
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "New File 1",
"~:revn": 11,
"~:modified-at": "~m1713873823633",
"~:id": "~uc7ce0794-0992-8105-8004-38f280443849",
"~:is-shared": false,
"~:version": 46,
"~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b",
"~:created-at": "~m1713536343369",
"~:data": {
"~:pages": [
"~u66697432-c33d-8055-8006-2c62cc084cad"
],
"~:pages-index": {
"~u66697432-c33d-8055-8006-2c62cc084cad": {
"~#penpot/pointer": [
"~ude58c8f6-c5c2-8196-8004-3df9e2e52d88",
{
"~:created-at": "~m1713873823636"
}
]
}
},
"~:id": "~uc7ce0794-0992-8105-8004-38f280443849",
"~:options": {
"~:components-v2": true
},
"~:recent-colors": [
{
"~:color": "#0000ff",
"~:opacity": 1,
"~:id": null,
"~:file-id": null,
"~:image": null
}
]
}
}

View File

@@ -0,0 +1,345 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"plugins/runtime",
"design-tokens/v1",
"layout/grid",
"styles/v2",
"fdata/pointer-map",
"fdata/objects-map",
"components/v2",
"fdata/shape-data-type",
"text-editor/v2"
]
},
"~:team-id": "~u9e6e22b2-db76-81d6-8006-75d7cdbb8bad",
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "Bug 11552",
"~:revn": 3,
"~:modified-at": "~m1753957736516",
"~:vern": 0,
"~:id": "~u238a17e0-75ff-8075-8006-934586ea2230",
"~:is-shared": false,
"~:migrations": {
"~#ordered-set": [
"legacy-2",
"legacy-3",
"legacy-5",
"legacy-6",
"legacy-7",
"legacy-8",
"legacy-9",
"legacy-10",
"legacy-11",
"legacy-12",
"legacy-13",
"legacy-14",
"legacy-16",
"legacy-17",
"legacy-18",
"legacy-19",
"legacy-25",
"legacy-26",
"legacy-27",
"legacy-28",
"legacy-29",
"legacy-31",
"legacy-32",
"legacy-33",
"legacy-34",
"legacy-36",
"legacy-37",
"legacy-38",
"legacy-39",
"legacy-40",
"legacy-41",
"legacy-42",
"legacy-43",
"legacy-44",
"legacy-45",
"legacy-46",
"legacy-47",
"legacy-48",
"legacy-49",
"legacy-50",
"legacy-51",
"legacy-52",
"legacy-53",
"legacy-54",
"legacy-55",
"legacy-56",
"legacy-57",
"legacy-59",
"legacy-62",
"legacy-65",
"legacy-66",
"legacy-67",
"0001-remove-tokens-from-groups",
"0002-normalize-bool-content-v2",
"0002-clean-shape-interactions",
"0003-fix-root-shape",
"0003-convert-path-content-v2",
"0004-clean-shadow-color",
"0005-deprecate-image-type",
"0006-fix-old-texts-fills",
"0007-clear-invalid-strokes-and-fills-v2",
"0008-fix-library-colors-v4",
"0009-clean-library-colors",
"0009-add-partial-text-touched-flags"
]
},
"~:version": 67,
"~:project-id": "~u9e6e22b2-db76-81d6-8006-75d7cdc30669",
"~:created-at": "~m1753957644225",
"~:data": {
"~:pages": ["~u238a17e0-75ff-8075-8006-934586ea2231"],
"~:pages-index": {
"~u238a17e0-75ff-8075-8006-934586ea2231": {
"~:objects": {
"~u00000000-0000-0000-0000-000000000000": {
"~#shape": {
"~:y": 0,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:name": "Root Frame",
"~:width": 0.01,
"~:type": "~:frame",
"~:points": [
{
"~#point": {
"~:x": 0.0,
"~:y": 0.0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0.0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0.01
}
},
{
"~#point": {
"~:x": 0.0,
"~:y": 0.01
}
}
],
"~:r2": 0,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:r3": 0,
"~:r1": 0,
"~:id": "~u00000000-0000-0000-0000-000000000000",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 0,
"~:proportion": 1.0,
"~:r4": 0,
"~:selrect": {
"~#rect": {
"~:x": 0,
"~:y": 0,
"~:width": 0.01,
"~:height": 0.01,
"~:x1": 0,
"~:y1": 0,
"~:x2": 0.01,
"~:y2": 0.01
}
},
"~:fills": [
{
"~:fill-color": "#FFFFFF",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 0.01,
"~:flip-y": null,
"~:shapes": ["~ucc6f0580-449c-8019-8006-9345db077fa0"]
}
},
"~ucc6f0580-449c-8019-8006-9345db077fa0": {
"~#shape": {
"~:y": 438,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:grow-type": "~:auto-width",
"~:content": {
"~:type": "root",
"~:key": "1s4am1jl24s",
"~:children": [
{
"~:type": "paragraph-set",
"~:children": [
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:children": [
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:font-id": "sourcesanspro",
"~:key": "13p0zwl2yhc",
"~:font-size": "14",
"~:font-weight": "400",
"~:typography-ref-file": null,
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro",
"~:text": "Lorem ipsum"
}
],
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:key": "20hf3kmyoub",
"~:font-size": "14",
"~:font-weight": "400",
"~:typography-ref-file": null,
"~:text-direction": "ltr",
"~:type": "paragraph",
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro"
}
]
}
],
"~:vertical-align": "top"
},
"~:hide-in-viewer": false,
"~:name": "Lorem ipsum",
"~:width": 77,
"~:type": "~:text",
"~:points": [
{
"~#point": {
"~:x": 404,
"~:y": 438
}
},
{
"~#point": {
"~:x": 481,
"~:y": 438
}
},
{
"~#point": {
"~:x": 481,
"~:y": 455
}
},
{
"~#point": {
"~:x": 404,
"~:y": 455
}
}
],
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:id": "~ucc6f0580-449c-8019-8006-9345db077fa0",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:x": 404,
"~:selrect": {
"~#rect": {
"~:x": 404,
"~:y": 438,
"~:width": 77,
"~:height": 17,
"~:x1": 404,
"~:y1": 438,
"~:x2": 481,
"~:y2": 455
}
},
"~:flip-x": null,
"~:height": 17,
"~:flip-y": null
}
}
},
"~:id": "~u238a17e0-75ff-8075-8006-934586ea2231",
"~:name": "Page 1"
}
},
"~:id": "~u238a17e0-75ff-8075-8006-934586ea2230",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
}
}
}

View File

@@ -1,5 +1 @@
{
"~:revn": 2,
"~:lagged": []
}
w

View File

@@ -0,0 +1,4 @@
{
"~:revn": 2,
"~:lagged": []
}

View File

@@ -0,0 +1,9 @@
[
{
"~:id": "~u088df3d4-d383-80f6-8004-527e50ea4f1f",
"~:revn": 21,
"~:file-id": "~uc7ce0794-0992-8105-8004-38f280443849",
"~:session-id": "~u1dc6d4fa-7bd3-803a-8004-527dd9df2c62",
"~:changes": []
}
]

View File

@@ -0,0 +1,36 @@
export class Clipboard {
static Permission = {
ONLY_READ: ['clipboard-read'],
ONLY_WRITE: ['clipboard-write'],
ALL: ['clipboard-read', 'clipboard-write']
}
static enable(context, permissions) {
return context.grantPermissions(permissions)
}
static writeText(page, text) {
return page.evaluate((text) => navigator.clipboard.writeText(text), text);
}
static readText(page) {
return page.evaluate(() => navigator.clipboard.readText());
}
constructor(page, context) {
this.page = page
this.context = context
}
enable(permissions) {
return Clipboard.enable(this.context, permissions);
}
writeText(text) {
return Clipboard.writeText(this.page, text);
}
readText() {
return Clipboard.readText(this.page);
}
}

View File

@@ -0,0 +1,30 @@
export class Transit {
static parse(value) {
if (typeof value !== 'string')
return value
if (value.startsWith('~'))
return value.slice(2)
return value
}
static get(object, ...path) {
let aux = object;
for (const name of path) {
if (typeof name !== 'string') {
if (!(name in aux)) {
return undefined;
}
aux = aux[name];
} else {
const transitName = `~:${name}`;
if (!(transitName in aux)) {
return undefined;
}
aux = aux[transitName];
}
}
return this.parse(aux);
}
}

View File

@@ -1,4 +1,27 @@
export class BasePage {
/**
* Mocks multiple RPC calls in a single call.
*
* @param {Page} page
* @param {object<string, string>} paths
* @param {*} options
* @returns {Promise<void>}
*/
static async mockRPCs(page, paths, options) {
for (const [path, jsonFilename] of Object.entries(paths)) {
await this.mockRPC(page, path, jsonFilename, options)
}
}
/**
* Mocks an RPC call using a file.
*
* @param {Page} page
* @param {string} path
* @param {string} jsonFilename
* @param {*} options
* @returns {Promise<void>}
*/
static async mockRPC(page, path, jsonFilename, options) {
if (!page) {
throw new TypeError("Invalid page argument. Must be a Playwright page.");
@@ -93,6 +116,10 @@ export class BasePage {
return this.#page;
}
async mockRPCs(paths, options) {
return BasePage.mockRPCs(this.page, paths, options);
}
async mockRPC(path, jsonFilename, options) {
return BasePage.mockRPC(this.page, path, jsonFilename, options);
}

View File

@@ -1,7 +1,146 @@
import { expect } from "@playwright/test";
import { readFile } from 'node:fs/promises';
import { BaseWebSocketPage } from "./BaseWebSocketPage";
import { Transit } from '../../helpers/Transit';
export class WorkspacePage extends BaseWebSocketPage {
static TextEditor = class TextEditor {
constructor(workspacePage) {
this.workspacePage = workspacePage;
// locators.
this.fontSize = this.workspacePage.rightSidebar.getByRole("textbox", {
name: "Font Size",
});
this.lineHeight = this.workspacePage.rightSidebar.getByRole("textbox", {
name: "Line Height",
});
this.letterSpacing = this.workspacePage.rightSidebar.getByRole(
"textbox",
{
name: "Letter Spacing",
},
);
}
get page() {
return this.workspacePage.page;
}
async waitForStyle(locator, styleName) {
return locator.evaluate(
(element, styleName) => element.style.getPropertyValue(styleName),
styleName,
);
}
async waitForEditor() {
return this.page.waitForSelector('[data-itype="editor"]');
}
async waitForRoot() {
return this.page.waitForSelector('[data-itype="root"]');
}
async waitForParagraph(nth) {
if (!nth) {
return this.page.waitForSelector('[data-itype="paragraph"]');
}
return this.page.waitForSelector(
`[data-itype="paragraph"]:nth-child(${nth})`,
);
}
async waitForParagraphStyle(nth, styleName) {
const paragraph = await this.waitForParagraph(nth);
return this.waitForStyle(paragraph, styleName);
}
async waitForTextSpan(nth = 0) {
if (!nth) {
return this.page.waitForSelector('[data-itype="inline"]');
}
return this.page.waitForSelector(
`[data-itype="inline"]:nth-child(${nth})`,
);
}
async waitForTextSpanContent(nth = 0) {
const textSpan = await this.waitForTextSpan(nth);
const textContent = await textSpan.textContent();
return textContent;
}
async waitForTextSpanStyle(nth, styleName) {
const textSpan = await this.waitForTextSpan(nth);
return this.waitForStyle(textSpan, styleName);
}
async startEditing() {
await this.page.keyboard.press("Enter");
return this.waitForEditor();
}
stopEditing() {
return this.page.keyboard.press("Escape");
}
async moveToLeft(amount = 0) {
for (let i = 0; i < amount; i++) {
await this.page.keyboard.press("ArrowLeft");
}
}
async moveToRight(amount = 0) {
for (let i = 0; i < amount; i++) {
await this.page.keyboard.press("ArrowRight");
}
}
async moveFromStart(offset = 0) {
await this.page.keyboard.press("ArrowLeft");
await this.moveToRight(offset);
}
async moveFromEnd(offset = 0) {
await this.page.keyboard.press("ArrowRight");
await this.moveToLeft(offset);
}
async selectFromStart(length, offset = 0) {
await this.moveFromStart(offset);
await this.page.keyboard.down("Shift");
await this.moveToRight(length);
await this.page.keyboard.up("Shift");
}
async selectFromEnd(length, offset = 0) {
await this.moveFromEnd(offset);
await this.page.keyboard.down("Shift");
await this.moveToLeft(length);
await this.page.keyboard.up("Shift");
}
async changeNumericInput(locator, newValue) {
await expect(locator).toBeVisible();
await locator.focus();
await locator.fill(`${newValue}`);
await locator.blur();
}
changeFontSize(newValue) {
return this.changeNumericInput(this.fontSize, newValue);
}
changeLineHeight(newValue) {
return this.changeNumericInput(this.lineHeight, newValue);
}
changeLetterSpacing(newValue) {
return this.changeNumericInput(this.letterSpacing, newValue);
}
};
/**
* This should be called on `test.beforeEach`.
*
@@ -11,50 +150,21 @@ export class WorkspacePage extends BaseWebSocketPage {
static async init(page) {
await BaseWebSocketPage.initWebSockets(page);
await BaseWebSocketPage.mockRPC(
page,
"get-profile",
"logged-in-user/get-profile-logged-in.json",
);
await BaseWebSocketPage.mockRPC(
page,
"get-team-users?file-id=*",
"logged-in-user/get-team-users-single-user.json",
);
await BaseWebSocketPage.mockRPC(
page,
"get-comment-threads?file-id=*",
"workspace/get-comment-threads-empty.json",
);
await BaseWebSocketPage.mockRPC(
page,
"get-project?id=*",
"workspace/get-project-default.json",
);
await BaseWebSocketPage.mockRPC(
page,
"get-team?id=*",
"workspace/get-team-default.json",
);
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams.json");
await BaseWebSocketPage.mockRPC(
page,
"get-team-members?team-id=*",
"logged-in-user/get-team-members-your-penpot.json",
);
await BaseWebSocketPage.mockRPC(
page,
"get-profiles-for-file-comments?file-id=*",
"workspace/get-profile-for-file-comments.json",
);
await BaseWebSocketPage.mockRPC(
page,
"update-profile-props",
"workspace/update-profile-empty.json",
);
await BaseWebSocketPage.mockRPCs(page, {
"get-profile": "logged-in-user/get-profile-logged-in.json",
"get-team-users?file-id=*":
"logged-in-user/get-team-users-single-user.json",
"get-comment-threads?file-id=*":
"workspace/get-comment-threads-empty.json",
"get-project?id=*": "workspace/get-project-default.json",
"get-team?id=*": "workspace/get-team-default.json",
"get-teams": "get-teams.json",
"get-team-members?team-id=*":
"logged-in-user/get-team-members-your-penpot.json",
"get-profiles-for-file-comments?file-id=*":
"workspace/get-profile-for-file-comments.json",
"update-profile-props": "workspace/update-profile-empty.json",
});
}
static anyTeamId = "c7ce0794-0992-8105-8004-38e630f7920a";
@@ -62,9 +172,20 @@ export class WorkspacePage extends BaseWebSocketPage {
static anyFileId = "c7ce0794-0992-8105-8004-38f280443849";
static anyPageId = "c7ce0794-0992-8105-8004-38f28044384a";
/**
* WebSocket mock
*
* @type {MockWebSocketHelper}
*/
#ws = null;
constructor(page) {
/**
* Constructor
*
* @param {Page} page
* @param {} [options]
*/
constructor(page, options) {
super(page);
this.pageName = page.getByTestId("page-name");
@@ -112,11 +233,14 @@ export class WorkspacePage extends BaseWebSocketPage {
"tokens-context-menu-for-set",
);
this.contextMenuForShape = page.getByTestId("context-menu");
if (options?.textEditor) {
this.textEditor = new WorkspacePage.TextEditor(this);
}
}
async goToWorkspace({
fileId = WorkspacePage.anyFileId,
pageId = WorkspacePage.anyPageId,
fileId = this.fileId ?? WorkspacePage.anyFileId,
pageId = this.pageId ?? WorkspacePage.anyPageId,
} = {}) {
await this.page.goto(
`/#/workspace?team-id=${WorkspacePage.anyTeamId}&file-id=${fileId}&page-id=${pageId}`,
@@ -141,48 +265,59 @@ export class WorkspacePage extends BaseWebSocketPage {
}
async setupEmptyFile() {
await this.mockRPC(
"get-profile",
"logged-in-user/get-profile-logged-in.json",
);
await this.mockRPC(
"get-team-users?file-id=*",
"logged-in-user/get-team-users-single-user.json",
);
await this.mockRPC(
"get-comment-threads?file-id=*",
"workspace/get-comment-threads-empty.json",
);
await this.mockRPC(
"get-project?id=*",
"workspace/get-project-default.json",
);
await this.mockRPC("get-team?id=*", "workspace/get-team-default.json");
await this.mockRPC(
"get-profiles-for-file-comments?file-id=*",
"workspace/get-profile-for-file-comments.json",
);
await this.mockRPC(/get\-file\?/, "workspace/get-file-blank.json");
await this.mockRPC(
"get-file-object-thumbnails?file-id=*",
"workspace/get-file-object-thumbnails-blank.json",
);
await this.mockRPC(
"get-font-variants?team-id=*",
"workspace/get-font-variants-empty.json",
);
await this.mockRPC(
"get-file-fragment?file-id=*",
"workspace/get-file-fragment-blank.json",
);
await this.mockRPC(
"get-file-libraries?file-id=*",
"workspace/get-file-libraries-empty.json",
);
await this.mockRPCs({
"get-profile": "logged-in-user/get-profile-logged-in.json",
"get-team-users?file-id=*":
"logged-in-user/get-team-users-single-user.json ",
"get-comment-threads?file-id=*":
"workspace/get-comment-threads-empty.json",
"get-project?id=*": "workspace/get-project-default.json",
"get-team?id=*": "workspace/get-team-default.json",
"get-profiles-for-file-comments?file-id=*":
"workspace/get-profile-for-file-comments.json",
"get-file-object-thumbnails?file-id=*":
"workspace/get-file-object-thumbnails-blank.json",
"get-font-variants?team-id=*": "workspace/get-font-variants-empty.json",
"get-file-fragment?file-id=*": "workspace/get-file-fragment-blank.json",
"get-file-libraries?file-id=*": "workspace/get-file-libraries-empty.json",
});
if (this.textEditor) {
await this.mockRPC("update-file?id=*", "text-editor/update-file.json");
}
// by default we mock the blank file.
await this.mockGetFile("workspace/get-file-blank.json");
}
async mockGetFile(jsonFile) {
await this.mockRPC(/get\-file\?/, jsonFile);
async mockGetFile(jsonFilename, options) {
const page = this.page;
const jsonPath = `playwright/data/${jsonFilename}`;
const body = await readFile(jsonPath, "utf-8");
const payload = JSON.parse(body);
const fileId = Transit.get(payload, "id");
const pageId = Transit.get(payload, "data", "pages", 0);
const teamId = Transit.get(payload, "team-id");
this.fileId = fileId ?? this.anyFileId;
this.pageId = pageId ?? this.anyPageId;
this.teamId = teamId ?? this.anyTeamId;
const path = /get\-file\?/;
const url = typeof path === "string" ? `**/api/main/methods/${path}` : path;
const interceptConfig = {
status: 200,
contentType: "application/transit+json",
...options,
};
return page.route(url, (route) =>
route.fulfill({
...interceptConfig,
body,
}),
);
// await this.mockRPC(/get\-file\?/, jsonFile);
}
async mockGetAsset(regex, asset) {
@@ -190,22 +325,15 @@ export class WorkspacePage extends BaseWebSocketPage {
}
async setupFileWithComments() {
await this.mockRPC(
"get-comment-threads?file-id=*",
"workspace/get-comment-threads-unread.json",
);
await this.mockRPC(
"get-file-fragment?file-id=*&fragment-id=*",
"viewer/get-file-fragment-single-board.json",
);
await this.mockRPC(
"get-comments?thread-id=*",
"workspace/get-thread-comments.json",
);
await this.mockRPC(
"update-comment-thread-status",
"workspace/update-comment-thread-status.json",
);
await this.mockRPCs({
"get-comment-threads?file-id=*":
"workspace/get-comment-threads-unread.json",
"get-file-fragment?file-id=*&fragment-id=*":
"viewer/get-file-fragment-single-board.json",
"get-comments?thread-id=*": "workspace/get-thread-comments.json",
"update-comment-thread-status":
"workspace/update-comment-thread-status.json",
});
}
async clickWithDragViewportAt(x, y, width, height) {
@@ -223,6 +351,67 @@ export class WorkspacePage extends BaseWebSocketPage {
await this.page.mouse.up();
}
/**
* Clicks and moves from the coordinates x1,y1 to x2,y2
*
* @param {number} x1
* @param {number} y1
* @param {number} x2
* @param {number} y2
*/
async clickAndMove(x1, y1, x2, y2) {
await this.page.waitForTimeout(100);
await this.viewport.hover({ position: { x: x1, y: y1 } });
await this.page.mouse.down();
await this.viewport.hover({ position: { x: x2, y: y2 } });
await this.page.mouse.up();
}
/**
* Creates a new Text Shape in the specified coordinates
* with an initial text.
*
* @param {number} x1
* @param {number} y1
* @param {number} x2
* @param {number} y2
* @param {string} initialText
* @param {*} [options]
*/
async createTextShape(x1, y1, x2, y2, initialText, options) {
const timeToWait = options?.timeToWait ?? 100;
await this.page.keyboard.press("T");
await this.page.waitForTimeout(timeToWait);
await this.clickAndMove(x1, y1, x2, y2);
await this.page.waitForTimeout(timeToWait);
if (initialText) {
await this.page.keyboard.type(initialText);
}
}
/**
* Copies the selected element into the clipboard.
*
* @returns {Promise<void>}
*/
async copy() {
return this.page.keyboard.press("Control+C");
}
/**
* Pastes something from the clipboard.
*
* @param {"keyboard"|"context-menu"} [kind="keyboard"]
* @returns {Promise<void>}
*/
async paste(kind = "keyboard") {
if (kind === "context-menu") {
await this.viewport.click({ button: "right" });
return this.page.getByText("PasteCtrlV").click();
}
return this.page.keyboard.press("Control+V");
}
async panOnViewportAt(x, y, width, height) {
await this.page.waitForTimeout(100);
await this.viewport.hover({ position: { x, y } });
@@ -250,10 +439,15 @@ export class WorkspacePage extends BaseWebSocketPage {
await this.page.waitForTimeout(500);
}
async doubleClickLeafLayer(name, clickOptions = {}) {
await this.clickLeafLayer(name, clickOptions);
await this.clickLeafLayer(name, clickOptions);
}
async clickToggableLayer(name, clickOptions = {}) {
const layer = this.layers
.getByTestId("layer-row")
.filter({ hasText: name });
.getByTestId("layer-row")
.filter({ hasText: name });
const button = layer.getByRole("button");
await button.waitFor();

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -360,7 +360,7 @@ test("Renders a file with texts with paragraphs and breaking lines", async ({
id: "a5f238bd-dd8a-8164-8007-1bc3481eaf05",
pageId: "a5f238bd-dd8a-8164-8007-1bc3481eaf06",
});
await workspace.waitForFirstRender();
await workspace.waitForFirstRenderWithoutUI();
await expect(workspace.canvas).toHaveScreenshot();
});

View File

@@ -18,6 +18,10 @@ const setupFile = async (workspacePage) => {
fileId: "7b2da435-6186-815a-8007-0daa95d2f26d",
pageId: "ce79274b-11ab-8088-8007-0487ad43f789",
});
await workspacePage.mockRPC(
"update-file?id=*",
"workspace/update-file-empty.json",
);
};
const shapeToLayerName = {

View File

@@ -1,12 +1,317 @@
import { test, expect } from "@playwright/test";
import { Clipboard } from '../../helpers/Clipboard';
import { WorkspacePage } from "../pages/WorkspacePage";
test.beforeEach(async ({ page }) => {
const timeToWait = 100;
test.beforeEach(async ({ page, context }) => {
await Clipboard.enable(context, Clipboard.Permission.ONLY_WRITE);
await WorkspacePage.init(page);
await WorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-v2"]);
});
test.skip("BUG 11552 - Apply styles to the current caret", async ({ page }) => {
test.afterEach(async ({ context}) => {
context.clearPermissions();
})
test("Create a new text shape", async ({ page }) => {
const initialText = "Lorem ipsum";
const workspace = new WorkspacePage(page, {
textEditor: true
});
await workspace.setupEmptyFile();
await workspace.goToWorkspace();
await workspace.createTextShape(190, 150, 300, 200, initialText);
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe(initialText);
await workspace.textEditor.stopEditing();
});
test("Create a new text shape from pasting text", async ({ page, context }) => {
const textToPaste = "Lorem ipsum";
const workspace = new WorkspacePage(page, {
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.mockRPC(
"update-file?id=*",
"text-editor/update-file.json",
);
await workspace.goToWorkspace();
await Clipboard.writeText(page, textToPaste);
await workspace.clickAt(190, 150);
await workspace.paste("keyboard");
await page.waitForTimeout(timeToWait);
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe(textToPaste);
await workspace.textEditor.stopEditing();
});
test("Create a new text shape from pasting text using context menu", async ({ page, context }) => {
const textToPaste = "Lorem ipsum";
const workspace = new WorkspacePage(page, {
textEditor: true
});
await workspace.setupEmptyFile();
await workspace.goToWorkspace();
await Clipboard.writeText(page, textToPaste);
await workspace.clickAt(190, 150);
await workspace.paste("context-menu");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe(textToPaste);
await workspace.textEditor.stopEditing();
})
test("Update an already created text shape by appending text", async ({ page }) => {
const workspace = new WorkspacePage(page, {
textEditor: true
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
await workspace.goToWorkspace();
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing();
await workspace.textEditor.moveFromEnd(0);
await page.keyboard.type(" dolor sit amet");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe("Lorem ipsum dolor sit amet");
await workspace.textEditor.stopEditing();
});
test("Update an already created text shape by prepending text", async ({
page,
}) => {
const workspace = new WorkspacePage(page, {
textEditor: true
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
await workspace.goToWorkspace();
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing();
await workspace.textEditor.moveFromStart(0);
await page.keyboard.type("Dolor sit amet ");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe("Dolor sit amet Lorem ipsum");
await workspace.textEditor.stopEditing();
});
test("Update an already created text shape by inserting text in between", async ({
page,
}) => {
const workspace = new WorkspacePage(page, {
textEditor: true
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
await workspace.goToWorkspace();
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing();
await workspace.textEditor.moveFromStart(5);
await page.keyboard.type(" dolor sit amet");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe("Lorem dolor sit amet ipsum");
await workspace.textEditor.stopEditing();
});
test("Update a new text shape appending text by pasting text", async ({ page, context }) => {
const textToPaste = " dolor sit amet";
const workspace = new WorkspacePage(page, {
textEditor: true
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
await workspace.goToWorkspace();
await Clipboard.writeText(page, textToPaste);
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing();
await workspace.textEditor.moveFromEnd();
await workspace.paste("keyboard");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe("Lorem ipsum dolor sit amet");
await workspace.textEditor.stopEditing();
});
test("Update a new text shape prepending text by pasting text", async ({
page, context
}) => {
const textToPaste = "Dolor sit amet ";
const workspace = new WorkspacePage(page, {
textEditor: true
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
await workspace.goToWorkspace();
await Clipboard.writeText(page, textToPaste);
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing();
await workspace.textEditor.moveFromStart();
await workspace.paste("keyboard");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe("Dolor sit amet Lorem ipsum");
await workspace.textEditor.stopEditing();
});
test("Update a new text shape replacing (starting) text with pasted text", async ({
page,
}) => {
const textToPaste = "Dolor sit amet";
const workspace = new WorkspacePage(page, {
textEditor: true
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
await workspace.goToWorkspace();
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing();
await workspace.textEditor.selectFromStart(5);
await Clipboard.writeText(page, textToPaste);
await workspace.paste("keyboard");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe("Dolor sit amet ipsum");
await workspace.textEditor.stopEditing();
});
test("Update a new text shape replacing (ending) text with pasted text", async ({
page,
}) => {
const textToPaste = "dolor sit amet";
const workspace = new WorkspacePage(page, {
textEditor: true
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
await workspace.goToWorkspace();
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing();
await workspace.textEditor.selectFromEnd(5);
await Clipboard.writeText(page, textToPaste);
await workspace.paste("keyboard");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe("Lorem dolor sit amet");
await workspace.textEditor.stopEditing();
});
test("Update a new text shape replacing (in between) text with pasted text", async ({
page,
}) => {
const textToPaste = "dolor sit amet";
const workspace = new WorkspacePage(page, {
textEditor: true
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
await workspace.goToWorkspace();
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing();
await workspace.textEditor.selectFromStart(5, 3);
await Clipboard.writeText(page, textToPaste);
await workspace.paste("keyboard");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe("Lordolor sit ametsum");
await workspace.textEditor.stopEditing();
});
test("Update text font size selecting a part of it (starting)", async ({
page,
}) => {
const workspace = new WorkspacePage(page, {
textEditor: true
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
await workspace.mockRPC(
"update-file?id=*",
"text-editor/update-file.json",
);
await workspace.goToWorkspace();
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing();
await workspace.textEditor.selectFromStart(5);
await workspace.textEditor.changeFontSize(36);
const textContent1 = await workspace.textEditor.waitForTextSpanContent(1);
expect(textContent1).toBe("Lorem");
const textContent2 = await workspace.textEditor.waitForTextSpanContent(2);
expect(textContent2).toBe(" ipsum");
await workspace.textEditor.stopEditing();
});
test.skip("Update text line height selecting a part of it (starting)", async ({
page,
}) => {
const workspace = new WorkspacePage(page, {
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
await workspace.mockRPC("update-file?id=*", "text-editor/update-file.json");
await workspace.goToWorkspace();
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing();
await workspace.textEditor.selectFromStart(5);
await workspace.textEditor.changeLineHeight(1.4);
const lineHeight = await workspace.textEditor.waitForParagraphStyle(1, 'line-height');
expect(lineHeight).toBe("1.4");
const textContent = await workspace.textEditor.waitForTextSpanContent();
expect(textContent).toBe("Lorem ipsum");
await workspace.textEditor.stopEditing();
});
test.skip("Update text letter spacing selecting a part of it (starting)", async ({
page,
}) => {
const workspace = new WorkspacePage(page, {
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
await workspace.mockRPC("update-file?id=*", "text-editor/update-file.json");
await workspace.goToWorkspace();
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing();
await workspace.textEditor.selectFromStart(5);
await workspace.textEditor.changeLetterSpacing(10);
const textContent1 = await workspace.textEditor.waitForTextSpanContent(1);
expect(textContent1).toBe("Lorem");
const textContent2 = await workspace.textEditor.waitForTextSpanContent(2);
expect(textContent2).toBe(" ipsum");
await workspace.textEditor.stopEditing();
});
test("BUG 11552 - Apply styles to the current caret", async ({ page }) => {
const workspace = new WorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-11552.json");
@@ -14,21 +319,16 @@ test.skip("BUG 11552 - Apply styles to the current caret", async ({ page }) => {
"update-file?id=*",
"text-editor/update-file-11552.json",
);
await workspace.goToWorkspace({
fileId: "238a17e0-75ff-8075-8006-934586ea2230",
pageId: "238a17e0-75ff-8075-8006-934586ea2231",
});
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.goToWorkspace();
await workspace.doubleClickLeafLayer("Lorem ipsum");
const fontSizeInput = workspace.rightSidebar.getByRole("textbox", {
name: "Font Size",
});
await expect(fontSizeInput).toBeVisible();
await workspace.page.keyboard.press("Enter");
await workspace.page.keyboard.press("ArrowRight");
await page.keyboard.press("Enter");
await page.keyboard.press("ArrowRight");
await fontSizeInput.fill("36");

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 676 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -76,7 +76,7 @@
(map :page-id))
(defn- apply-changes-localy
[{:keys [file-id redo-changes] :as commit} pending]
[{:keys [file-id redo-changes ignore-wasm?] :as commit} pending]
(ptk/reify ::apply-changes-localy
ptk/UpdateEvent
(update [_ state]
@@ -103,7 +103,7 @@
pids (into #{} xf:map-page-id redo-changes)]
(reduce #(ctst/update-object-indices %1 %2) fdata pids)))]
(if (features/active-feature? state "render-wasm/v1")
(if (and (not ignore-wasm?) (features/active-feature? state "render-wasm/v1"))
;; Update the wasm model
(let [shape-changes (volatile! {})
@@ -122,7 +122,7 @@
(defn commit
"Create a commit event instance"
[{:keys [commit-id redo-changes undo-changes origin save-undo? features
file-id file-revn file-vern undo-group tags stack-undo? source]}]
file-id file-revn file-vern undo-group tags stack-undo? source ignore-wasm?]}]
(assert (cpc/check-changes redo-changes)
"expect valid vector of changes for redo-changes")
@@ -147,7 +147,8 @@
:save-undo? save-undo?
:undo-group undo-group
:tags tags
:stack-undo? stack-undo?}]
:stack-undo? stack-undo?
:ignore-wasm? ignore-wasm?}]
(ptk/reify ::commit
cljs.core/IDeref

View File

@@ -67,7 +67,7 @@
[]
(let [uagent (new ua/UAParser)]
(merge
{:app-version (:full cf/version)
{:version (:full cf/version)
:locale @i18n/locale}
(let [browser (.getBrowser uagent)]
{:browser (obj/get browser "name")

View File

@@ -20,7 +20,9 @@
[app.common.path-names :as cpn]
[app.common.transit :as t]
[app.common.types.component :as ctc]
[app.common.types.components-list :as ctkl]
[app.common.types.shape :as cts]
[app.common.types.variant :as ctv]
[app.common.uuid :as uuid]
[app.main.data.changes :as dch]
[app.main.data.comments :as dcmt]
@@ -377,6 +379,23 @@
(->> (rx/from added)
(rx/map process-wasm-object)))))))
(when render-wasm?
(->> stream
(rx/filter (ptk/type? :wasm/position-data))
(rx/map deref)
(rx/filter
(fn [{:keys [position-data]}]
(some? position-data)))
(rx/map
(fn [{:keys [id position-data]}]
(prn "???" id position-data)
(dwsh/update-shapes
[id]
(fn [shape]
(.log js/console (clj->js shape))
(assoc shape :position-data position-data))
{:ignore-wasm? true})))))
(->> stream
(rx/filter dch/commit?)
(rx/map deref)
@@ -551,7 +570,6 @@
component-id (:component-id shape)
undo-id (js/Symbol)]
(when valid?
(if (ctc/is-variant-container? shape)
;; Rename the full variant when it is a variant container
@@ -566,6 +584,43 @@
(dwl/rename-component component-id clean-name))
(dwu/commit-undo-transaction undo-id))))))))))
(defn rename-shape-or-variant
([id name]
(rename-shape-or-variant nil nil id name))
([file-id page-id id name]
(ptk/reify ::rename-shape-or-variant
ptk/WatchEvent
(watch [_ state _]
(let [file-id (d/nilv file-id (:current-file-id state))
page-id (d/nilv page-id (:current-page-id state))
file-data (dsh/lookup-file-data state file-id)
shape
(-> (dsh/lookup-page-objects state file-id page-id)
(get id))
is-variant? (ctc/is-variant? shape)
variant-id (when is-variant? (:variant-id shape))
variant-name (when is-variant? (:variant-name shape))
component-id (:component-id shape)
component (ctkl/get-component file-data (:component-id shape))
variant-properties (:variant-properties component)]
(cond
(and variant-name (ctv/valid-properties-formula? name))
(rx/of (dwva/update-properties-names-and-values
component-id variant-id variant-properties (ctv/properties-formula->map name))
(dwva/remove-empty-properties variant-id)
(dwva/update-error component-id))
variant-name
(rx/of (dwva/update-properties-names-and-values
component-id variant-id variant-properties {})
(dwva/remove-empty-properties variant-id)
(dwva/update-error component-id name))
:else
(rx/of (end-rename-shape id name))))))))
;; --- Update Selected Shapes attrs
(defn update-selected-shapes

View File

@@ -102,7 +102,8 @@
{:origin it
:redo-changes changes
:undo-changes []
:save-undo? false})))))))
:save-undo? false
:ignore-wasm? true})))))))
;; FIXME: would be nice to not execute this code twice per page in the
;; same working session, maybe some local memoization can improve that
@@ -119,4 +120,5 @@
{:origin it
:redo-changes changes
:undo-changes []
:save-undo? false})))))))
:save-undo? false
:ignore-wasm? true})))))))

View File

@@ -649,7 +649,7 @@
(propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state))
ids
(into [] xf:without-uuid-zero (keys transforms))
(into (set (keys modif-tree)) xf:without-uuid-zero (keys transforms))
update-shape
(fn [shape]

View File

@@ -50,7 +50,8 @@
([ids update-fn] (update-shapes ids update-fn nil))
([ids update-fn
{:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id
ignore-touched undo-group with-objects? changed-sub-attr]
ignore-touched undo-group with-objects? changed-sub-attr
ignore-wasm?]
:or {reg-objects? false
save-undo? true
stack-undo? false
@@ -89,6 +90,7 @@
:ignore-tree ignore-tree
:ignore-touched ignore-touched
:with-objects? with-objects?})
(assoc :ignore-wasm? ignore-wasm?)
(cond-> undo-group
(pcb/set-undo-group undo-group)))

View File

@@ -831,7 +831,8 @@
(effect [_ state _]
(when (features/active-feature? state "text-editor/v2")
(let [instance (:workspace-editor state)
attrs-to-override (some-> (editor.v2/getCurrentStyle instance) (styles/get-styles-from-style-declaration))
attrs-to-override (some-> (editor.v2/getCurrentStyle instance)
(styles/get-styles-from-style-declaration))
overriden-attrs (merge attrs-to-override attrs)
styles (styles/attrs->styles overriden-attrs)]
(editor.v2/applyStylesToSelection instance styles))))))

View File

@@ -128,14 +128,16 @@
related-components (cfv/find-variant-components data objects variant-id)
props (-> related-components last :variant-properties)
prop-name (-> props (nth pos) :name)
valid-pos? (> (count props) pos)
prop-name (when valid-pos? (-> props (nth pos) :name))
changes (-> (pcb/empty-changes it page-id)
(pcb/with-objects objects)
(pcb/with-library-data data)
(clvp/generate-update-property-name variant-id pos new-name))
changes (when valid-pos?
(-> (pcb/empty-changes it page-id)
(pcb/with-objects objects)
(pcb/with-library-data data)
(clvp/generate-update-property-name variant-id pos new-name)))
undo-id (js/Symbol)]
(when (not= prop-name new-name)
(when (and valid-pos? (not= prop-name new-name))
(rx/of
(dwu/start-undo-transaction undo-id)
(dch/commit-changes changes)

View File

@@ -26,7 +26,7 @@
(log/set-level! :warn)
(def google-fonts
(preload-gfonts "fonts/gfonts.2025.05.19.json"))
(preload-gfonts "fonts/gfonts.2025.11.28.json"))
(def local-fonts
[{:id "sourcesanspro"
@@ -342,8 +342,8 @@
(fn [result {:keys [font-id] :as node}]
(let [current-font
(if (some? font-id)
(select-keys node [:font-id :font-variant-id])
(select-keys txt/default-typography [:font-id :font-variant-id]))]
(select-keys node [:font-id :font-variant-id :font-weight :font-style])
(select-keys txt/default-typography [:font-id :font-variant-id :font-weight :font-style]))]
(conj result current-font)))
#{})))

View File

@@ -372,6 +372,9 @@
(def workspace-modifiers
(l/derived :workspace-modifiers st/state))
(def workspace-wasm-modifiers
(l/derived :workspace-wasm-modifiers st/state))
(def ^:private workspace-modifiers-with-objects
(l/derived
(fn [state]

View File

@@ -14,6 +14,7 @@
[app.main.ui.components.dropdown :refer [dropdown-content*]]
[app.main.ui.icons :as deprecated-icon]
[app.util.dom :as dom]
[app.util.globals :as ug]
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[app.util.timers :as tm]
@@ -53,14 +54,13 @@
(def ^:private valid-option?
(sm/lazy-validator schema:option))
(mf/defc context-menu*
[{:keys [show on-close options selectable selected
(mf/defc context-menu-inner*
[{:keys [on-close options selectable selected
top left fixed min-width origin width]
:as props}]
(assert (every? valid-option? options) "expected valid options")
(assert (fn? on-close) "missing `on-close` prop")
(assert (boolean? show) "missing `show` prop")
(assert (vector? options) "missing `options` prop")
(let [width (d/nilv width "initial")
@@ -80,14 +80,15 @@
offset-x (get state :offset-x)
offset-y (get state :offset-y)
levels (get state :levels)
internal-id (mf/use-id)
on-local-close
(mf/use-fn
(mf/deps on-close)
(fn []
(swap! state* assoc :levels [{:parent nil
:options options}])
(on-close)))
(swap! state* assoc :levels [{:parent nil :options options}])
(when (fn? on-close)
(on-close))))
props
(mf/spread-props props {:on-close on-local-close})
@@ -216,11 +217,22 @@
(swap! state* assoc :levels [{:parent nil
:options options}]))
(mf/with-effect [internal-id]
(ug/dispatch! (ug/event "penpot:context-menu:open" #js {:id internal-id})))
(mf/with-effect [internal-id on-local-close]
(letfn [(on-event [event]
(when-let [detail (unchecked-get event "detail")]
(when (not= internal-id (unchecked-get detail "id"))
(on-local-close event))))]
(ug/listen "penpot:context-menu:open" on-event)
(partial ug/unlisten "penpot:context-menu:open" on-event)))
(mf/with-effect [ids]
(tm/schedule-on-idle
#(dom/focus! (dom/get-element (first ids)))))
(when (and show (some? levels))
(when (some? levels)
[:> dropdown-content* props
(let [level (peek levels)
options (:options level)
@@ -229,7 +241,7 @@
[:div {:class (stl/css-case
:is-selectable selectable
:context-menu true
:is-open show
:is-open true
:fixed fixed)
:style {:top (+ top offset-y)
:left (+ left offset-x)}
@@ -241,7 +253,7 @@
:role "menu"
:ref check-menu-offscreen}
(when-let [parent (:parent level)]
(when parent
[:*
[:li {:id "go-back-sub-option"
:class (stl/css :context-menu-item)
@@ -256,7 +268,7 @@
[:li {:class (stl/css :separator)}]])
(for [[index option] (d/enumerate (:options level))]
(for [[index option] (d/enumerate options)]
(let [name (:name option)
id (:id option)
sub-options (:options option)
@@ -297,3 +309,12 @@
:data-testid id}
name
[:span {:class (stl/css :submenu-icon)} deprecated-icon/arrow]])]))))]])])))
(mf/defc context-menu*
{::mf/private true}
[{:keys [show] :as props}]
(assert (boolean? show) "expected `show` prop to be a boolean")
(when ^boolean show
[:> context-menu-inner* props]))

View File

@@ -30,6 +30,7 @@
(def current-zoom (mf/create-context nil))
(def workspace-read-only? (mf/create-context nil))
(def is-render? (mf/create-context false))
(def is-component? (mf/create-context false))
(def sidebar

View File

@@ -151,14 +151,16 @@
(mf/defc menu-team-icon*
[{:keys [subscription-type]}]
[:span {:class (stl/css :subscription-icon)
:title (if (= subscription-type "unlimited")
(tr "subscription.dashboard.power-up.unlimited-plan")
(tr "subscription.dashboard.power-up.enterprise-plan"))
:data-testid "subscription-icon"}
(case subscription-type
"unlimited" i/character-u
"enterprise" i/character-e)])
[:span {:class (stl/css :subscription-icon-wrapper)}
[:> icon* {:icon-id (case subscription-type
"unlimited" i/character-u
"enterprise" i/character-e)
:class (stl/css :subscription-icon)
:size "s"
:title (if (= subscription-type "unlimited")
(tr "subscription.dashboard.power-up.unlimited-plan")
(tr "subscription.dashboard.power-up.enterprise-plan"))
:data-testid "subscription-icon"}]])
(mf/defc main-menu-power-up*
[{:keys [close-sub-menu]}]

View File

@@ -144,20 +144,20 @@
padding: 0;
}
.subscription-icon {
@extend .button-icon;
.subscription-icon-wrapper {
display: flex;
justify-content: center;
align-items: center;
background: var(--color-background-primary);
stroke: var(--color-foreground-secondary);
border-radius: 6px;
border-radius: $br-6;
border: 1.75px solid var(--color-foreground-secondary);
stroke-width: 2.25px;
width: var(--sp-xl);
height: var(--sp-xl);
block-size: var(--sp-xl);
inline-size: var(--sp-xl);
}
svg {
block-size: var(--sp-m);
inline-size: var(--sp-m);
}
.subscription-icon {
stroke: var(--color-foreground-secondary);
stroke-width: 2.25px;
}
.menu-item {

View File

@@ -106,14 +106,14 @@
(when (not= 0 count-libraries)
(if (pos? (count references))
[:*
[:div
(when (and (string? scd-msg) (not= scd-msg ""))
[:h3 {:class (stl/css :modal-scd-msg)} scd-msg])
[:ul {:class (stl/css :element-list)}
(for [[file-id file-name] references]
[:li {:class (stl/css :list-item)
:key (dm/str file-id)}
[:span "- " file-name]])]]
(when (and (string? scd-msg) (not= scd-msg ""))
[:p {:class (stl/css :modal-scd-msg)} scd-msg])
[:ul {:class (stl/css :element-list)}
(for [[file-id file-name] references]
[:li {:class (stl/css :list-item)
:key (dm/str file-id)}
[:span "- " file-name]])]
(when (and (string? hint) (not= hint ""))
[:> context-notification* {:level :info
:appearance :ghost}

View File

@@ -4,7 +4,8 @@
//
// Copyright (c) KALEIDOS INC
@use "refactor/common-refactor.scss" as deprecated;
@use "refactor/basic-rules.scss" as *;
@use "ds/typography.scss" as t;
.modal-overlay {
@extend .modal-overlay-base;
@@ -15,14 +16,19 @@
.modal-container {
@extend .modal-container-base;
display: grid;
gap: var(--sp-xxl);
grid-template-rows: auto minmax(0, 1fr) auto;
}
.modal-header {
margin-bottom: deprecated.$s-24;
.list-wrapper {
display: grid;
grid-template-rows: auto 1fr auto;
max-height: 100%;
}
.modal-title {
@include deprecated.headlineMediumTypography;
@include t.use-typography("headline-medium");
color: var(--modal-title-foreground-color);
}
@@ -31,13 +37,16 @@
}
.modal-content {
@include deprecated.bodySmallTypography;
margin-bottom: deprecated.$s-24;
@include t.use-typography("body-small");
display: grid;
gap: var(--sp-s);
}
.element-list {
@include deprecated.bodyLargeTypography;
@include t.use-typography("body-large");
color: var(--modal-text-foreground-color);
overflow-y: scroll;
margin-block: 0;
}
.action-buttons {
@@ -55,10 +64,14 @@
}
}
.modal-scd-msg {
margin-block: 0;
}
.modal-scd-msg,
.modal-subtitle,
.modal-msg {
@include deprecated.bodyLargeTypography;
@include t.use-typography("body-large");
color: var(--modal-text-foreground-color);
line-height: 1.5;
}

View File

@@ -173,7 +173,7 @@
[:id {:optional true} :string]
[:offset {:optional true} :int]
[:delay {:optional true} :int]
[:content [:or fn? :string]]
[:content [:or fn? :string map?]]
[:placement {:optional true}
[:maybe [:enum "top" "bottom" "left" "right" "top-right" "bottom-right" "bottom-left" "top-left"]]]])

View File

@@ -7,6 +7,7 @@
(ns app.main.ui.flex-controls.gap
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt]
[app.common.geom.shapes :as gsh]
@@ -16,6 +17,8 @@
[app.common.types.shape.layout :as ctl]
[app.main.data.helpers :as dsh]
[app.main.data.workspace.modifiers :as dwm]
[app.main.data.workspace.transforms :as dwt]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.css-cursors :as cur]
@@ -27,10 +30,11 @@
(mf/defc gap-display
[{:keys [frame-id zoom gap-type gap on-pointer-enter on-pointer-leave
rect-data hover? selected? mouse-pos hover-value
on-move-selected on-context-menu]}]
on-move-selected on-context-menu on-change]}]
(let [resizing (mf/use-var nil)
start (mf/use-var nil)
original-value (mf/use-var 0)
last-pos (mf/use-var nil)
negate? (:resize-negate? rect-data)
axis (:resize-axis rect-data)
@@ -43,32 +47,55 @@
(reset! start (dom/get-client-position event))
(reset! original-value (:initial-value rect-data))))
on-lost-pointer-capture
calc-modifiers
(mf/use-fn
(mf/deps frame-id gap-type gap)
(fn [pos]
(let [delta
(-> (gpt/to-vec @start pos)
(cond-> negate? gpt/negate)
(get axis))
val
(int (max (+ @original-value (/ delta zoom)) 0))
layout-gap (assoc gap gap-type val)]
[val
(dwm/create-modif-tree
[frame-id]
(ctm/change-property (ctm/empty) :layout-gap layout-gap))])))
on-lost-pointer-capture
(mf/use-fn
(mf/deps calc-modifiers)
(fn [event]
(dom/release-pointer event)
(when (and (features/active-feature? @st/state "render-wasm/v1") (= @resizing gap-type))
(let [[_ modifiers] (calc-modifiers @last-pos)]
(st/emit! (dwm/apply-wasm-modifiers modifiers)
(dwt/finish-transform))))
(reset! resizing nil)
(reset! start nil)
(reset! original-value 0)
(st/emit! (dwm/apply-modifiers))))
(when (not (features/active-feature? @st/state "render-wasm/v1"))
(st/emit! (dwm/apply-modifiers)))))
on-pointer-move
(mf/use-fn
(mf/deps frame-id gap-type gap)
(mf/deps calc-modifiers on-change)
(fn [event]
(let [pos (dom/get-client-position event)]
(reset! last-pos pos)
(reset! mouse-pos (point->viewport pos))
(when (= @resizing gap-type)
(let [delta (-> (gpt/to-vec @start pos)
(cond-> negate? gpt/negate)
(get axis))
val (int (max (+ @original-value (/ delta zoom)) 0))
layout-gap (assoc gap gap-type val)
modifiers (dwm/create-modif-tree [frame-id] (ctm/change-property (ctm/empty) :layout-gap layout-gap))]
(let [[val modifiers] (calc-modifiers pos)]
(reset! hover-value val)
(st/emit! (dwm/set-modifiers modifiers)))))))]
(if (features/active-feature? @st/state "render-wasm/v1")
(st/emit! (dwm/set-wasm-modifiers modifiers))
(st/emit! (dwm/set-modifiers modifiers)))
(when on-change
(on-change modifiers)))))))]
[:g.gap-rect
[:rect.info-area
@@ -120,10 +147,17 @@
pill-width (/ fcc/flex-display-pill-width zoom)
pill-height (/ fcc/flex-display-pill-height zoom)
workspace-modifiers (mf/deref refs/workspace-modifiers)
workspace-wasm-modifiers (mf/deref refs/workspace-wasm-modifiers)
gap-selected (mf/deref refs/workspace-gap-selected)
hover (mf/use-state nil)
hover-value (mf/use-state 0)
mouse-pos (mf/use-state nil)
current-modifiers (mf/use-state nil)
frame
(ctm/apply-structure-modifiers frame (dm/get-in @current-modifiers [frame-id :modifiers]))
padding (:layout-padding frame)
gap (:layout-gap frame)
{:keys [width height x1 y1]} (:selrect frame)
@@ -132,6 +166,12 @@
(reset! hover-value val))
on-pointer-leave #(reset! hover nil)
on-change
(mf/use-fn
(fn [modifiers]
(reset! current-modifiers modifiers)))
negate {:column-gap (if flip-x true false)
:row-gap (if flip-y true false)}
@@ -143,8 +183,16 @@
(= :column-reverse saved-dir))
(drop-last children)
(rest children))
children-to-display (->> children-to-display
(map #(gsh/transform-shape % (get-in workspace-modifiers [(:id %) :modifiers]))))
children-to-display
(if (features/active-feature? @st/state "render-wasm/v1")
(let [modifiers (into {} workspace-wasm-modifiers)]
(->> children-to-display
;;(map #(gsh/transform-shape % (get-in workspace-modifiers [(:id %) :modifiers])))
(map (fn [shape]
(gsh/apply-transform shape (get modifiers (:id shape)))))))
(->> children-to-display
(map #(gsh/transform-shape % (get-in workspace-modifiers [(:id %) :modifiers])))))
wrap-blocks
(let [block-children (->> children
@@ -272,20 +320,22 @@
[:g.gaps {:pointer-events "visible"}
(for [[index display-item] (d/enumerate (concat display-blocks display-children))]
(let [gap-type (:gap-type display-item)]
[:& gap-display {:key (str frame-id index)
:frame-id frame-id
:zoom zoom
:gap-type gap-type
:gap gap
:on-pointer-enter (partial on-pointer-enter gap-type (get gap gap-type))
:on-pointer-leave on-pointer-leave
:on-move-selected on-move-selected
:on-context-menu on-context-menu
:rect-data display-item
:hover? (= @hover gap-type)
:selected? (= gap-selected gap-type)
:mouse-pos mouse-pos
:hover-value hover-value}]))
[:& gap-display
{:key (str frame-id index)
:frame-id frame-id
:zoom zoom
:gap-type gap-type
:gap gap
:on-pointer-enter (partial on-pointer-enter gap-type (get gap gap-type))
:on-pointer-leave on-pointer-leave
:on-move-selected on-move-selected
:on-context-menu on-context-menu
:on-change on-change
:rect-data display-item
:hover? (= @hover gap-type)
:selected? (= gap-selected gap-type)
:mouse-pos mouse-pos
:hover-value hover-value}]))
(when @hover
[:& fcc/flex-display-pill

View File

@@ -6,9 +6,12 @@
(ns app.main.ui.flex-controls.margin
(:require
[app.common.data.macros :as dm]
[app.common.geom.point :as gpt]
[app.common.types.modifiers :as ctm]
[app.main.data.workspace.modifiers :as dwm]
[app.main.data.workspace.transforms :as dwt]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.css-cursors :as cur]
@@ -17,11 +20,14 @@
[app.util.dom :as dom]
[rumext.v2 :as mf]))
(mf/defc margin-display [{:keys [shape-id zoom hover-all? hover-v? hover-h? margin-num margin on-pointer-enter on-pointer-leave
rect-data hover? selected? mouse-pos hover-value]}]
(mf/defc margin-display
[{:keys [shape-id zoom hover-all? hover-v? hover-h? margin-num margin
on-pointer-enter on-pointer-leave on-change
rect-data hover? selected? mouse-pos hover-value]}]
(let [resizing? (mf/use-var false)
start (mf/use-var nil)
original-value (mf/use-var 0)
last-pos (mf/use-var nil)
negate? (true? (:resize-negate? rect-data))
axis (:resize-axis rect-data)
@@ -34,39 +40,69 @@
(reset! start (dom/get-client-position event))
(reset! original-value (:initial-value rect-data))))
calc-modifiers
(mf/use-fn
(mf/deps shape-id margin-num margin hover-all? hover-v? hover-h?)
(fn [pos]
(let [delta
(-> (gpt/to-vec @start pos)
(cond-> negate? gpt/negate)
(get axis))
val
(int (max (+ @original-value (/ delta zoom)) 0))
layout-item-margin
(cond
hover-all? (assoc margin :m1 val :m2 val :m3 val :m4 val)
hover-v? (assoc margin :m1 val :m3 val)
hover-h? (assoc margin :m2 val :m4 val)
:else (assoc margin margin-num val))
layout-item-margin-type
(if (= (:m1 margin) (:m2 margin) (:m3 margin) (:m4 margin)) :simple :multiple)]
[val
(dwm/create-modif-tree
[shape-id]
(-> (ctm/empty)
(ctm/change-property :layout-item-margin layout-item-margin)
(ctm/change-property :layout-item-margin-type layout-item-margin-type)))])))
on-lost-pointer-capture
(mf/use-fn
(mf/deps shape-id margin-num margin)
(mf/deps calc-modifiers)
(fn [event]
(dom/release-pointer event)
(when (features/active-feature? @st/state "render-wasm/v1")
(let [[_ modifiers] (calc-modifiers @last-pos)]
(st/emit! (dwm/apply-wasm-modifiers modifiers)
(dwt/finish-transform))))
(reset! resizing? false)
(reset! start nil)
(reset! original-value 0)
(st/emit! (dwm/apply-modifiers))))
(when (not (features/active-feature? @st/state "render-wasm/v1"))
(st/emit! (dwm/apply-modifiers)))))
on-pointer-move
(mf/use-fn
(mf/deps shape-id margin-num margin hover-all? hover-v? hover-h?)
(mf/deps calc-modifiers on-change)
(fn [event]
(let [pos (dom/get-client-position event)]
(reset! mouse-pos (point->viewport pos))
(reset! last-pos pos)
(when @resizing?
(let [delta (-> (gpt/to-vec @start pos)
(cond-> negate? gpt/negate)
(get axis))
val (int (max (+ @original-value (/ delta zoom)) 0))
layout-item-margin (cond
hover-all? (assoc margin :m1 val :m2 val :m3 val :m4 val)
hover-v? (assoc margin :m1 val :m3 val)
hover-h? (assoc margin :m2 val :m4 val)
:else (assoc margin margin-num val))
layout-item-margin-type (if (= (:m1 margin) (:m2 margin) (:m3 margin) (:m4 margin)) :simple :multiple)
modifiers (dwm/create-modif-tree [shape-id]
(-> (ctm/empty)
(ctm/change-property :layout-item-margin layout-item-margin)
(ctm/change-property :layout-item-margin-type layout-item-margin-type)))]
(let [[val modifiers] (calc-modifiers pos)]
(reset! hover-value val)
(st/emit! (dwm/set-modifiers modifiers)))))))]
(if (features/active-feature? @st/state "render-wasm/v1")
(st/emit! (dwm/set-wasm-modifiers modifiers))
(st/emit! (dwm/set-modifiers modifiers)))
(when on-change
(on-change modifiers)))))))]
[:rect.margin-rect
{:x (:x rect-data)
@@ -89,6 +125,11 @@
pill-width (/ fcc/flex-display-pill-width zoom)
pill-height (/ fcc/flex-display-pill-height zoom)
margins-selected (mf/deref refs/workspace-margins-selected)
current-modifiers (mf/use-state nil)
shape
(ctm/apply-structure-modifiers shape (dm/get-in @current-modifiers [shape-id :modifiers]))
hover-value (mf/use-state 0)
mouse-pos (mf/use-state nil)
hover (mf/use-state nil)
@@ -97,50 +138,67 @@
hover-h? (and (or (= @hover :m2) (= @hover :m4)) shift?)
margin (:layout-item-margin shape)
{:keys [width height x1 x2 y1 y2]} (:selrect shape)
on-pointer-enter (fn [hover-type val]
(reset! hover hover-type)
(reset! hover-value val))
on-pointer-leave #(reset! hover nil)
hover? #(or hover-all?
(and (or (= % :m1) (= % :m3)) hover-v?)
(and (or (= % :m2) (= % :m4)) hover-h?)
(= @hover %))
margin-display-data {:m1 {:key (str shape-id "-m1")
:x x1
:y (if (:flip-y frame) y2 (- y1 (:m1 margin)))
:width width
:height (:m1 margin)
:initial-value (:m1 margin)
:resize-type :top
:resize-axis :y
:resize-negate? (:flip-y frame)}
:m2 {:key (str shape-id "-m2")
:x (if (:flip-x frame) (- x1 (:m2 margin)) x2)
:y y1
:width (:m2 margin)
:height height
:initial-value (:m2 margin)
:resize-type :left
:resize-axis :x
:resize-negate? (:flip-x frame)}
:m3 {:key (str shape-id "-m3")
:x x1
:y (if (:flip-y frame) (- y1 (:m3 margin)) y2)
:width width
:height (:m3 margin)
:initial-value (:m3 margin)
:resize-type :top
:resize-axis :y
:resize-negate? (:flip-y frame)}
:m4 {:key (str shape-id "-m4")
:x (if (:flip-x frame) x2 (- x1 (:m4 margin)))
:y y1
:width (:m4 margin)
:height height
:initial-value (:m4 margin)
:resize-type :left
:resize-axis :x
:resize-negate? (:flip-x frame)}}]
on-pointer-enter
(mf/use-fn
(fn [hover-type val]
(reset! hover hover-type)
(reset! hover-value val)))
on-pointer-leave
(mf/use-fn
(fn []
(reset! hover nil)))
on-change
(mf/use-fn
(fn [modifiers]
(reset! current-modifiers modifiers)))
hover?
(fn [value]
(or hover-all?
(and (or (= value :m1) (= value :m3)) hover-v?)
(and (or (= value :m2) (= value :m4)) hover-h?)
(= @hover value)))
margin-display-data
{:m1 {:key (str shape-id "-m1")
:x x1
:y (if (:flip-y frame) y2 (- y1 (:m1 margin)))
:width width
:height (:m1 margin)
:initial-value (:m1 margin)
:resize-type :top
:resize-axis :y
:resize-negate? (:flip-y frame)}
:m2 {:key (str shape-id "-m2")
:x (if (:flip-x frame) (- x1 (:m2 margin)) x2)
:y y1
:width (:m2 margin)
:height height
:initial-value (:m2 margin)
:resize-type :left
:resize-axis :x
:resize-negate? (:flip-x frame)}
:m3 {:key (str shape-id "-m3")
:x x1
:y (if (:flip-y frame) (- y1 (:m3 margin)) y2)
:width width
:height (:m3 margin)
:initial-value (:m3 margin)
:resize-type :top
:resize-axis :y
:resize-negate? (:flip-y frame)}
:m4 {:key (str shape-id "-m4")
:x (if (:flip-x frame) x2 (- x1 (:m4 margin)))
:y y1
:width (:m4 margin)
:height height
:initial-value (:m4 margin)
:resize-type :left
:resize-axis :x
:resize-negate? (:flip-x frame)}}]
[:g.margins {:pointer-events "visible"}
(for [[margin-num rect-data] margin-display-data]
@@ -155,6 +213,7 @@
:margin margin
:on-pointer-enter (partial on-pointer-enter margin-num (get margin margin-num))
:on-pointer-leave on-pointer-leave
:on-change on-change
:rect-data rect-data
:hover? (hover? margin-num)
:selected? (get margins-selected margin-num)

View File

@@ -6,9 +6,12 @@
(ns app.main.ui.flex-controls.padding
(:require
[app.common.data.macros :as dm]
[app.common.geom.point :as gpt]
[app.common.types.modifiers :as ctm]
[app.main.data.workspace.modifiers :as dwm]
[app.main.data.workspace.transforms :as dwt]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.css-cursors :as cur]
@@ -18,11 +21,13 @@
[rumext.v2 :as mf]))
(mf/defc padding-display
[{:keys [frame-id zoom hover-all? hover-v? hover-h? padding-num padding on-pointer-enter on-pointer-leave
rect-data hover? selected? mouse-pos hover-value on-move-selected on-context-menu]}]
[{:keys [frame-id zoom hover-all? hover-v? hover-h? padding-num padding on-pointer-enter
on-pointer-leave rect-data hover? selected? mouse-pos hover-value on-move-selected
on-context-menu on-change]}]
(let [resizing? (mf/use-var false)
start (mf/use-var nil)
original-value (mf/use-var 0)
last-pos (mf/use-var nil)
negate? (true? (:resize-negate? rect-data))
axis (:resize-axis rect-data)
@@ -35,41 +40,69 @@
(reset! start (dom/get-client-position event))
(reset! original-value (:initial-value rect-data))))
calc-modifiers
(mf/use-fn
(mf/deps frame-id padding-num padding hover-all? hover-v? hover-h?)
(fn [pos]
(let [delta
(-> (gpt/to-vec @start pos)
(cond-> negate? gpt/negate)
(get axis))
val
(int (max (+ @original-value (/ delta zoom)) 0))
layout-padding
(cond
hover-all? (assoc padding :p1 val :p2 val :p3 val :p4 val)
hover-v? (assoc padding :p1 val :p3 val)
hover-h? (assoc padding :p2 val :p4 val)
:else (assoc padding padding-num val))
layout-padding-type
(if (= (:p1 padding) (:p2 padding) (:p3 padding) (:p4 padding)) :simple :multiple)]
[val
(dwm/create-modif-tree
[frame-id]
(-> (ctm/empty)
(ctm/change-property :layout-padding layout-padding)
(ctm/change-property :layout-padding-type layout-padding-type)))])))
on-lost-pointer-capture
(mf/use-fn
(mf/deps frame-id padding-num padding)
(mf/deps calc-modifiers)
(fn [event]
(dom/release-pointer event)
(when (features/active-feature? @st/state "render-wasm/v1")
(let [[_ modifiers] (calc-modifiers @last-pos)]
(st/emit! (dwm/apply-wasm-modifiers modifiers)
(dwt/finish-transform))))
(reset! resizing? false)
(reset! start nil)
(reset! original-value 0)
(st/emit! (dwm/apply-modifiers))))
(when (not (features/active-feature? @st/state "render-wasm/v1"))
(st/emit! (dwm/apply-modifiers)))))
on-pointer-move
(mf/use-fn
(mf/deps frame-id padding-num padding hover-all? hover-v? hover-h?)
(mf/deps calc-modifiers on-change)
(fn [event]
(let [pos (dom/get-client-position event)]
(reset! mouse-pos (point->viewport pos))
(reset! last-pos pos)
(when @resizing?
(let [delta (-> (gpt/to-vec @start pos)
(cond-> negate? gpt/negate)
(get axis))
val (int (max (+ @original-value (/ delta zoom)) 0))
layout-padding (cond
hover-all? (assoc padding :p1 val :p2 val :p3 val :p4 val)
hover-v? (assoc padding :p1 val :p3 val)
hover-h? (assoc padding :p2 val :p4 val)
:else (assoc padding padding-num val))
layout-padding-type (if (= (:p1 padding) (:p2 padding) (:p3 padding) (:p4 padding)) :simple :multiple)
modifiers (dwm/create-modif-tree [frame-id]
(-> (ctm/empty)
(ctm/change-property :layout-padding layout-padding)
(ctm/change-property :layout-padding-type layout-padding-type)))]
(let [[val modifiers] (calc-modifiers pos)]
(reset! hover-value val)
(st/emit! (dwm/set-modifiers modifiers)))))))]
(if (features/active-feature? @st/state "render-wasm/v1")
(st/emit! (dwm/set-wasm-modifiers modifiers))
(st/emit! (dwm/set-modifiers modifiers)))
(when on-change
(on-change modifiers)))))))]
[:g.padding-rect
[:rect.info-area
@@ -105,77 +138,108 @@
:on-lost-pointer-capture on-lost-pointer-capture
:on-pointer-move on-pointer-move
:on-context-menu on-context-menu
:class (when (or hover? selected?)
(if (= (:resize-axis rect-data) :x) (cur/get-dynamic "resize-ew" 0) (cur/get-dynamic "resize-ew" 90)))
:style {:fill (if (or hover? selected?) fcc/distance-color "none")
:opacity (if selected? 0 1)}}])]))
:class
(when (or hover? selected?)
(if (= (:resize-axis rect-data) :x)
(cur/get-dynamic "resize-ew" 0)
(cur/get-dynamic "resize-ew" 90)))
:style
{:fill (if (or hover? selected?) fcc/distance-color "none")
:opacity (if selected? 0 1)}}])]))
(mf/defc padding-rects
[{:keys [frame zoom alt? shift? on-move-selected on-context-menu]}]
(let [frame-id (:id frame)
paddings-selected (mf/deref refs/workspace-paddings-selected)
current-modifiers (mf/use-state nil)
frame
(ctm/apply-structure-modifiers frame (dm/get-in @current-modifiers [frame-id :modifiers]))
hover-value (mf/use-state 0)
mouse-pos (mf/use-state nil)
hover (mf/use-state nil)
hover-all? (and (not (nil? @hover)) alt?)
hover-v? (and (or (= @hover :p1) (= @hover :p3)) shift?)
hover-h? (and (or (= @hover :p2) (= @hover :p4)) shift?)
padding (:layout-padding frame)
{:keys [width height x1 x2 y1 y2]} (:selrect frame)
on-pointer-enter (fn [hover-type val]
(reset! hover hover-type)
(reset! hover-value val))
on-pointer-leave #(reset! hover nil)
pill-width (/ fcc/flex-display-pill-width zoom)
pill-height (/ fcc/flex-display-pill-height zoom)
hover? #(or hover-all?
(and (or (= % :p1) (= % :p3)) hover-v?)
(and (or (= % :p2) (= % :p4)) hover-h?)
(= @hover %))
negate {:p1 (if (:flip-y frame) true false)
:p2 (if (:flip-x frame) true false)
:p3 (if (:flip-y frame) true false)
:p4 (if (:flip-x frame) true false)}
negate (cond-> negate
(not= :auto (:layout-item-h-sizing frame)) (assoc :p2 (not (:p2 negate)))
(not= :auto (:layout-item-v-sizing frame)) (assoc :p3 (not (:p3 negate))))
padding-rect-data {:p1 {:key (str frame-id "-p1")
:x x1
:y (if (:flip-y frame) (- y2 (:p1 padding)) y1)
:width width
:height (:p1 padding)
:initial-value (:p1 padding)
:resize-type (if (:flip-y frame) :bottom :top)
:resize-axis :y
:resize-negate? (:p1 negate)}
:p2 {:key (str frame-id "-p2")
:x (if (:flip-x frame) x1 (- x2 (:p2 padding)))
:y y1
:width (:p2 padding)
:height height
:initial-value (:p2 padding)
:resize-type :left
:resize-axis :x
:resize-negate? (:p2 negate)}
:p3 {:key (str frame-id "-p3")
:x x1
:y (if (:flip-y frame) y1 (- y2 (:p3 padding)))
:width width
:height (:p3 padding)
:initial-value (:p3 padding)
:resize-type :bottom
:resize-axis :y
:resize-negate? (:p3 negate)}
:p4 {:key (str frame-id "-p4")
:x (if (:flip-x frame) (- x2 (:p4 padding)) x1)
:y y1
:width (:p4 padding)
:height height
:initial-value (:p4 padding)
:resize-type (if (:flip-x frame) :right :left)
:resize-axis :x
:resize-negate? (:p4 negate)}}]
negate
{:p1 (if (:flip-y frame) true false)
:p2 (if (:flip-x frame) true false)
:p3 (if (:flip-y frame) true false)
:p4 (if (:flip-x frame) true false)}
negate
(cond-> negate
(not= :auto (:layout-item-h-sizing frame)) (assoc :p2 (not (:p2 negate)))
(not= :auto (:layout-item-v-sizing frame)) (assoc :p3 (not (:p3 negate))))
padding-rect-data
{:p1 {:key (str frame-id "-p1")
:x x1
:y (if (:flip-y frame) (- y2 (:p1 padding)) y1)
:width width
:height (:p1 padding)
:initial-value (:p1 padding)
:resize-type (if (:flip-y frame) :bottom :top)
:resize-axis :y
:resize-negate? (:p1 negate)}
:p2 {:key (str frame-id "-p2")
:x (if (:flip-x frame) x1 (- x2 (:p2 padding)))
:y y1
:width (:p2 padding)
:height height
:initial-value (:p2 padding)
:resize-type :left
:resize-axis :x
:resize-negate? (:p2 negate)}
:p3 {:key (str frame-id "-p3")
:x x1
:y (if (:flip-y frame) y1 (- y2 (:p3 padding)))
:width width
:height (:p3 padding)
:initial-value (:p3 padding)
:resize-type :bottom
:resize-axis :y
:resize-negate? (:p3 negate)}
:p4 {:key (str frame-id "-p4")
:x (if (:flip-x frame) (- x2 (:p4 padding)) x1)
:y y1
:width (:p4 padding)
:height height
:initial-value (:p4 padding)
:resize-type (if (:flip-x frame) :right :left)
:resize-axis :x
:resize-negate? (:p4 negate)}}
on-pointer-enter
(mf/use-fn
(fn [hover-type val]
(reset! hover hover-type)
(reset! hover-value val)))
on-pointer-leave
(mf/use-fn
(fn []
(reset! hover nil)))
on-change
(mf/use-fn
(fn [modifiers]
(reset! current-modifiers modifiers)))
hover?
(fn [value]
(or hover-all?
(and (or (= value :p1) (= value :p3)) hover-v?)
(and (or (= value :p2) (= value :p4)) hover-h?)
(= @hover value)))]
[:g.paddings {:pointer-events "visible"}
(for [[padding-num rect-data] padding-rect-data]
@@ -194,9 +258,11 @@
:on-pointer-leave on-pointer-leave
:on-move-selected on-move-selected
:on-context-menu on-context-menu
:on-change on-change
:hover? (hover? padding-num)
:selected? (get paddings-selected padding-num)
:rect-data rect-data}])
(when @hover
[:& fcc/flex-display-pill
{:height pill-height

View File

@@ -6,6 +6,15 @@
@use "ds/typography.scss" as *;
// TODO: this must be a custom property in the design system
:global(.light) {
--low-emphasis-background: #fafafa;
}
:global(.default) {
--low-emphasis-background: #121214;
}
.style-box {
--title-gap: var(--sp-xs);
--title-padding: var(--sp-s);
@@ -13,12 +22,9 @@
--arrow-color: var(--color-foreground-secondary);
--box-border-color: var(--color-background-primary);
// TODO: this must be a custom property in the design system
--lowEmphasis-background: #121214;
padding-block: var(--sp-s);
padding-inline: var(--sp-m);
background-color: var(--lowEmphasis-background);
background-color: var(--low-emphasis-background);
border-block-end: 2px solid var(--box-border-color);
}

View File

@@ -30,6 +30,7 @@
[app.main.ui.releases.v2-1]
[app.main.ui.releases.v2-10]
[app.main.ui.releases.v2-11]
[app.main.ui.releases.v2-12]
[app.main.ui.releases.v2-2]
[app.main.ui.releases.v2-3]
[app.main.ui.releases.v2-4]
@@ -102,4 +103,4 @@
(defmethod rc/render-release-notes "0.0"
[params]
(rc/render-release-notes (assoc params :version "2.11")))
(rc/render-release-notes (assoc params :version "2.12")))

View File

@@ -0,0 +1,162 @@
;; 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.releases.v2-12
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
(defmethod c/render-release-notes "2.12"
[{:keys [slide klass next finish navigate version]}]
(mf/html
(case slide
:start
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.12-slide-0.jpg"
:class (stl/css :start-image)
:border "0"
:alt "Penpot 2.12 is here!"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"Whats new in Penpot?"]
[:div {:class (stl/css :version-tag)}
(dm/str "Version " version)]]
[:div {:class (stl/css :features-block)}
[:span {:class (stl/css :feature-title)}
"Better tokens visibility and more!"]
[:p {:class (stl/css :feature-content)}
"This release focuses on making your everyday workflow feel clearer, faster and more intuitive. Tokens are now easier to see and apply, appearing directly where you work and giving the designs better context during code inspection. Variants gain a more natural flow thanks to simple boolean toggles that remove friction when switching states. And PDF export becomes more flexible, letting you choose exactly which boards to share so your files match the story you want to tell."]
[:p {:class (stl/css :feature-content)}
"Together, these enhancements bring greater control and fluidity to your entire design process."]
[:p {:class (stl/css :feature-content)}
"Lets dive in!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.12-tokens-sidebar.gif"
:class (stl/css :start-image)
:border "0"
:alt "Better tokens visibility"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"Better tokens visibility"]]
[:div {:class (stl/css :feature)}
[:p {:class (stl/css :feature-content)}
"Design systems should be both powerful and effortless to use. This release brings tokens closer to where you work, making them easier to apply and easier to understand."]
[:span {:class (stl/css :feature-title)}
"Apply color tokens right from the sidebar"]
[:p {:class (stl/css :feature-content)}
"Your color tokens now appear directly in the properties sidebar, making it faster to apply or unapply tokens from the design tab. No more digging: now you can use tokens within your design flow."]
[:span {:class (stl/css :feature-title)}
"See token names in the Inspect panel"]
[:p {:class (stl/css :feature-content)}
"Developers now get a clearer context during handoff. The Inspect panel shows the actual token used in your design, in a similar way to how styles are displayed. This small detail reduces ambiguity, aligns everyone on the same language, and strengthens collaboration across the team."]]
[:div {:class (stl/css :navigation)}
[:& c/navigation-bullets
{:slide slide
:navigate navigate
:total 3}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.12-variants.gif"
:class (stl/css :start-image)
:border "0"
:alt "Simpler boolean variants"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"Simpler boolean variants"]]
[:div {:class (stl/css :feature)}
[:p {:class (stl/css :feature-content)}
"Variants are central to building flexible, scalable components. With this release, boolean properties become far easier to work with."]
[:span {:class (stl/css :feature-title)}
"A simple toggle for boolean values"]
[:p {:class (stl/css :feature-content)}
"Binary states now use a clean toggle, to be able to switch visually, instead of a dropdown. This makes adjusting component states more intuitive and speeds up working with multiple instances."]
[:p {:class (stl/css :feature-content)}
"Its a subtle improvement, but it removes friction you feel hundreds of times a week, and makes component work flow more naturally."]]
[:div {:class (stl/css :navigation)}
[:& c/navigation-bullets
{:slide slide
:navigate navigate
:total 3}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
2
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.12-export-pdf.gif"
:class (stl/css :start-image)
:border "0"
:alt "Smarter PDF export"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"Smarter PDF export"]]
[:div {:class (stl/css :feature)}
[:p {:class (stl/css :feature-content)}
"Exporting your work is now more precise and flexible."]
[:span {:class (stl/css :feature-title)}
"Select specific boards when exporting"]
[:p {:class (stl/css :feature-content)}
"Youre now in control of which boards make it into your PDF. Share just the final screens, just a flow, just the workshop materials. This streamlined export flow adapts to the way real teams work: share the story you want to tell, with exactly the boards you need."]]
[:div {:class (stl/css :navigation)}
[:& c/navigation-bullets
{:slide slide
:navigate navigate
:total 3}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))

View File

@@ -0,0 +1,102 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend .modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
a {
color: var(--button-primary-background-color-rest);
}
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flexCenter;
@include deprecated.headlineSmallTypography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headlineLargeTypography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature {
display: flex;
flex-direction: column;
gap: deprecated.$s-8;
}
.feature-title {
@include deprecated.bodyLargeTypography;
color: var(--modal-title-foreground-color);
}
.feature-content {
@include deprecated.bodyMediumTypography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.feature-list {
@include deprecated.bodyMediumTypography;
color: var(--modal-text-foreground-color);
list-style: disc;
display: grid;
gap: deprecated.$s-8;
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend .button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}

View File

@@ -28,6 +28,7 @@
{::mf/wrap-props false}
[props]
(let [{:keys [position-data content] :as shape} (obj/get props "shape")
is-render? (mf/use-ctx ctx/is-render?)
is-component? (mf/use-ctx ctx/is-component?)]
(mf/with-memo [content]
@@ -41,5 +42,5 @@
;; Only use this for component preview, otherwise the dashboard thumbnails
;; will give a tainted canvas error because the `foreignObject` cannot be
;; rendered.
(and (nil? position-data) is-component?)
(and (nil? position-data) (or is-component? is-render?))
[:> fo/text-shape props])))

View File

@@ -12,18 +12,20 @@
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
[app.render-wasm.api :as wasm.api]
[rumext.v2 :as mf]))
(mf/defc text-edition-outline
[{:keys [shape zoom modifiers]}]
(if (features/active-feature? @st/state "render-wasm/v1")
(let [selrect-transform (mf/deref refs/workspace-selrect)
[{:keys [x y width height]} transform] (dsh/get-selrect selrect-transform shape)]
(let [{:keys [width height]} (wasm.api/get-text-dimensions (:id shape))
selrect-transform (mf/deref refs/workspace-selrect)
[selrect transform] (dsh/get-selrect selrect-transform shape)]
[:rect.main.viewport-selrect
{:x x
:y y
:width width
:height height
{:x (:x selrect)
:y (:y selrect)
:width (max width (:width selrect))
:height (max height (:height selrect))
:transform transform
:style {:stroke "var(--color-accent-tertiary)"
:stroke-width (/ 1 zoom)

View File

@@ -320,10 +320,12 @@
[{:keys [x y width height]} transform]
(if render-wasm?
(let [{:keys [height]} (wasm.api/get-text-dimensions shape-id)
(let [{:keys [width height]} (wasm.api/get-text-dimensions shape-id)
selrect-transform (mf/deref refs/workspace-selrect)
[selrect transform] (dsh/get-selrect selrect-transform shape)
selrect-height (:height selrect)
selrect-width (:width selrect)
max-width (max width selrect-width)
max-height (max height selrect-height)
valign (-> shape :content :vertical-align)
y (:y selrect)
@@ -333,7 +335,7 @@
"center" (- y (/ (- height selrect-height) 2))
"top" y)
y)]
[(assoc selrect :y y :width (:width selrect) :height max-height) transform])
[(assoc selrect :y y :width max-width :height max-height) transform])
(let [bounds (gst/shape->rect shape)
x (mth/min (dm/get-prop bounds :x)
@@ -352,7 +354,7 @@
(obj/merge!
#js {"--editor-container-width" (dm/str width "px")
"--editor-container-height" (dm/str height "px")
"--fallback-families" (dm/str (str/join ", " fallback-families))})
"--fallback-families" (if (seq fallback-families) (dm/str (str/join ", " fallback-families)) "sourcesanspro")})
(not render-wasm?)
(obj/merge!

View File

@@ -56,9 +56,8 @@
(update file :data dissoc :pages-index))
refs/file))
(mf/defc assets-local-library
{::mf/wrap [mf/memo]
::mf/wrap-props false}
(mf/defc assets-local-library*
{::mf/private true}
[{:keys [filters]}]
(let [file (mf/deref ref:local-library)]
[:> file-library*
@@ -68,7 +67,7 @@
:filters filters}]))
(defn- toggle-values
[v [a b]]
[v a b]
(if (= v a) b a))
(mf/defc assets-toolbox*
@@ -97,7 +96,7 @@
(mf/use-fn
(mf/deps ordering)
(fn []
(let [new-value (toggle-values ordering [:asc :desc])]
(let [new-value (toggle-values ordering :asc :desc)]
(swap! filters* assoc :ordering new-value)
(dwa/set-current-assets-ordering! new-value))))
@@ -105,7 +104,7 @@
(mf/use-fn
(mf/deps list-style)
(fn []
(let [new-value (toggle-values list-style [:thumbs :list])]
(let [new-value (toggle-values list-style :thumbs :list)]
(swap! filters* assoc :list-style new-value)
(dwa/set-current-assets-list-style! new-value))))
@@ -209,5 +208,5 @@
[:& (mf/provider cmm/assets-toggle-ordering) {:value toggle-ordering}
[:& (mf/provider cmm/assets-toggle-list-style) {:value toggle-list-style}
[:*
[:& assets-local-library {:filters filters}]
[:> assets-local-library* {:filters filters}]
[:> assets-libraries* {:filters filters}]]]]]]))

View File

@@ -15,7 +15,7 @@
cursor: pointer;
.title-menu {
display: block;
visibility: visible;
}
}
}
@@ -25,7 +25,7 @@
}
.title-menu {
display: none;
visibility: hidden;
}
.group-title {

View File

@@ -11,7 +11,6 @@
[app.common.data.macros :as dm]
[app.common.types.variant :as ctv]
[app.main.data.workspace :as dw]
[app.main.data.workspace.variants :as dwv]
[app.main.store :as st]
[app.util.debug :as dbg]
[app.util.dom :as dom]
@@ -69,15 +68,7 @@
name (str/trim (dom/get-value name-input))]
(on-stop-edit)
(reset! edition* false)
(if variant-name
(if (ctv/valid-properties-formula? name)
(st/emit! (dwv/update-properties-names-and-values component-id variant-id variant-properties (ctv/properties-formula->map name))
(dwv/remove-empty-properties variant-id)
(dwv/update-error component-id))
(st/emit! (dwv/update-properties-names-and-values component-id variant-id variant-properties {})
(dwv/remove-empty-properties variant-id)
(dwv/update-error component-id name)))
(st/emit! (dw/end-rename-shape shape-id name))))))
(st/emit! (dw/rename-shape-or-variant shape-id name)))))
cancel-edit
(mf/use-fn

View File

@@ -687,7 +687,7 @@
(str/upper (tr "workspace.assets.local-library"))
(dm/get-in libraries [current-library-id :name]))
current-lib-data (mf/with-memo [libraries]
current-lib-data (mf/with-memo [libraries current-library-id]
(get-in libraries [current-library-id :data]))
current-lib-counts (mf/with-memo [current-lib-data]

View File

@@ -378,6 +378,7 @@
:step 0.1
:default-value "1.2"
:class (stl/css :line-height-input)
:aria-label (tr "inspect.attributes.typography.line-height")
:value (attr->string line-height)
:placeholder (if (= :multiple line-height) (tr "settings.multiple") "--")
:nillable (= :multiple line-height)
@@ -396,6 +397,7 @@
:step 0.1
:default-value "0"
:class (stl/css :letter-spacing-input)
:aria-label (tr "inspect.attributes.typography.letter-spacing")
:value (attr->string letter-spacing)
:placeholder (if (= :multiple letter-spacing) (tr "settings.multiple") "--")
:on-change #(handle-change % :letter-spacing)

View File

@@ -149,9 +149,9 @@
canvas-ref (mf/use-ref nil)
;; VARS
disable-paste (mf/use-var false)
in-viewport? (mf/use-var false)
;; STATE REFS
disable-paste-ref (mf/use-ref false)
in-viewport-ref (mf/use-ref false)
;; STREAMS
move-stream (mf/use-memo #(rx/subject))
@@ -210,10 +210,10 @@
on-pointer-down (actions/on-pointer-down @hover selected edition drawing-tool text-editing? path-editing? grid-editing?
path-drawing? create-comment? space? panning z? read-only?)
on-pointer-up (actions/on-pointer-up disable-paste)
on-pointer-up (actions/on-pointer-up disable-paste-ref)
on-pointer-enter (actions/on-pointer-enter in-viewport?)
on-pointer-leave (actions/on-pointer-leave in-viewport?)
on-pointer-enter (actions/on-pointer-enter in-viewport-ref)
on-pointer-leave (actions/on-pointer-leave in-viewport-ref)
on-pointer-move (actions/on-pointer-move move-stream)
on-move-selected (actions/on-move-selected hover hover-ids selected space? z? read-only?)
on-menu-selected (actions/on-menu-selected hover hover-ids selected read-only?)
@@ -304,7 +304,7 @@
#(st/emit!
(dwv/add-new-variant (:id first-shape))))]
(hooks/setup-dom-events zoom disable-paste in-viewport? read-only? drawing-tool path-drawing?)
(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?)

View File

@@ -266,7 +266,7 @@
(st/emit! (dw/show-shape-context-menu {:position position :hover-ids @hover-ids})))))))
(defn on-pointer-up
[disable-paste]
[disable-paste-ref]
(mf/use-callback
(fn [event]
(dom/stop-propagation event)
@@ -291,21 +291,19 @@
(dom/prevent-default event)
;; We store this so in Firefox the middle button won't do a paste of the content
(reset! disable-paste true)
(ts/schedule #(reset! disable-paste false)))
(mf/set-ref-val! disable-paste-ref true)
(ts/schedule #(mf/set-ref-val! disable-paste-ref false)))
(st/emit! (dw/finish-panning)
(dw/finish-zooming))))))
(defn on-pointer-enter [in-viewport?]
(mf/use-callback
(fn []
(reset! in-viewport? true))))
(defn on-pointer-enter
[in-viewport-ref]
(mf/use-fn #(mf/set-ref-val! in-viewport-ref true)))
(defn on-pointer-leave [in-viewport?]
(mf/use-callback
(fn []
(reset! in-viewport? false))))
(defn on-pointer-leave
[in-viewport-ref]
(mf/use-fn #(mf/set-ref-val! in-viewport-ref false)))
(defn on-key-down []
(mf/use-callback
@@ -524,15 +522,22 @@
:blobs (seq files)}]
(st/emit! (dwm/upload-media-workspace params))))))))
(def ^:private invalid-paste-targets
#{"INPUT" "TEXTAREA"})
(defn on-paste
[disable-paste in-viewport? read-only?]
[disable-paste-ref in-viewport-ref read-only?]
(mf/use-fn
(mf/deps read-only?)
(fn [event]
;; We disable the paste just after mouse-up of a middle button so
;; when panning won't paste the content into the workspace
(let [tag-name (-> event dom/get-target dom/get-tag-name)]
(when (and (not (#{"INPUT" "TEXTAREA"} tag-name))
(not @disable-paste)
;; We disable the paste when: 1. just after mouse-up of a middle
;; button (so when panning won't paste the content into the
;; workspace); 2. when we paste content in an input on the
;; sidebar
(let [tag-name (-> event dom/get-target dom/get-tag-name)
disable-paste? (mf/ref-val disable-paste-ref)
in-viewport? (mf/ref-val in-viewport-ref)]
(when (and (not (contains? invalid-paste-targets tag-name))
(not disable-paste?)
(not read-only?))
(st/emit! (dw/paste-from-event event @in-viewport?)))))))
(st/emit! (dw/paste-from-event event in-viewport?)))))))

View File

@@ -6,6 +6,7 @@
(ns app.main.ui.workspace.viewport.debug
(:require
[app.render-wasm.api :as wasm.api]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
@@ -275,3 +276,29 @@
:y2 (:y end-p)
:style {:stroke "red"
:stroke-width (/ 1 zoom)}}]))]))))
(mf/defc debug-text-position-data
{::mf/wrap-props false}
[props]
(let [objects (unchecked-get props "objects")
zoom (unchecked-get props "zoom")
selected-shapes (unchecked-get props "selected-shapes")
selected-text
(when (and (= (count selected-shapes) 1) (= :text (-> selected-shapes first :type)))
(first selected-shapes))
position-data
(when selected-text
(wasm.api/calculate-position-data selected-text))]
(for [{:keys [x y width height]} position-data]
[:rect {:x x
:y y
:width width
:height height
:fill "none"
:strokeWidth 1
:stroke "red"}]
)))

View File

@@ -23,6 +23,7 @@
[app.main.data.workspace.grid-layout.editor :as dwge]
[app.main.data.workspace.modifiers :as dwm]
[app.main.data.workspace.shape-layout :as dwsl]
[app.main.data.workspace.transforms :as dwt]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
@@ -257,7 +258,8 @@
(let [modifiers (calculate-drag-modifiers position)
modif-tree (dwm/create-modif-tree [(:id shape)] modifiers)]
(when on-clear-modifiers (on-clear-modifiers modifiers))
(st/emit! (dwm/apply-wasm-modifiers modif-tree)))
(st/emit! (dwm/apply-wasm-modifiers modif-tree)
(dwt/finish-transform)))
(st/emit! (dwm/apply-modifiers)))))
{:keys [handle-pointer-down handle-lost-pointer-capture handle-pointer-move]}
@@ -506,7 +508,8 @@
(let [modifiers (calculate-modifiers position)
modif-tree (dwm/create-modif-tree [(:id shape)] modifiers)]
(when on-clear-modifiers (on-clear-modifiers))
(st/emit! (dwm/apply-wasm-modifiers modif-tree)))
(st/emit! (dwm/apply-wasm-modifiers modif-tree)
(dwt/finish-transform)))
(st/emit! (dwm/apply-modifiers)))
(reset! start-size-before nil)
(reset! start-size-after nil)))]

View File

@@ -42,11 +42,12 @@
[rumext.v2 :as mf])
(:import goog.events.EventType))
(defn setup-dom-events [zoom disable-paste in-viewport? workspace-read-only? drawing-tool drawing-path?]
(defn setup-dom-events
[zoom disable-paste-ref in-viewport-ref workspace-read-only? drawing-tool drawing-path?]
(let [on-key-down (actions/on-key-down)
on-key-up (actions/on-key-up)
on-mouse-wheel (actions/on-mouse-wheel zoom)
on-paste (actions/on-paste disable-paste in-viewport? workspace-read-only?)
on-paste (actions/on-paste disable-paste-ref in-viewport-ref workspace-read-only?)
on-pointer-down (mf/use-fn
(mf/deps drawing-tool drawing-path?)
(fn [e]
@@ -56,27 +57,27 @@
(st/emit! (dwe/clear-edition-mode))))))
on-blur (mf/use-fn #(st/emit! (mse/->BlurEvent)))]
(mf/use-effect
(mf/deps drawing-tool drawing-path?)
(fn []
(let [keys [(events/listen js/window EventType.POINTERDOWN on-pointer-down)]]
(fn []
(doseq [key keys]
(events/unlistenByKey key))))))
(mf/with-effect [drawing-tool drawing-path?]
(let [key (events/listen js/window EventType.POINTERDOWN on-pointer-down)]
(mf/use-layout-effect
(mf/deps on-key-down on-key-up on-mouse-wheel on-paste workspace-read-only?)
(fn []
(let [keys [(events/listen js/document EventType.KEYDOWN on-key-down)
(events/listen js/document EventType.KEYUP on-key-up)
;; bind with passive=false to allow the event to be cancelled
;; https://stackoverflow.com/a/57582286/3219895
(events/listen js/window EventType.WHEEL on-mouse-wheel #js {:passive false})
(events/listen js/window EventType.PASTE on-paste)
(events/listen js/window EventType.BLUR on-blur)]]
(fn []
(doseq [key keys]
(events/unlistenByKey key))))))))
;; We need to disable workspace paste when we on comments
(if (= drawing-tool :comments)
(mf/set-ref-val! disable-paste-ref true)
(mf/set-ref-val! disable-paste-ref false))
#(events/unlistenByKey key)))
(mf/with-layout-effect [on-key-down on-key-up on-mouse-wheel on-paste workspace-read-only?]
(let [keys [(events/listen js/document EventType.KEYDOWN on-key-down)
(events/listen js/document EventType.KEYUP on-key-up)
;; bind with passive=false to allow the event to be cancelled
;; https://stackoverflow.com/a/57582286/3219895
(events/listen js/window EventType.WHEEL on-mouse-wheel #js {:passive false})
(events/listen js/window EventType.PASTE on-paste)
(events/listen js/window EventType.BLUR on-blur)]]
(fn []
(doseq [key keys]
(events/unlistenByKey key)))))))
(defn setup-viewport-size [vport viewport-ref]
(mf/with-effect [vport]

View File

@@ -54,15 +54,11 @@
[app.util.debug :as dbg]
[app.util.text-editor :as ted]
[beicon.v2.core :as rx]
[okulary.core :as l]
[promesa.core :as p]
[rumext.v2 :as mf]))
;; --- Viewport
(def workspace-wasm-modifiers
(l/derived :workspace-wasm-modifiers st/state))
(defn apply-modifiers-to-selected
[selected objects modifiers]
(->> modifiers
@@ -98,7 +94,7 @@
;; DEREFS
drawing (mf/deref refs/workspace-drawing)
focus (mf/deref refs/workspace-focus-selected)
wasm-modifiers (mf/deref workspace-wasm-modifiers)
wasm-modifiers (mf/deref refs/workspace-wasm-modifiers)
workspace-editor-state (mf/deref refs/workspace-editor-state)
@@ -142,9 +138,9 @@
canvas-ref (mf/use-ref nil)
text-editor-ref (mf/use-ref nil)
;; VARS
disable-paste (mf/use-var false)
in-viewport? (mf/use-var false)
;; STATE REFS
disable-paste-ref (mf/use-ref false)
in-viewport-ref (mf/use-ref false)
;; STREAMS
move-stream (mf/use-memo #(rx/subject))
@@ -204,10 +200,10 @@
on-pointer-down (actions/on-pointer-down @hover selected edition drawing-tool text-editing? path-editing? grid-editing?
path-drawing? create-comment? space? panning z? read-only?)
on-pointer-up (actions/on-pointer-up disable-paste)
on-pointer-up (actions/on-pointer-up disable-paste-ref)
on-pointer-enter (actions/on-pointer-enter in-viewport?)
on-pointer-leave (actions/on-pointer-leave in-viewport?)
on-pointer-enter (actions/on-pointer-enter in-viewport-ref)
on-pointer-leave (actions/on-pointer-leave in-viewport-ref)
on-pointer-move (actions/on-pointer-move move-stream)
on-move-selected (actions/on-move-selected hover hover-ids selected space? z? read-only?)
on-menu-selected (actions/on-menu-selected hover hover-ids selected read-only?)
@@ -349,7 +345,7 @@
(wasm.api/show-grid @hover-top-frame-id)
(wasm.api/clear-grid))))
(hooks/setup-dom-events zoom disable-paste in-viewport? read-only? drawing-tool path-drawing?)
(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?)
@@ -639,6 +635,10 @@
:hover-top-frame-id @hover-top-frame-id
:zoom zoom}])
[:& wvd/debug-text-position-data {:selected-shapes selected-shapes
:objects base-objects
:zoom zoom}]
(when show-selection-handlers?
[:g.selection-handlers {:clipPath "url(#clip-handlers)"}
(when-not text-editing?

View File

@@ -901,10 +901,10 @@
(fn [pos value]
(cond
(not (nat-int? pos))
(u/display-not-valid :pos pos)
(u/display-not-valid :pos (str pos))
(not (string? name))
(u/display-not-valid :name name)
(not (string? value))
(u/display-not-valid :name value)
:else
(st/emit!

View File

@@ -218,7 +218,7 @@
(u/display-not-valid :name value)
:else
(st/emit! (dw/end-rename-shape id value)))))}
(st/emit! (dw/rename-shape-or-variant file-id page-id id value)))))}
:blocked
{:this true

View File

@@ -18,6 +18,7 @@
[app.main.render :as render]
[app.main.repo :as repo]
[app.main.store :as st]
[app.main.ui.context :as ctx]
[app.util.dom :as dom]
[app.util.globals :as glob]
[beicon.v2.core :as rx]
@@ -76,11 +77,12 @@
(mth/ceil height) "px")}))))
(when objects
[:& render/object-svg
{:objects objects
:object-id object-id
:embed embed
:skip-children skip-children}])))
[:& (mf/provider ctx/is-render?) {:value true}
[:& render/object-svg
{:objects objects
:object-id object-id
:embed embed
:skip-children skip-children}]])))
(mf/defc objects-svg
{::mf/wrap-props false}
@@ -88,12 +90,13 @@
(when-let [objects (mf/deref ref:objects)]
(for [object-id object-ids]
(let [objects (render/adapt-objects-for-shape objects object-id)]
[:& render/object-svg
{:objects objects
:key (str object-id)
:object-id object-id
:embed embed
:skip-children skip-children}]))))
[:& (mf/provider ctx/is-render?) {:value true}
[:& render/object-svg
{:objects objects
:key (str object-id)
:object-id object-id
:embed embed
:skip-children skip-children}]]))))
(defn- fetch-objects-bundle
[& {:keys [file-id page-id share-id object-id] :as options}]

View File

@@ -7,6 +7,9 @@
(ns app.render-wasm.api
"A WASM based render API"
(:require
[potok.v2.core :as ptk]
[app.main.data.helpers :as dsh]
[app.main.ui.shapes.text]
["react-dom/server" :as rds]
[app.common.data :as d]
[app.common.data.macros :as dm]
@@ -20,7 +23,6 @@
[app.common.types.shape.layout :as ctl]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.fonts :as fonts]
[app.main.refs :as refs]
[app.main.render :as render]
[app.main.store :as st]
@@ -60,6 +62,9 @@
(def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024))
(def ^:const DEBOUNCE_DELAY_MS 100)
(def ^:const THROTTLE_DELAY_MS 10)
(def dpr
(if use-dpr? (if (exists? js/window) js/window.devicePixelRatio 1.0) 1.0))
@@ -125,10 +130,18 @@
(render ts)))))
(declare get-text-dimensions)
(declare calculate-position-data)
(defn update-text-rect!
[id]
(when wasm/context-initialized?
(let [objects (dsh/lookup-page-objects @st/state)
shape (get objects id)
position-data (calculate-position-data shape)]
(.log js/console (:name shape) (clj->js position-data))
(st/emit!
(ptk/data-event :wasm/position-data {:id id :position-data position-data})))
(mw/emit!
{:cmd :index/update-text-rect
:page-id (:current-page-id @st/state)
@@ -828,7 +841,7 @@
(set-shape-vertical-align (get content :vertical-align))
(let [fonts (fonts/get-content-fonts content)
(let [fonts (f/get-content-fonts content)
fallback-fonts (fonts-from-text-content content true)
all-fonts (concat fonts fallback-fonts)
result (f/store-fonts shape-id all-fonts)]
@@ -874,10 +887,10 @@
(letfn [(do-render [ts]
(h/call wasm/internal-module "_set_view_end")
(render ts))]
(fns/debounce do-render 100)))
(fns/debounce do-render DEBOUNCE_DELAY_MS)))
(def render-pan
(fns/throttle render 10))
(fns/throttle render THROTTLE_DELAY_MS))
(defn set-view-box
[prev-zoom zoom vbox]
@@ -986,10 +999,7 @@
(run!
(fn [id]
(f/update-text-layout id)
(mw/emit! {:cmd :index/update-text-rect
:page-id (:current-page-id @st/state)
:shape-id id
:dimensions (get-text-dimensions id)})))))
(update-text-rect! id)))))
(defn process-pending
([shapes thumbnails full on-complete]
@@ -1043,6 +1053,7 @@
(process-pending shapes thumbnails full noop-fn
(fn []
(when render-callback (render-callback))
(render-finish)
(ug/dispatch! (ug/event "penpot:wasm:set-objects")))))))
(defn clear-focus-mode
@@ -1246,9 +1257,15 @@
(defn clear-canvas
[]
;; TODO: perform corresponding cleaning
(set! wasm/context-initialized? false)
(h/call wasm/internal-module "_clean_up"))
(try
;; TODO: perform corresponding cleaning
(set! wasm/context-initialized? false)
(h/call wasm/internal-module "_clean_up")
;; If this calls panics we don't want to crash. This happens sometimes
;; with hot-reload in develop
(catch :default error
(.error js/console error))))
(defn show-grid
[id]
@@ -1291,12 +1308,7 @@
(mem/free)
content))
(defn- calculate-bool*
[bool-type]
(-> (h/call wasm/internal-module "_calculate_bool" (sr/translate-bool-type bool-type))
(mem/->offset-32)))
(defn calculate-bool
(defn calculate-bool*
[bool-type ids]
(let [size (mem/get-alloc-size ids UUID-U8-SIZE)
heap (mem/get-heap-u32)
@@ -1307,7 +1319,10 @@
offset
(rseq ids))
(let [offset (calculate-bool* bool-type)
(let [offset
(-> (h/call wasm/internal-module "_calculate_bool" (sr/translate-bool-type bool-type))
(mem/->offset-32))
length (aget heap offset)
data (mem/slice heap
(+ offset 1)
@@ -1316,6 +1331,81 @@
(mem/free)
content)))
(defn calculate-bool
[shape objects]
;; We need to be able to calculate the boolean data but we cannot
;; depend on the serialization flow.
;; start_temp_object / end_temp_object create a new shapes_pool
;; temporary and then we serialize the objects needed to calculate the
;; boolean object.
;; After the content is returned we discard that temporary context
(h/call wasm/internal-module "_start_temp_objects")
(let [bool-type (get shape :bool-type)
ids (get shape :shapes)
all-children
(->> ids
(mapcat #(cfh/get-children-with-self objects %)))]
(h/call wasm/internal-module "_init_shapes_pool" (count all-children))
(run! (partial set-object objects) all-children)
(let [content (-> (calculate-bool* bool-type ids)
(path.impl/path-data))]
(h/call wasm/internal-module "_end_temp_objects")
content)))
(def POSITION-DATA-U8-SIZE 36)
(def POSITION-DATA-U32-SIZE (/ POSITION-DATA-U8-SIZE 4))
(defn calculate-position-data
[shape]
(use-shape (:id shape))
(let [heapf32 (mem/get-heap-f32)
heapu32 (mem/get-heap-u32)
offset (-> (h/call wasm/internal-module "_calc_position_data")
(mem/->offset-32))
length (aget heapu32 offset)
max-offset (+ offset 1 (* length POSITION-DATA-U32-SIZE))
result
(loop [result (transient [])
offset (inc offset)]
(if (< offset max-offset)
(let [entry (dr/read-position-data-entry heapu32 heapf32 offset)]
(recur (conj! result entry)
(+ offset POSITION-DATA-U32-SIZE)))
(persistent! result)))
result
(->> result
(mapv
(fn [{:keys [paragraph span start-pos end-pos direction x y width height]}]
(let [content (:content shape)
element (-> content :children
(get 0) :children ;; paragraph-set
(get paragraph) :children ;; paragraph
(get span))
text (subs (:text element) start-pos end-pos)]
{:x x
:y y
:width width
:height height
:direction direction
:font-family (get element :font-family)
:font-size (get element :font-size)
:font-weight (get element :font-weight)
:text-transform (get element :text-transform)
:text-decoration (get element :text-decoration)
:letter-spacing (get element :letter-spacing)
:font-style (get element :font-style)
:fills (get element :fills)
:text text}))))]
(mem/free)
result))
(defn init-wasm-module
[module]

View File

@@ -9,6 +9,7 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.logging :as log]
[app.common.types.text :as txt]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.fonts :as fonts]
@@ -49,10 +50,13 @@
:builtin))
(defn- font-db-data
[font-id font-variant-id]
[font-id font-variant-id font-weight-fallback font-style-fallback]
(let [font (fonts/get-font-data font-id)
closest-variant (fonts/find-closest-variant font font-weight-fallback font-style-fallback)
variant (fonts/get-variant font font-variant-id)]
variant))
(if (or (nil? closest-variant) (= closest-variant variant))
variant
closest-variant)))
(defn- font-id->uuid [font-id]
(case (font-backend font-id)
@@ -63,22 +67,22 @@
:builtin
uuid/zero))
(defn ^:private font-id->asset-id [font-id font-variant-id]
(defn ^:private font-id->asset-id [font-id font-variant-id font-weight font-style]
(case (font-backend font-id)
:google
font-id
:custom
(let [font-uuid (custom-font-id->uuid font-id)
matching-font (d/seek (fn [[_ font]]
(let [variant-id (or (:font-variant-id font) (dm/str (:font-style font) "-" (:font-weight font)))]
(and (= (:font-id font) font-uuid)
(or (nil? font-variant-id)
(= variant-id font-variant-id)))))
(seq @fonts))]
matching-font (some (fn [[_ font]]
(and (= (:font-id font) font-uuid)
(= (str (:font-weight font)) (str font-weight))
font))
(seq @fonts))]
(when matching-font
(:ttf-file-id (second matching-font))))
(:ttf-file-id matching-font)))
:builtin
(let [variant (font-db-data font-id font-variant-id)]
(let [variant (font-db-data font-id font-variant-id font-weight font-style)]
(:ttf-url variant))))
(defn update-text-layout
@@ -100,6 +104,7 @@
ptr (h/call wasm/internal-module "_alloc_bytes" size)
heap (gobj/get ^js wasm/internal-module "HEAPU8")
mem (js/Uint8Array. (.-buffer heap) ptr size)]
(.set mem (js/Uint8Array. font-array-buffer))
(h/call wasm/internal-module "_store_font"
(aget shape-id-buffer 0)
@@ -134,17 +139,17 @@
(rx/empty))))})
(defn- google-font-ttf-url
[font-id font-variant-id]
(let [variant (font-db-data font-id font-variant-id)]
[font-id font-variant-id font-weight font-style]
(let [variant (font-db-data font-id font-variant-id font-weight font-style)]
(if-let [ttf-url (:ttf-url variant)]
(str/replace ttf-url "https://fonts.gstatic.com/s/" (u/join cf/public-uri "/internal/gfonts/font/"))
nil)))
(defn- font-id->ttf-url
[font-id asset-id font-variant-id]
[font-id asset-id font-variant-id font-weight font-style]
(case (font-backend font-id)
:google
(google-font-ttf-url font-id font-variant-id)
(google-font-ttf-url font-id font-variant-id font-weight font-style)
:custom
(dm/str (u/join cf/public-uri "assets/by-id/" asset-id))
:builtin
@@ -153,7 +158,7 @@
(defn- store-font-id
[shape-id font-data asset-id emoji? fallback?]
(when asset-id
(let [uri (font-id->ttf-url (:font-id font-data) asset-id (:font-variant-id font-data))
(let [uri (font-id->ttf-url (:font-id font-data) asset-id (:font-variant-id font-data) (:weight font-data) (:style-name font-data))
id-buffer (uuid/get-u32 (:wasm-id font-data))
font-data (assoc font-data :family-id-buffer id-buffer)
font-stored? (not= 0 (h/call wasm/internal-module "_is_font_uploaded"
@@ -187,6 +192,30 @@
(catch :default _e
uuid/zero)))
(defn normalize-span-font
[span paragraph]
(let [font-id (:font-id span)
font-variant-id (:font-variant-id span)
font-weight-fallback (or (:font-weight span) (:font-weight paragraph))
font-style-fallback (or (:font-style span) (:font-style paragraph))
font-data (font-db-data font-id font-variant-id font-weight-fallback font-style-fallback)]
(-> span
(assoc :font-variant-id (or (:id font-data) (:id font-data) font-variant-id)
:font-weight (or (:weight font-data) font-weight-fallback)
:font-style (or (:style font-data) font-style-fallback)))))
(defn normalize-paragraph-font
[paragraph]
(let [font-id (:font-id paragraph)
font-variant-id (:font-variant-id paragraph)
font-weight-fallback (:font-weight paragraph)
font-style-fallback (:font-style paragraph)
font-data (font-db-data font-id font-variant-id font-weight-fallback font-style-fallback)]
(-> paragraph
(assoc :font-variant-id (or (:id font-data) (:id font-data) font-variant-id)
:font-weight (or (:weight font-data) font-weight-fallback)
:font-style (or (:style font-data) font-style-fallback)))))
(defn serialize-font-size
[font-size]
(cond
@@ -244,26 +273,41 @@
(string? letter-spacing)
(or (d/parse-double letter-spacing) default-letter-spacing)))
(defn normalize-font-variant
[font-variant-id]
(if (or (nil? font-variant-id) (str/blank? font-variant-id))
"regular"
font-variant-id))
(defn store-font
[shape-id font]
(let [font-id (get font :font-id)
font-variant-id (get font :font-variant-id)
normalized-variant-id (when font-variant-id
(-> font-variant-id
(str/lower)
(str/replace #"\s+" "")))
font-weight-fallback (or (get font :font-weight) 400)
font-style-fallback (or (get font :font-style) "normal")
emoji? (get font :is-emoji false)
fallback? (get font :is-fallback false)
font-data (font-db-data font-id normalized-variant-id font-weight-fallback font-style-fallback)
wasm-id (font-id->uuid font-id)
raw-weight (or (:weight (font-db-data font-id font-variant-id)) 400)
raw-weight (or (:weight font-data) font-weight-fallback)
weight (serialize-font-weight raw-weight)
style (serialize-font-style (cond
(str/includes? font-variant-id "italic") "italic"
(str/includes? raw-weight "italic") "italic"
:else "normal"))
asset-id (font-id->asset-id font-id font-variant-id)
style (cond
(str/includes? (or normalized-variant-id "") "italic") "italic"
(str/includes? raw-weight "italic") "italic"
:else font-style-fallback)
variant-id (or (:id font-data) normalized-variant-id)
asset-id (font-id->asset-id font-id variant-id raw-weight style)
font-data {:wasm-id wasm-id
:font-id font-id
:font-variant-id font-variant-id
:style style
:font-variant-id variant-id
:style (serialize-font-style style)
:style-name style
:weight weight}]
(store-font-id shape-id font-data asset-id emoji? fallback?)))
;; FIXME: This is a temporary function to load the fallback fonts for the editor.
@@ -273,6 +317,29 @@
(doseq [font fonts]
(fonts/ensure-loaded! (:font-id font) (:font-variant-id font))))
(defn get-content-fonts
"Extends from app.main.fonts/get-content-fonts. Extracts the fonts used by the content of a text shape, resolving the correct font variant info."
[content]
(let [paragraph-set (first (get content :children))
paragraphs (get paragraph-set :children)]
(->> paragraphs
(mapcat #(get % :children))
(filter txt/is-text-node?)
(reduce
(fn [result {:keys [font-id font-variant-id font-weight font-style] :as node}]
(let [resolved-font-id (or font-id (:font-id txt/default-typography))
resolved-variant-id (or font-variant-id (:font-variant-id txt/default-typography))
font-weight-fallback (or font-weight (:font-weight txt/default-typography) 400)
font-style-fallback (or font-style (:font-style txt/default-typography) "normal")
font-data (font-db-data resolved-font-id resolved-variant-id font-weight-fallback font-style-fallback)
font-ref {:font-id resolved-font-id
:font-variant-id (or (:id font-data) (:name font-data) resolved-variant-id)
:font-weight (or (:weight font-data) font-weight-fallback)
:font-style (or (:style font-data) font-style-fallback)}]
(conj result font-ref)))
#{}))))
(defn store-fonts
[shape-id fonts]
(keep (fn [font] (store-font shape-id font)) fonts))

View File

@@ -80,7 +80,7 @@
font-size (f/serialize-font-size font-size)
line-height (f/serialize-line-height (get span :line-height) paragraph-line-height)
letter-spacing (f/serialize-letter-spacing (get paragraph :letter-spacing))
letter-spacing (f/serialize-letter-spacing (get span :letter-spacing))
font-weight (get span :font-weight paragraph-font-weight)
font-weight (f/serialize-font-weight font-weight)
@@ -142,7 +142,9 @@
;; buffer has the following format:
;; [<num-spans> <paragraph_attributes> <spans_attributes> <text>]
[spans paragraph text]
(let [num-spans (count spans)
(let [normalized-paragraph (f/normalize-paragraph-font paragraph)
normalized-spans (map #(f/normalize-span-font % normalized-paragraph) spans)
num-spans (count normalized-spans)
fills-size (* types.fills.impl/FILL-U8-SIZE MAX-TEXT-FILLS)
metadata-size (+ PARAGRAPH-ATTR-U8-SIZE
(* num-spans (+ SPAN-ATTR-U8-SIZE fills-size)))
@@ -157,8 +159,8 @@
(-> offset
(mem/write-u32 dview num-spans)
(write-paragraph dview paragraph)
(write-spans dview spans paragraph)
(write-paragraph dview normalized-paragraph)
(write-spans dview normalized-spans normalized-paragraph)
(mem/write-buffer heapu8 text-buffer))
(h/call wasm/internal-module "_set_shape_text_content")))

View File

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

View File

@@ -291,7 +291,8 @@
(api/set-grid-layout-data shape)
(ctl/flex-layout? shape)
(api/set-flex-layout shape)))
(api/set-flex-layout shape))
(api/set-layout-child shape))
;; Property not in WASM
nil))))
@@ -322,7 +323,7 @@
(rx/subs! #(api/request-render "set-wasm-attrs"))))
;; `conj` empty set initialization
(def conj* (fnil conj #{}))
(def conj* (fnil conj (d/ordered-set)))
(defn- impl-assoc
[self k v]

View File

@@ -57,10 +57,11 @@
(= (dom/get-tag-name target) "INPUT")]
;; ignore when pasting into an editable control
(when-not (or content-editable? is-input?)
(if-not (or content-editable? is-input?)
(-> event
(dom/event->browser-event)
(from-clipboard-event options))))))
(from-clipboard-event options))
(rx/empty)))))
(defn from-drop-event
"Get clipboard stream from drop event"

View File

@@ -35,14 +35,26 @@ goog.scope(function () {
};
}
self.event = function(...args) {
return new CustomEvent(...args);
self.event = function(name, detail) {
const options = {};
if (detail !== undefined) {
options.detail = detail;
}
return new CustomEvent(name, options);
};
self.dispatch_BANG_ = function(...args) {
self.document.dispatchEvent(...args);
};
self.listen = function(...args) {
self.document.addEventListener(...args);
};
self.unlisten = function(...args) {
self.document.removeEventListener(...args);
}
self.window = (function () {
if (typeof goog.global.window !== "undefined") {
return goog.global.window;

View File

@@ -48,6 +48,9 @@
"This function strips units from attr values and un-scapes font-family"
[k v]
(cond
(= v "mixed")
:multiple
(and (or (= k :font-size)
(= k :letter-spacing))
(= (str/slice v -2) "px"))

View File

@@ -257,7 +257,7 @@
(filter (if clip-children?
(comp overlaps-parent? :clip-parents)
(constantly true)))
(map :id))
(keep :id))
result)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@@ -6,6 +6,7 @@
(ns debug
(:require
[app.render-wasm.api :as wasm.api]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.repair :as cfr]
@@ -456,3 +457,10 @@
(defn ^:export network-averages
[]
(.log js/console (clj->js @http/network-averages)))
(defn ^:export tmp
[]
(let [objects (dsh/lookup-page-objects @st/state)
shape (->> (get-selected @st/state) (first) (get objects))]
(wasm.api/calculate-position-data shape))
)

View File

@@ -26,6 +26,7 @@ import LayoutType from "./layout/LayoutType.js";
* @typedef {Object} TextEditorOptions
* @property {CSSStyleDeclaration|Object.<string,*>} [styleDefaults]
* @property {SelectionControllerDebug} [debug]
* @property {boolean} [shouldUpdatePositionOnScroll=false]
* @property {boolean} [allowHTMLPaste=false]
*/
@@ -92,6 +93,21 @@ export class TextEditor extends EventTarget {
*/
#canvas = null;
/**
* Text editor options.
*
* @type {TextEditorOptions}
*/
#options = {};
/**
* A boolean indicating that this instance was
* disposed or not.
*
* @type {boolean}
*/
#isDisposed = false;
/**
* Constructor.
*
@@ -101,9 +117,9 @@ export class TextEditor extends EventTarget {
*/
constructor(element, canvas, options) {
super();
if (!(element instanceof HTMLElement))
if (!(element instanceof HTMLElement)) {
throw new TypeError("Invalid text editor element");
}
this.#element = element;
this.#canvas = canvas;
this.#events = {
@@ -119,6 +135,7 @@ export class TextEditor extends EventTarget {
keydown: this.#onKeyDown,
};
this.#styleDefaults = options?.styleDefaults;
this.#options = options;
this.#setup(options);
}
@@ -150,14 +167,18 @@ export class TextEditor extends EventTarget {
/**
* Setups the root element.
*
* @param {TextEditorOptions} options
*/
#setupRoot() {
#setupRoot(options) {
this.#root = createEmptyRoot(this.#styleDefaults);
this.#element.appendChild(this.#root);
}
/**
* Setups event listeners.
*
* @param {TextEditorOptions} options
*/
#setupListeners(options) {
this.#changeController.addEventListener("change", this.#onChange);
@@ -174,18 +195,61 @@ export class TextEditor extends EventTarget {
}
/**
* Setups the elements, the properties and the
* initial content.
* Disposes everything.
*/
#setup(options) {
this.#setupElementProperties(options);
this.#setupRoot(options);
dispose() {
if (this.#isDisposed) {
return this;
}
this.#isDisposed = true;
// Dispose change controller.
this.#changeController.removeEventListener("change", this.#onChange);
this.#changeController.dispose();
this.#changeController = null;
// Disposes selection controller.
this.#selectionController.removeEventListener(
"stylechange",
this.#onStyleChange,
);
this.#selectionController.dispose();
this.#selectionController = null;
// Disposes the rest of event listeners.
removeEventListeners(this.#element, this.#events);
if (this.#options.shouldUpdatePositionOnScroll) {
window.removeEventListener("scroll", this.#onScroll);
}
// Disposes references to DOM elements.
this.#element = null;
this.#root = null;
return this;
}
/**
* Setups controllers.
*
* @param {TextEditorOptions} options
*/
#setupControllers(options) {
this.#changeController = new ChangeController(this);
this.#selectionController = new SelectionController(
this,
document.getSelection(),
options,
);
}
/**
* Setups the elements, the properties and the
* initial content.
*/
#setup(options) {
this.#setupElementProperties(options);
this.#setupRoot(options);
this.#setupControllers(options);
this.#setupListeners(options);
}
@@ -242,7 +306,9 @@ export class TextEditor extends EventTarget {
* @param {CustomEvent} e
* @returns {void}
*/
#onChange = (e) => this.dispatchEvent(new e.constructor(e.type, e));
#onChange = (e) => {
this.dispatchEvent(new e.constructor(e.type, e));
};
/**
* Dispatchs a `stylechange` event.
@@ -421,6 +487,15 @@ export class TextEditor extends EventTarget {
);
}
/**
* Indicates that the TextEditor was disposed.
*
* @type {boolean}
*/
get isDisposed() {
return this.#isDisposed;
}
/**
* Root element that contains all the paragraphs.
*
@@ -478,6 +553,15 @@ export class TextEditor extends EventTarget {
return this.#selectionController.currentStyle;
}
/**
* Text editor options
*
* @type {TextEditorOptions}
*/
get options() {
return this.#options;
}
/**
* Focus the element
*/
@@ -540,7 +624,8 @@ export class TextEditor extends EventTarget {
* Applies the current styles to the selection or
* the current DOM node at the caret.
*
* @param {*} styles
* @param {Object.<string, *>} styles
* @returns {TextEditor}
*/
applyStylesToSelection(styles) {
this.#selectionController.startMutation();
@@ -553,6 +638,8 @@ export class TextEditor extends EventTarget {
/**
* Selects all content.
*
* @returns {TextEditor}
*/
selectAll() {
this.#selectionController.selectAll();
@@ -562,30 +649,12 @@ export class TextEditor extends EventTarget {
/**
* Moves cursor to end.
*
* @returns
* @returns {TextEditor}
*/
cursorToEnd() {
this.#selectionController.cursorToEnd();
return this;
}
/**
* Disposes everything.
*/
dispose() {
this.#changeController.removeEventListener("change", this.#onChange);
this.#changeController.dispose();
this.#changeController = null;
this.#selectionController.removeEventListener(
"stylechange",
this.#onStyleChange,
);
this.#selectionController.dispose();
this.#selectionController = null;
removeEventListeners(this.#element, this.#events);
this.#element = null;
this.#root = null;
}
}
/**
@@ -615,47 +684,98 @@ export function createRootFromString(string) {
return root;
}
export function isEditor(instance) {
/**
* Returns true if the passed object is a TextEditor
* instance.
*
* @param {*} instance
* @returns {boolean}
*/
export function isTextEditor(instance) {
return instance instanceof TextEditor;
}
/* Convenience function based API for Text Editor */
/**
* Returns the root element of a TextEditor
* instance.
*
* @param {TextEditor} instance
* @returns {HTMLDivElement}
*/
export function getRoot(instance) {
if (isEditor(instance)) {
if (isTextEditor(instance)) {
return instance.root;
} else {
return null;
}
return null;
}
/**
* Sets the root of the text editor.
*
* @param {TextEditor} instance
* @param {HTMLDivElement} root
* @returns {TextEditor}
*/
export function setRoot(instance, root) {
if (isEditor(instance)) {
if (isTextEditor(instance)) {
instance.root = root;
}
return instance;
}
/**
* Creates a new TextEditor instance.
*
* @param {HTMLDivElement} element
* @param {HTMLCanvasElement} canvas
* @param {TextEditorOptions} options
* @returns {TextEditor}
*/
export function create(element, canvas, options) {
return new TextEditor(element, canvas, { ...options });
}
/**
* Returns the current style of the TextEditor instance.
*
* @param {TextEditor} instance
* @returns {CSSStyleDeclaration|undefined}
*/
export function getCurrentStyle(instance) {
if (isEditor(instance)) {
if (isTextEditor(instance)) {
return instance.currentStyle;
}
return null;
}
/**
* Applies the specified styles to the TextEditor
* passed.
*
* @param {TextEditor} instance
* @param {Object.<string, *>} styles
* @returns {TextEditor|null}
*/
export function applyStylesToSelection(instance, styles) {
if (isEditor(instance)) {
if (isTextEditor(instance)) {
return instance.applyStylesToSelection(styles);
}
return null;
}
/**
* Disposes the current instance resources by nullifying
* every property.
*
* @param {TextEditor} instance
* @returns {TextEditor|null}
*/
export function dispose(instance) {
if (isEditor(instance)) {
instance.dispose();
if (isTextEditor(instance)) {
return instance.dispose();
}
return null;
}
export default TextEditor;

View File

@@ -10,6 +10,7 @@ import {
mapContentFragmentFromHTML,
mapContentFragmentFromString,
} from "../content/dom/Content.js";
import { TextEditor } from "../TextEditor.js";
/**
* Returns a DocumentFragment from text/html.
@@ -38,19 +39,26 @@ function getPlainFragmentFromClipboardData(selectionController, clipboardData) {
}
/**
* Returns a DocumentFragment (or null) if it contains
* a compatible clipboardData type.
* Returns a document fragment of html data.
*
* @param {DataTransfer} clipboardData
* @returns {DocumentFragment|null}
* @returns {DocumentFragment}
*/
function getFragmentFromClipboardData(selectionController, clipboardData) {
function getFormattedOrPlainFragmentFromClipboardData(
selectionController,
clipboardData,
) {
if (clipboardData.types.includes("text/html")) {
return getFormattedFragmentFromClipboardData(selectionController, clipboardData)
return getFormattedFragmentFromClipboardData(
selectionController,
clipboardData,
);
} else if (clipboardData.types.includes("text/plain")) {
return getPlainFragmentFromClipboardData(selectionController, clipboardData)
return getPlainFragmentFromClipboardData(
selectionController,
clipboardData,
);
}
return null
}
/**
@@ -71,18 +79,37 @@ export function paste(event, editor, selectionController) {
let fragment = null;
if (editor?.options?.allowHTMLPaste) {
fragment = getFragmentFromClipboardData(selectionController, event.clipboardData);
fragment = getFormattedOrPlainFragmentFromClipboardData(event.clipboardData);
} else {
fragment = getPlainFragmentFromClipboardData(selectionController, event.clipboardData);
}
if (!fragment) {
// NOOP
return;
}
if (selectionController.isCollapsed) {
selectionController.insertPaste(fragment);
const hasOnlyOneParagraph = fragment.children.length === 1;
const hasOnlyOneTextSpan = fragment.firstElementChild.children.length === 1;
const forceTextSpan = fragment.firstElementChild.dataset.textSpan === "force";
if (hasOnlyOneParagraph
&& hasOnlyOneTextSpan
&& forceTextSpan) {
selectionController.insertIntoFocus(fragment.textContent);
} else {
selectionController.insertPaste(fragment);
}
} else {
selectionController.replaceWithPaste(fragment);
const hasOnlyOneParagraph = fragment.children.length === 1;
const hasOnlyOneTextSpan = fragment.firstElementChild.children.length === 1;
const forceTextSpan = fragment.firstElementChild.dataset.textSpan === "force";
if (hasOnlyOneParagraph
&& hasOnlyOneTextSpan
&& forceTextSpan) {
selectionController.replaceText(fragment.textContent);
} else {
selectionController.replaceWithPaste(fragment);
}
}
}

View File

@@ -230,14 +230,19 @@ export function mapContentFragmentFromString(string, styleDefaults) {
const fragment = document.createDocumentFragment();
for (const line of lines) {
if (line === "") {
fragment.appendChild(createEmptyParagraph(styleDefaults));
} else {
fragment.appendChild(
createParagraph(
[createTextSpan(new Text(line), styleDefaults)],
styleDefaults,
),
createEmptyParagraph(styleDefaults)
);
} else {
const textSpan = createTextSpan(new Text(line), styleDefaults);
const paragraph = createParagraph(
[textSpan],
styleDefaults,
);
if (lines.length === 1) {
paragraph.dataset.textSpan = "force";
}
fragment.appendChild(paragraph);
}
}
return fragment;

View File

@@ -6,6 +6,7 @@
* Copyright (c) KALEIDOS INC
*/
import StyleDeclaration from '../../controllers/StyleDeclaration.js';
import { getFills } from "./Color.js";
const DEFAULT_FONT_SIZE = "16px";
@@ -338,13 +339,14 @@ export function setStylesFromObject(element, allowedStyles, styleObject) {
continue;
}
let styleValue = styleObject[styleName];
if (!styleValue)
continue;
if (styleName === "font-family") {
styleValue = sanitizeFontFamily(styleValue);
}
if (styleValue) {
setStyle(element, styleName, styleValue, styleUnit);
}
setStyle(element, styleName, styleValue, styleUnit);
}
return element;
}
@@ -386,7 +388,8 @@ export function setStylesFromDeclaration(
* @returns {HTMLElement}
*/
export function setStyles(element, allowedStyles, styleObjectOrDeclaration) {
if (styleObjectOrDeclaration instanceof CSSStyleDeclaration) {
if (styleObjectOrDeclaration instanceof CSSStyleDeclaration
|| styleObjectOrDeclaration instanceof StyleDeclaration) {
return setStylesFromDeclaration(
element,
allowedStyles,
@@ -426,13 +429,15 @@ export function mergeStyles(allowedStyles, styleDeclaration, newStyles) {
const mergedStyles = {};
for (const [styleName, styleUnit] of allowedStyles) {
if (styleName in newStyles) {
mergedStyles[styleName] = newStyles[styleName];
const styleValue = newStyles[styleName];
mergedStyles[styleName] = styleValue;
} else {
mergedStyles[styleName] = getStyleFromDeclaration(
const styleValue = getStyleFromDeclaration(
styleDeclaration,
styleName,
styleUnit,
);
mergedStyles[styleName] = styleValue;
}
}
return mergedStyles;

View File

@@ -6,6 +6,8 @@
* Copyright (c) KALEIDOS INC
*/
import SafeGuard from '../../controllers/SafeGuard.js';
/**
* Iterator direction.
*
@@ -245,6 +247,51 @@ export class TextNodeIterator {
this.#currentNode = previousNode;
return this.#currentNode;
}
/**
* Returns an array of text nodes.
*
* @param {TextNode} startNode
* @param {TextNode} endNode
* @returns {Array<TextNode>}
*/
collectFrom(startNode, endNode) {
const nodes = [];
for (const node of this.iterateFrom(startNode, endNode)) {
nodes.push(node);
}
return nodes;
}
/**
* Iterates over a list of nodes.
*
* @param {TextNode} startNode
* @param {TextNode} endNode
* @yields {TextNode}
*/
* iterateFrom(startNode, endNode) {
const comparedPosition = startNode.compareDocumentPosition(
endNode
);
this.#currentNode = startNode;
SafeGuard.start();
while (this.#currentNode !== endNode) {
yield this.#currentNode;
SafeGuard.update();
if (comparedPosition === Node.DOCUMENT_POSITION_PRECEDING) {
if (!this.previousNode()) {
break;
}
} else if (comparedPosition === Node.DOCUMENT_POSITION_FOLLOWING) {
if (!this.nextNode()) {
break;
}
} else {
break;
}
}
}
}
export default TextNodeIterator;

View File

@@ -28,7 +28,20 @@ export function update() {
}
}
let timeoutId = 0
export function throwAfter(error, timeout = SAFE_GUARD_TIME) {
timeoutId = setTimeout(() => {
throw error
}, timeout)
}
export function throwCancel() {
clearTimeout(timeoutId)
}
export default {
start,
update,
}
throwAfter,
throwCancel,
};

View File

@@ -54,6 +54,7 @@ import { isRoot, setRootStyles } from "../content/dom/Root.js";
import { SelectionDirection } from "./SelectionDirection.js";
import SafeGuard from "./SafeGuard.js";
import { sanitizeFontFamily } from "../content/dom/Style.js";
import StyleDeclaration from './StyleDeclaration.js';
/**
* Supported options for the SelectionController.
@@ -64,39 +65,7 @@ import { sanitizeFontFamily } from "../content/dom/Style.js";
/**
* SelectionController uses the same concepts used by the Selection API but extending it to support
* our own internal model based on paragraphs (in drafconst textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraph([createTextSpan(new Text("Hello, "))]),
createEmptyParagraph(),
createParagraph([createTextSpan(new Text("World!"))]),
]);
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(
textEditorMock,
selection
);
focus(
selection,
textEditorMock,
root.childNodes.item(2).firstChild.firstChild,
0
);
selectionController.mergeBackwardParagraph();
expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.children.length).toBe(2);
expect(textEditorMock.root.dataset.itype).toBe("root");
expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph");
expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf(
HTMLSpanElement
);
expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe(
"span"
);
expect(textEditorMock.root.textContent).toBe("Hello, World!");
expect(textEditorMock.root.firstChild.textContent).toBe("Hello, ");
expect(textEditorMock.root.lastChild.textContent).toBe("World!");
t.js they were called blocks) and text spans.
* our own internal model based on paragraphs (in draft.js they were called blocks) and text spans.
*/
export class SelectionController extends EventTarget {
/**
@@ -164,21 +133,12 @@ export class SelectionController extends EventTarget {
#textNodeIterator = null;
/**
* CSSStyleDeclaration that we can mutate
* StyleDeclaration that we can mutate
* to handle style changes.
*
* @type {CSSStyleDeclaration}
* @type {StyleDeclaration}
*/
#currentStyle = null;
/**
* Element used to have a custom CSSStyleDeclaration
* that we can modify to handle style changes when the
* selection is changed.
*
* @type {HTMLDivElement}
*/
#inertElement = null;
#currentStyle = new StyleDeclaration();
/**
* @type {SelectionControllerDebug}
@@ -275,19 +235,62 @@ export class SelectionController extends EventTarget {
*
* @param {HTMLElement} element
*/
#applyStylesToCurrentStyle(element) {
#applyStylesFromElementToCurrentStyle(element) {
for (let index = 0; index < element.style.length; index++) {
const styleName = element.style.item(index);
if (styleName === "--fills") {
continue;
}
let styleValue = element.style.getPropertyValue(styleName);
if (styleName === "font-family") {
styleValue = sanitizeFontFamily(styleValue);
}
this.#currentStyle.setProperty(styleName, styleValue);
}
}
/**
* Applies some styles to the currentStyle
* CSSStyleDeclaration
*
* @param {HTMLElement} element
*/
#mergeStylesFromElementToCurrentStyle(element) {
for (let index = 0; index < element.style.length; index++) {
const styleName = element.style.item(index);
let styleValue = element.style.getPropertyValue(styleName);
if (styleName === "font-family") {
styleValue = sanitizeFontFamily(styleValue);
}
this.#currentStyle.setProperty(styleName, styleValue);
this.#currentStyle.mergeProperty(styleName, styleValue);
}
}
/**
* Updates current styles based on the currently selected text spans.
*
* @param {HTMLSpanElement} startNode
* @param {HTMLSpanElement} endNode
*/
#updateCurrentStyleFrom(startNode, endNode) {
this.#applyDefaultStylesToCurrentStyle();
const root = startNode.parentElement.parentElement.parentElement;
this.#applyStylesFromElementToCurrentStyle(root);
// FIXME: I don't like this approximation. Having to iterate nodes twice
// is bad for performance. I think we need another way of "computing"
// the cascade.
for (const textNode of this.#textNodeIterator.iterateFrom(startNode, endNode)) {
const paragraph = textNode.parentElement.parentElement;
this.#applyStylesFromElementToCurrentStyle(paragraph);
}
for (const textNode of this.#textNodeIterator.iterateFrom(startNode, endNode)) {
const textSpan = textNode.parentElement;
this.#mergeStylesFromElementToCurrentStyle(textSpan);
}
return this;
}
/**
* Updates current styles based on the currently selected text span.
*
@@ -297,20 +300,14 @@ export class SelectionController extends EventTarget {
#updateCurrentStyle(textSpan) {
this.#applyDefaultStylesToCurrentStyle();
const root = textSpan.parentElement.parentElement;
this.#applyStylesToCurrentStyle(root);
this.#applyStylesFromElementToCurrentStyle(root);
const paragraph = textSpan.parentElement;
this.#applyStylesToCurrentStyle(paragraph);
this.#applyStylesToCurrentStyle(textSpan);
this.#applyStylesFromElementToCurrentStyle(paragraph);
this.#applyStylesFromElementToCurrentStyle(textSpan);
return this;
}
/**
* This is called on every `selectionchange` because it is dispatched
* only by the `document` object.
*
* @param {Event} e
*/
#onSelectionChange = (e) => {
#updateState() {
// If we're outside the contenteditable element, then
// we return.
if (!this.hasFocus) {
@@ -320,17 +317,23 @@ export class SelectionController extends EventTarget {
let focusNodeChanges = false;
let anchorNodeChanges = false;
if (this.#focusNode !== this.#selection.focusNode) {
if (
this.#focusNode !== this.#selection.focusNode ||
this.#focusOffset !== this.#selection.focusOffset
) {
this.#focusNode = this.#selection.focusNode;
this.#focusOffset = this.#selection.focusOffset;
focusNodeChanges = true;
}
this.#focusOffset = this.#selection.focusOffset;
if (this.#anchorNode !== this.#selection.anchorNode) {
if (
this.#anchorNode !== this.#selection.anchorNode ||
this.#anchorOffset !== this.#selection.anchorOffset
) {
this.#anchorNode = this.#selection.anchorNode;
this.#anchorOffset = this.#selection.anchorOffset;
anchorNodeChanges = true;
}
this.#anchorOffset = this.#selection.anchorOffset;
// We need to handle multi selection from firefox
// and remove all the old ranges and just keep the
@@ -359,7 +362,7 @@ export class SelectionController extends EventTarget {
// If focus node changed, we need to retrieve all the
// styles of the current text span and dispatch an event
// to notify that the styles have changed.
if (focusNodeChanges) {
if (focusNodeChanges || anchorNodeChanges) {
this.#notifyStyleChange();
}
@@ -372,43 +375,42 @@ export class SelectionController extends EventTarget {
if (this.#debug) {
this.#debug.update(this);
}
};
}
/**
* This is called on every `selectionchange` because it is dispatched
* only by the `document` object.
*
* @param {Event} e
*/
#onSelectionChange = (_) => this.#updateState();
/**
* Notifies that the styles have changed.
*/
#notifyStyleChange() {
const textSpan = this.focusTextSpan;
if (textSpan) {
this.#updateCurrentStyle(textSpan);
this.dispatchEvent(
new CustomEvent("stylechange", {
detail: this.#currentStyle,
}),
);
} else {
const firstTextSpan =
if (this.#selection.isCollapsed) {
// CARET
const textSpan =
this.focusTextSpan ??
this.#textEditor.root?.firstElementChild?.firstElementChild;
if (firstTextSpan) {
this.#updateCurrentStyle(firstTextSpan);
this.dispatchEvent(
new CustomEvent("stylechange", {
detail: this.#currentStyle,
}),
);
}
this.#updateCurrentStyle(textSpan);
} else {
// SELECTION.
this.#updateCurrentStyleFrom(this.#anchorNode, this.#focusNode);
}
this.dispatchEvent(
new CustomEvent("stylechange", {
detail: this.#currentStyle,
}),
);
}
/**
* Setups
*/
#setup() {
// This element is not attached to the DOM
// so it doesn't trigger style or layout calculations.
// That's why it's called "inertElement".
this.#inertElement = document.createElement("div");
this.#currentStyle = this.#inertElement.style;
this.#applyDefaultStylesToCurrentStyle();
if (this.#selection.rangeCount > 0) {
@@ -428,6 +430,22 @@ export class SelectionController extends EventTarget {
document.addEventListener("selectionchange", this.#onSelectionChange);
}
/**
* Disposes the current resources.
*/
dispose() {
document.removeEventListener("selectionchange", this.#onSelectionChange);
this.#textEditor = null;
this.#ranges.clear();
this.#ranges = null;
this.#range = null;
this.#selection = null;
this.#focusNode = null;
this.#anchorNode = null;
this.#mutations.dispose();
this.#mutations = null;
}
/**
* Returns a Range-like object.
*
@@ -502,6 +520,8 @@ export class SelectionController extends EventTarget {
* Marks the start of a mutation.
*
* Clears all the mutations kept in CommandMutations.
*
* @returns {boolean}
*/
startMutation() {
this.#mutations.clear();
@@ -512,7 +532,7 @@ export class SelectionController extends EventTarget {
/**
* Marks the end of a mutation.
*
* @returns
* @returns {CommandMutations}
*/
endMutation() {
return this.#mutations;
@@ -520,6 +540,8 @@ export class SelectionController extends EventTarget {
/**
* Selects all content.
*
* @returns {SelectionController}
*/
selectAll() {
if (this.#textEditor.isEmpty) {
@@ -558,23 +580,15 @@ export class SelectionController extends EventTarget {
this.#selection.removeAllRanges();
this.#selection.addRange(range);
// Ensure internal state is synchronized
this.#focusNode = this.#selection.focusNode;
this.#focusOffset = this.#selection.focusOffset;
this.#anchorNode = this.#selection.anchorNode;
this.#anchorOffset = this.#selection.anchorOffset;
this.#range = range;
this.#ranges.clear();
this.#ranges.add(range);
// Notify style changes
this.#notifyStyleChange();
this.#updateState();
return this;
}
/**
* Moves cursor to end.
*
* @returns {SelectionController}
*/
cursorToEnd() {
const range = document.createRange(); //Create a range (a range is a like the selection but invisible)
@@ -662,22 +676,6 @@ export class SelectionController extends EventTarget {
}
}
/**
* Disposes the current resources.
*/
dispose() {
document.removeEventListener("selectionchange", this.#onSelectionChange);
this.#textEditor = null;
this.#ranges.clear();
this.#ranges = null;
this.#range = null;
this.#selection = null;
this.#focusNode = null;
this.#anchorNode = null;
this.#mutations.dispose();
this.#mutations = null;
}
/**
* Returns the current selection.
*
@@ -1114,8 +1112,8 @@ export class SelectionController extends EventTarget {
return isParagraphEnd(this.focusNode, this.focusOffset);
}
#getFragmentInlineTextNode(fragment) {
if (isInline(fragment.firstElementChild.lastChild)) {
#getFragmentTextSpanTextNode(fragment) {
if (isTextSpan(fragment.firstElementChild.lastChild)) {
return fragment.firstElementChild.firstElementChild.lastChild;
}
return fragment.firstElementChild.lastChild;
@@ -1131,11 +1129,15 @@ export class SelectionController extends EventTarget {
* @param {DocumentFragment} fragment
*/
insertPaste(fragment) {
const hasOnlyOneParagraph = fragment.children.length === 1;
const forceTextSpan =
fragment.firstElementChild?.dataset?.textSpan === "force";
if (
fragment.children.length === 1 &&
fragment.firstElementChild?.dataset?.textSpan === "force"
hasOnlyOneParagraph &&
forceTextSpan
) {
const collapseNode = fragment.firstElementChild.firstChild;
// first text span
const collapseNode = fragment.firstElementChild.firstElementChild;
if (this.isTextSpanStart) {
this.focusTextSpan.before(...fragment.firstElementChild.children);
} else if (this.isTextSpanEnd) {
@@ -1147,7 +1149,9 @@ export class SelectionController extends EventTarget {
newTextSpan,
);
}
return this.collapse(collapseNode, collapseNode.nodeValue?.length || 0);
// collapseNode could be a <br>, that's why we need to
// make `nodeValue` as optional.
return this.collapse(collapseNode, collapseNode?.nodeValue?.length || 0);
}
const collapseNode = this.#getFragmentParagraphTextNode(fragment);
if (this.isParagraphStart) {
@@ -1393,9 +1397,16 @@ export class SelectionController extends EventTarget {
this.focusOffset,
newText,
);
this.collapse(this.focusNode, this.focusOffset + newText.length);
} else if (this.isLineBreakFocus) {
const textNode = new Text(newText);
this.focusNode.replaceWith(textNode);
// the focus node is a <span>.
if (isTextSpan(this.focusNode)) {
this.focusNode.firstElementChild.replaceWith(textNode);
// the focus node is a <br>.
} else {
this.focusNode.replaceWith(textNode);
}
this.collapse(textNode, newText.length);
} else {
throw new Error("Unknown node type");
@@ -1932,11 +1943,21 @@ export class SelectionController extends EventTarget {
const textSpan = this.startTextSpan;
const midText = startNode.splitText(startOffset);
const endText = midText.splitText(endOffset - startOffset);
const midTextSpan = createTextSpanFrom(textSpan, midText, newStyles);
textSpan.after(midTextSpan);
if (endText.length > 0) {
const endTextSpan = createTextSpan(endText, textSpan.style);
midTextSpan.after(endTextSpan);
// Only create text span if midText is not empty
if (midText.nodeValue && midText.nodeValue.length > 0) {
const midTextSpan = createTextSpanFrom(textSpan, midText, newStyles);
textSpan.after(midTextSpan);
if (endText.length > 0) {
const endTextSpan = createTextSpan(endText, textSpan.style);
midTextSpan.after(endTextSpan);
}
} else {
// If midText is empty, just create endTextSpan if needed
if (endText.length > 0) {
const endTextSpan = createTextSpan(endText, textSpan.style);
textSpan.after(endTextSpan);
}
}
// NOTE: This is necessary because sometimes
@@ -1953,16 +1974,21 @@ export class SelectionController extends EventTarget {
// the styles are applied to the current caret
else if (
this.startOffset === this.endOffset &&
this.endOffset === endNode.nodeValue.length
this.endOffset === endNode.nodeValue?.length
) {
const newTextSpan = createVoidTextSpan(newStyles);
this.endTextSpan.after(newTextSpan);
this.setSelection(newTextSpan.firstChild, 0, newTextSpan.firstChild, 0);
}
// The styles are applied to the paragraph
else {
else
{
const paragraph = this.startParagraph;
setParagraphStyles(paragraph, newStyles);
// Apply styles to child text spans.
for (const textSpan of paragraph.children) {
setTextSpanStyles(textSpan, newStyles);
}
}
return this.#notifyStyleChange();
@@ -1984,7 +2010,8 @@ export class SelectionController extends EventTarget {
// new text span.
if (
this.#textNodeIterator.currentNode === startNode &&
startOffset > 0
startOffset > 0 &&
startOffset < (startNode.nodeValue?.length || 0)
) {
const newTextSpan = splitTextSpan(textSpan, startOffset);
setTextSpanStyles(newTextSpan, newStyles);
@@ -1999,14 +2026,15 @@ export class SelectionController extends EventTarget {
(this.#textNodeIterator.currentNode !== startNode &&
this.#textNodeIterator.currentNode !== endNode) ||
(this.#textNodeIterator.currentNode === endNode &&
endOffset === endNode.nodeValue.length)
endOffset === endNode.nodeValue?.length)
) {
setTextSpanStyles(textSpan, newStyles);
// If we're at end node
} else if (
this.#textNodeIterator.currentNode === endNode &&
endOffset < endNode.nodeValue.length
endOffset < endNode.nodeValue?.length &&
endOffset > 0
) {
const newTextSpan = splitTextSpan(textSpan, endOffset);
setTextSpanStyles(textSpan, newStyles);

View File

@@ -0,0 +1,110 @@
export class StyleDeclaration {
static Property = class Property {
static NULL = '["~#\'",null]';
static Default = new Property("", "", "");
name;
value = "";
priority = "";
constructor(name, value = "", priority = "") {
this.name = name;
this.value = value ?? "";
this.priority = priority ?? "";
}
};
#items = new Map();
get cssFloat() {
throw new Error("Not implemented");
}
get cssText() {
throw new Error("Not implemented");
}
get parentRule() {
throw new Error("Not implemented");
}
get length() {
return this.#items.size;
}
#getProperty(name) {
return this.#items.get(name) ?? StyleDeclaration.Property.Default;
}
getPropertyPriority(name) {
const { priority } = this.#getProperty(name);
return priority ?? "";
}
getPropertyValue(name) {
const { value } = this.#getProperty(name);
return value ?? "";
}
item(index) {
return Array.from(this.#items).at(index).name;
}
removeProperty(name) {
const value = this.getPropertyValue(name);
this.#items.delete(name);
return value;
}
setProperty(name, value, priority) {
this.#items.set(name, new StyleDeclaration.Property(name, value, priority));
}
/** Non compatible methods */
#isQuotedValue(a, b) {
if (a.startsWith('"') && b.startsWith('"')) {
return a === b;
} else if (a.startsWith('"') && !b.startsWith('"')) {
return a.slice(1, -1) === b;
} else if (!a.startsWith('"') && b.startsWith('"')) {
return a === b.slice(1, -1);
}
return a === b;
}
mergeProperty(name, value) {
const currentValue = this.getPropertyValue(name);
if (this.#isQuotedValue(currentValue, value)) {
return this.setProperty(name, value);
} else if (currentValue === "" && value === StyleDeclaration.Property.NULL) {
return this.setProperty(name, value);
} else if (currentValue === "" && ["initial", "none"].includes(value)) {
return this.setProperty(name, value);
} else if (currentValue !== value && name === "--fills") {
return this.setProperty(name, value);
} else if (currentValue !== value) {
return this.setProperty(name, "mixed");
}
}
fromCSSStyleDeclaration(cssStyleDeclaration) {
for (let index = 0; index < cssStyleDeclaration.length; index++) {
const name = cssStyleDeclaration.item(index);
const value = cssStyleDeclaration.getPropertyValue(name);
const priority = cssStyleDeclaration.getPropertyPriority(name);
this.setProperty(name, value, priority);
}
}
toObject() {
return Object.fromEntries(
Array.from(this.#items.entries(), ([name, property]) => [
name,
property.value,
]),
);
}
}
export default StyleDeclaration

View File

@@ -0,0 +1,32 @@
import { describe, test, expect } from "vitest";
import { StyleDeclaration } from "./StyleDeclaration.js";
describe("StyleDeclaration", () => {
test("Create a new StyleDeclaration", () => {
const styleDeclaration = new StyleDeclaration();
expect(styleDeclaration).toBeInstanceOf(StyleDeclaration);
});
test("Uninmplemented getters should throw", () => {
expect(() => styleDeclaration.cssFloat).toThrow();
expect(() => styleDeclaration.cssText).toThrow();
expect(() => styleDeclaration.parentRule).toThrow();
});
test("Set property", () => {
const styleDeclaration = new StyleDeclaration();
styleDeclaration.setProperty("line-height", "1.2");
expect(styleDeclaration.getPropertyValue("line-height")).toBe("1.2");
expect(styleDeclaration.getPropertyPriority("line-height")).toBe("");
});
test("Remove property", () => {
const styleDeclaration = new StyleDeclaration();
styleDeclaration.setProperty("line-height", "1.2");
expect(styleDeclaration.getPropertyValue("line-height")).toBe("1.2");
expect(styleDeclaration.getPropertyPriority("line-height")).toBe("");
styleDeclaration.removeProperty("line-height");
expect(styleDeclaration.getPropertyValue("line-height")).toBe("");
expect(styleDeclaration.getPropertyPriority("line-height")).toBe("");
});
});

View File

@@ -392,7 +392,7 @@ msgstr ""
#: src/app/main/ui/exports/files.cljs:164
msgid "dashboard.export.options.all.message"
msgstr ""
"els fitxers amb biblioteques compartides sinclouran a lexportació, "
"Els fitxers amb biblioteques compartides sinclouran a lexportació, "
"mantenint la vinculació."
#: src/app/main/ui/exports/files.cljs:165

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