Compare commits

...

33 Commits

Author SHA1 Message Date
Aitor Moreno
b2e689a645 ♻️ Refactor TextCursor and TextPositionWithAffinity 2026-02-24 15:56:54 +01:00
Elena Torró
32d4026641 Merge pull request #8338 from penpot/azazeln28-11826-compute-selection-rects-from-pointer-events
🎉 Add compute selection rects from pointer events
2026-02-24 12:53:08 +01:00
Aitor Moreno
4477b2b4a0 🎉 Compute selection rects from pointer events 2026-02-24 11:09:45 +01:00
Andrey Antukh
9e51fa198a Merge remote-tracking branch 'origin/staging' into staging-render 2026-02-24 00:09:41 +01:00
Andrey Antukh
d176da8012 Add jfr and jcmd to the backend docker image (#8446) 2026-02-24 00:08:14 +01:00
Andrey Antukh
20862c2da3 🐛 Fix incorrect plugin icon resolution 2026-02-24 00:07:30 +01:00
Andrey Antukh
1b8afccba2 Remove usage of multipart body size config on backend 2026-02-23 14:44:44 +01:00
Yamila Moreno
dd856ecf50 ♻️ Deprecate PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE envvar 2026-02-23 13:48:01 +01:00
Elena Torró
f4e79af3cd Merge pull request #8438 from penpot/alotor-fix-flex-layout-issue
🐛 Fix problem with flex layout propagation
2026-02-23 13:09:04 +01:00
alonso.torres
3e758826fe 🐛 Fix problem with flex layout propagation 2026-02-23 12:49:27 +01:00
Aitor Moreno
2cf66c948d Merge pull request #8427 from penpot/superalex-fix-blur-0-artifacts-2
🐛 Fix blur 0 artifacts
2026-02-23 12:25:54 +01:00
Andrey Antukh
145198c148 📎 Use proper version tag on frontend index template 2026-02-23 12:17:58 +01:00
alonso.torres
eddfc4c4b2 🐛 Fix problem with createText in plugins 2026-02-23 09:35:30 +01:00
alonso.torres
e6e34af391 🐛 Show outline on hidden paths 2026-02-23 09:34:50 +01:00
Alejandro Alonso
4ee908fc89 Revert "🐛 Fix stroke artifacts"
This reverts commit bdcf448f3f.
2026-02-23 07:23:41 +01:00
Alejandro Alonso
bdcf448f3f 🐛 Fix stroke artifacts 2026-02-23 07:23:12 +01:00
Aitor Moreno
c58054d19c Merge pull request #8408 from penpot/ladybenko-always-mock-config-playwright
🔧 Always mock config.js in Playwright
2026-02-20 14:26:45 +01:00
Alejandro Alonso
a7ab506c5c 🐛 Fix blur 0 artifacts 2026-02-20 13:37:27 +01:00
Alejandro Alonso
c7f644ab2a Merge pull request #8420 from penpot/elenatorro-13426-improve-pan-and-zoom-for-blur
🔧 Improve performance on shapes with blur
2026-02-20 12:49:24 +01:00
Andrés Moya
3d41dc276e 🐛 Fix resolve tokens with tokenscript when type is font family 2026-02-20 12:41:17 +01:00
Elena Torró
cb5cacbcee Merge pull request #8413 from penpot/alotor-fix-error-emtpy-text
🐛 Fix problem with empty text
2026-02-19 17:09:03 +01:00
Elena Torro
337cfc2d3e 🔧 Improve performance on shapes with blur 2026-02-19 16:50:42 +01:00
Belén Albeza
c2ee31e791 Fix some flaky text editor v2 tests 2026-02-19 15:46:16 +01:00
alonso.torres
360937f613 🐛 Fix problem with empty text 2026-02-19 12:00:05 +01:00
Belén Albeza
f6d0414449 🔧 Use config flag for variants tests 2026-02-19 09:56:28 +01:00
Belén Albeza
4d05827fa9 🔧 Use disable-onboarding config flag instead of mocking get-profile in playwright 2026-02-19 09:56:27 +01:00
Belén Albeza
48fb9fa6ea Fix broken playwright tests 2026-02-19 09:56:27 +01:00
alonso.torres
cee974a906 🐛 Fix problem with tokens in plugins 2026-02-18 17:20:46 +01:00
Belén Albeza
c74cf3fa37 🔧 Make config.js automatically mocked in playwright tests 2026-02-18 16:54:03 +01:00
Aitor Moreno
7c1ddd3d7d Merge pull request #8382 from penpot/alotor-fix-components-propagation
🐛 Fix problem with text change component propagation and undo
2026-02-18 10:37:06 +01:00
Alejandro Alonso
4965f6d859 Merge pull request #8394 from penpot/elenatorro-fix-watch
🔧 Fix watch script
2026-02-18 10:11:44 +01:00
Elena Torro
a3cd90da7f 🔧 Fix watch script 2026-02-18 09:57:25 +01:00
alonso.torres
30106f8524 🐛 Fix problem with text change component propagation and undo 2026-02-17 11:54:33 +01:00
81 changed files with 5246 additions and 765 deletions

View File

@@ -2,6 +2,9 @@
## 2.14.0 (Unreleased)
### :boom: Breaking changes & Deprecations
- Deprecate `PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE` in favour of `PENPOT_HTTP_SERVER_MAX_BODY_SIZE`.
### :sparkles: New features & Enhancements
- Access to design tokens in Penpot Plugins [Taiga #8990](https://tree.taiga.io/project/penpot/us/8990)

View File

@@ -28,8 +28,8 @@
com.google.guava/guava {:mvn/version "33.4.8-jre"}
funcool/yetti
{:git/tag "v11.8"
:git/sha "1d1b33f"
{:git/tag "v11.9"
:git/sha "5fad7a9"
:git/url "https://github.com/funcool/yetti.git"
:exclusions [org.slf4j/slf4j-api]}

View File

@@ -98,7 +98,6 @@
[:http-server-port {:optional true} ::sm/int]
[:http-server-host {:optional true} :string]
[:http-server-max-body-size {:optional true} ::sm/int]
[:http-server-max-multipart-body-size {:optional true} ::sm/int]
[:http-server-io-threads {:optional true} ::sm/int]
[:http-server-max-worker-threads {:optional true} ::sm/int]

View File

@@ -42,8 +42,8 @@
(def default-params
{::port 6060
::host "0.0.0.0"
::max-body-size 31457280 ; default 30 MiB
::max-multipart-body-size 367001600}) ; default 350 MiB
::max-body-size 367001600 ; default 350 MiB
})
(defmethod ig/expand-key ::server
[k v]
@@ -56,7 +56,6 @@
[::io-threads {:optional true} ::sm/int]
[::max-worker-threads {:optional true} ::sm/int]
[::max-body-size {:optional true} ::sm/int]
[::max-multipart-body-size {:optional true} ::sm/int]
[::router {:optional true} [:fn r/router?]]
[::handler {:optional true} ::sm/fn]])
@@ -79,7 +78,7 @@
{:http/port port
:http/host host
:http/max-body-size (::max-body-size cfg)
:http/max-multipart-body-size (::max-multipart-body-size cfg)
:http/max-multipart-body-size (::max-body-size cfg)
:xnio/direct-buffers false
:xnio/io-threads (::io-threads cfg)
:xnio/max-worker-threads (::max-worker-threads cfg)

View File

@@ -226,11 +226,10 @@
::http/server
{::http/port (cf/get :http-server-port)
::http/host (cf/get :http-server-host)
::http/router (ig/ref ::http/router)
::http/io-threads (cf/get :http-server-io-threads)
::http/max-worker-threads (cf/get :http-server-max-worker-threads)
::http/max-body-size (cf/get :http-server-max-body-size)
::http/max-multipart-body-size (cf/get :http-server-max-multipart-body-size)
::http/router (ig/ref ::http/router)
::mtx/metrics (ig/ref ::mtx/metrics)}
::ldap/provider

View File

@@ -605,31 +605,31 @@
add-undo-change-shape
(fn [change-set id]
(let [shape (get objects id)]
(conj
change-set
{:type :add-obj
:id id
:page-id page-id
:parent-id (:parent-id shape)
:frame-id (:frame-id shape)
:index (cfh/get-position-on-parent objects id)
:obj (cond-> shape
(contains? shape :shapes)
(assoc :shapes []))})))
(cond-> change-set
(some? shape)
(conj {:type :add-obj
:id id
:page-id page-id
:parent-id (:parent-id shape)
:frame-id (:frame-id shape)
:index (cfh/get-position-on-parent objects id)
:obj (cond-> shape
(contains? shape :shapes)
(assoc :shapes []))}))))
add-undo-change-parent
(fn [change-set id]
(let [shape (get objects id)
prev-sibling (cfh/get-prev-sibling objects (:id shape))]
(conj
change-set
{:type :mov-objects
:page-id page-id
:parent-id (:parent-id shape)
:shapes [id]
:after-shape prev-sibling
:index 0
:ignore-touched true})))]
(cond-> change-set
(some? shape)
(conj {:type :mov-objects
:page-id page-id
:parent-id (:parent-id shape)
:shapes [id]
:after-shape prev-sibling
:index 0
:ignore-touched true}))))]
(-> changes
(update :redo-changes #(reduce add-redo-change % ids))
@@ -1150,3 +1150,24 @@
[changes]
(::page-id (meta changes)))
(defn set-text-content
[changes id content prev-content]
(assert-page-id! changes)
(let [page-id (::page-id (meta changes))
redo-change
{:type :mod-obj
:page-id page-id
:id id
:operations [{:type :set :attr :content :val content}]}
undo-change
{:type :mod-obj
:page-id page-id
:id id
:operations [{:type :set :attr :content :val prev-content}]}]
(-> changes
(update :redo-changes conj redo-change)
(update :undo-changes conj undo-change))))

View File

@@ -50,6 +50,7 @@ services:
- 4400:4400
- 4401:4401
- 4402:4402
- 4403:4403
# Plugins
- 4200:4200

View File

@@ -68,7 +68,7 @@ RUN set -eux; \
--no-header-files \
--no-man-pages \
--strip-debug \
--add-modules java.base,jdk.management.agent,java.se,jdk.compiler,jdk.javadoc,jdk.attach,jdk.unsupported \
--add-modules java.base,jdk.management.agent,java.se,jdk.compiler,jdk.javadoc,jdk.attach,jdk.unsupported,jdk.jfr,jdk.jcmd \
--output /opt/jre;
FROM ubuntu:24.04 AS image

View File

@@ -30,11 +30,9 @@ x-uri: &penpot-public-uri
PENPOT_PUBLIC_URI: http://localhost:9001
x-body-size: &penpot-http-body-size
# Max body size (30MiB); Used for plain requests, should never be
# greater than multi-part size
PENPOT_HTTP_SERVER_MAX_BODY_SIZE: 31457280
# Max multipart body size (350MiB)
# Max body size
PENPOT_HTTP_SERVER_MAX_BODY_SIZE: 367001600
# Deprecation warning: this variable is deprecated. Use PENPOT_HTTP_SERVER_MAX_BODY (defaults to 367001600)
PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE: 367001600
## Penpot SECRET KEY. It serves as a master key from which other keys for subsystems

View File

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

View File

@@ -76,7 +76,7 @@ http {
listen [::]:8080 default_server;
server_name _;
client_max_body_size $PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE;
client_max_body_size $PENPOT_HTTP_SERVER_MAX_BODY_SIZE;
charset utf-8;
etag off;

View File

@@ -188,8 +188,8 @@ server {
server_name penpot.mycompany.com;
# This value should be in sync with the corresponding in the docker-compose.yml
# PENPOT_HTTP_SERVER_MAX_BODY_SIZE: 31457280
client_max_body_size 31457280;
# PENPOT_HTTP_SERVER_MAX_BODY_SIZE: 367001600
client_max_body_size 367001600;
# Logs: Configure your logs following the best practices inside your company
access_log /path/to/penpot.access.log;

View File

@@ -43,12 +43,13 @@
"clear:wasm": "cargo clean --manifest-path ../render-wasm/Cargo.toml",
"watch": "exit 0",
"watch:app": "pnpm run clear:shadow-cache && pnpm run clear:wasm && pnpm run build:wasm && concurrently --kill-others-on-fail \"pnpm run watch:app:assets\" \"pnpm run watch:app:main\" \"pnpm run watch:app:libs\"",
"watch:storybook": "pnpm run build:storybook:assets && concurrently --kill-others-on-fail \"storybook dev -p 6006 --no-open\" \"node ./scripts/watch-storybook.js\""
"watch:storybook": "pnpm run build:storybook:assets && concurrently --kill-others-on-fail \"storybook dev -p 6006 --no-open\" \"node ./scripts/watch-storybook.js\"",
"postinstall": "(cd ../plugins/libs/plugins-runtime; pnpm install; pnpm run build)"
},
"devDependencies": {
"@penpot/draft-js": "workspace:./packages/draft-js",
"@penpot/mousetrap": "workspace:./packages/mousetrap",
"@penpot/plugins-runtime": "1.4.2",
"@penpot/plugins-runtime": "link:../plugins/dist/plugins-runtime",
"@penpot/svgo": "penpot/svgo#v3.2",
"@penpot/text-editor": "workspace:./text-editor",
"@penpot/tokenscript": "workspace:./packages/tokenscript",

View File

@@ -1,26 +0,0 @@
[
{
"~:features": {
"~#set": [
"variants/v1",
"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
},
"~:name": "Default",
"~:modified-at": "~m1713533116375",
"~:id": "~uc7ce0794-0992-8105-8004-38e630f7920a",
"~:created-at": "~m1713533116375",
"~:is-default": true
}
]

View File

@@ -1,26 +0,0 @@
{
"~:email": "foo@example.com",
"~:is-demo": false,
"~:auth-backend": "penpot",
"~:fullname": "Princesa Leia",
"~:modified-at": "~m1713533116365",
"~:is-active": true,
"~:default-project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b",
"~:id": "~uc7ce0794-0992-8105-8004-38e630f29a9b",
"~:is-muted": false,
"~:default-team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d",
"~:created-at": "~m1713533116365",
"~:is-blocked": false,
"~:props": {
"~:nudge": {
"~:big": 10,
"~:small": 1
},
"~:v2-info-shown": true,
"~:viewed-tutorial?": false,
"~:viewed-walkthrough?": false,
"~:onboarding-viewed": true,
"~:builtin-templates-collapsed-status":
true
}
}

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
{
"~:file-id": "~u3a4d7ec7-c391-8146-8007-9a05c41da6b9",
"~:id": "~u3a4d7ec7-c391-8146-8007-9dd6c998fbc4",
"~:created-at": "~m1771846681191",
"~:modified-at": "~m1771846681191",
"~:type": "fragment",
"~:backend": "db",
"~:data": {
"~:id": "~u95b23c15-79f9-81ba-8007-99d81b5290dd",
"~:name": "Page 1",
"~:objects": {
"~#penpot/objects-map/v2": {
"~u00000000-0000-0000-0000-000000000000": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"Root Frame\",\"~:width\",0.01,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0.0,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.01]],[\"^:\",[\"^ \",\"~:x\",0.0,\"~:y\",0.01]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u95b23c15-79f9-81ba-8007-99d81b5290dd\",\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1.0,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",0.01,\"~:height\",0.01,\"~:x1\",0,\"~:y1\",0,\"~:x2\",0.01,\"~:y2\",0.01]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#FFFFFF\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^I\",0.01,\"~:flip-y\",null,\"~:shapes\",[\"~ucfb31a9c-83c2-806f-8007-9dbf43043bdf\"]]]",
"~ucfb31a9c-83c2-806f-8007-9dbf43043be0": "[\"~#shape\",[\"^ \",\"~:y\",-218.99999605032087,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:layout-padding\",[\"^ \",\"~:p1\",5,\"~:p2\",5,\"~:p3\",5,\"~:p4\",5],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:nowrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"Container\",\"~:layout-align-items\",\"~:start\",\"~:width\",431.99994866329087,\"~:layout-padding-type\",\"~:simple\",\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",608.9999813066789,\"~:y\",-218.99999605032087]],[\"^J\",[\"^ \",\"~:x\",1040.9999299699698,\"~:y\",-218.99999605032087]],[\"^J\",[\"^ \",\"~:x\",1040.9999299699698,\"~:y\",-177.00001533586985]],[\"^J\",[\"^ \",\"~:x\",608.9999813066789,\"~:y\",-177.00001533586985]]],\"~:show-content\",true,\"~:layout-item-h-sizing\",\"~:fill\",\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",4,\"~:column-gap\",4],\"~:transform-inverse\",[\"^:\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u95b23c15-79f9-81ba-8007-99d81b5290dd\",\"~:layout-item-v-sizing\",\"~:auto\",\"~:layout-justify-content\",\"^C\",\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:id\",\"~ucfb31a9c-83c2-806f-8007-9dbf43043be0\",\"~:parent-id\",\"~ucfb31a9c-83c2-806f-8007-9dbf43043bdf\",\"~:layout-flex-dir\",\"~:column\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~ucfb31a9c-83c2-806f-8007-9dbf43043bdf\",\"~:strokes\",[],\"~:x\",608.9999813066788,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",608.9999813066788,\"~:y\",-218.99999605032087,\"^D\",431.99994866329087,\"~:height\",41.99998071445103,\"~:x1\",608.9999813066788,\"~:y1\",-218.99999605032087,\"~:x2\",1040.9999299699698,\"~:y2\",-177.00001533586985]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffc0cb\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^1:\",41.99998071445103,\"~:flip-y\",null,\"~:shapes\",[\"~ucfb31a9c-83c2-806f-8007-9dbf43043be2\",\"~ucfb31a9c-83c2-806f-8007-9dbf43043be3\"]]]",
"~ucfb31a9c-83c2-806f-8007-9dbf43043be2": "[\"~#shape\",[\"^ \",\"~:y\",-178.00000568505413,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:hide-in-viewer\",false,\"~:name\",\"show / hide me\",\"~:width\",99.98206911702209,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",614.0000002576337,\"~:y\",-178.00000568505413]],[\"^:\",[\"^ \",\"~:x\",713.9820693746558,\"~:y\",-178.00000568505413]],[\"^:\",[\"^ \",\"~:x\",713.9820693746558,\"~:y\",-148.0000135081636]],[\"^:\",[\"^ \",\"~:x\",614.0000002576337,\"~:y\",-148.0000135081636]]],\"~:r2\",0,\"~:layout-item-h-sizing\",\"~:fix\",\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:layout-item-v-sizing\",\"^=\",\"~:r3\",0,\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:r1\",0,\"~:hidden\",true,\"~:id\",\"~ucfb31a9c-83c2-806f-8007-9dbf43043be2\",\"~:parent-id\",\"~ucfb31a9c-83c2-806f-8007-9dbf43043be0\",\"~:frame-id\",\"~ucfb31a9c-83c2-806f-8007-9dbf43043be0\",\"~:strokes\",[],\"~:x\",614.0000002576337,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",614.0000002576337,\"~:y\",-178.00000568505413,\"^6\",99.98206911702209,\"~:height\",29.999992176890544,\"~:x1\",614.0000002576337,\"~:y1\",-178.00000568505413,\"~:x2\",713.9820693746558,\"~:y2\",-148.0000135081636]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^P\",29.999992176890544,\"~:flip-y\",null]]",
"~ucfb31a9c-83c2-806f-8007-9dbf43043be3": "[\"~#shape\",[\"^ \",\"~:y\",-213.99999587313152,\"~:hide-fill-on-export\",false,\"~:rx\",8,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:hide-in-viewer\",true,\"~:name\",\"Full width\",\"~:width\",422.00001200500014,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",613.9999939062393,\"~:y\",-213.99999587313152]],[\"^<\",[\"^ \",\"~:x\",1036.0000059112394,\"~:y\",-213.99999587313152]],[\"^<\",[\"^ \",\"~:x\",1036.0000059112394,\"~:y\",-182.00001303926604]],[\"^<\",[\"^ \",\"~:x\",613.9999939062393,\"~:y\",-182.00001303926604]]],\"~:r2\",8,\"~:show-content\",true,\"~:layout-item-h-sizing\",\"~:fix\",\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^4\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u95b23c15-79f9-81ba-8007-99d81b5290dd\",\"~:layout-item-v-sizing\",\"^@\",\"~:r3\",8,\"~:r1\",8,\"~:id\",\"~ucfb31a9c-83c2-806f-8007-9dbf43043be3\",\"~:parent-id\",\"~ucfb31a9c-83c2-806f-8007-9dbf43043be0\",\"~:frame-id\",\"~ucfb31a9c-83c2-806f-8007-9dbf43043be0\",\"~:strokes\",[],\"~:x\",613.9999939062393,\"~:proportion\",1,\"~:r4\",8,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",613.9999939062393,\"~:y\",-213.99999587313152,\"^8\",422.00001200500014,\"~:height\",31.999982833865488,\"~:x1\",613.9999939062393,\"~:y1\",-213.99999587313152,\"~:x2\",1036.0000059112394,\"~:y2\",-182.00001303926604]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#212426\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"~:ry\",8,\"^O\",31.999982833865488,\"~:flip-y\",null,\"~:shapes\",[]]]",
"~ucfb31a9c-83c2-806f-8007-9dbf43043bdf": "[\"~#shape\",[\"^ \",\"~:y\",-228.99999763039506,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:layout-padding\",[\"^ \",\"~:p1\",10,\"~:p2\",10,\"~:p3\",10,\"~:p4\",10],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:nowrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"Parent\",\"~:layout-align-items\",\"~:start\",\"~:width\",451.999905143128,\"~:layout-padding-type\",\"~:simple\",\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",599.0000149607649,\"~:y\",-228.99999763039506]],[\"^J\",[\"^ \",\"~:x\",1050.999920103893,\"~:y\",-228.99999763039506]],[\"^J\",[\"^ \",\"~:x\",1050.999920103893,\"~:y\",-167.0000160450801]],[\"^J\",[\"^ \",\"~:x\",599.0000149607649,\"~:y\",-167.0000160450801]]],\"~:r2\",0,\"~:show-content\",true,\"~:layout-item-h-sizing\",\"~:fix\",\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",10,\"~:column-gap\",8],\"~:transform-inverse\",[\"^:\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u95b23c15-79f9-81ba-8007-99d81b5290dd\",\"~:layout-item-v-sizing\",\"~:auto\",\"~:r3\",0,\"~:layout-justify-content\",\"^C\",\"~:r1\",0,\"~:id\",\"~ucfb31a9c-83c2-806f-8007-9dbf43043bdf\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:layout-flex-dir\",\"~:column\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",599.0000149607649,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",599.0000149607649,\"~:y\",-228.99999763039506,\"^D\",451.999905143128,\"~:height\",61.99998158531497,\"~:x1\",599.0000149607649,\"~:y1\",-228.99999763039506,\"~:x2\",1050.999920103893,\"~:y2\",-167.0000160450801]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#000000\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^1:\",61.99998158531497,\"~:flip-y\",null,\"~:shapes\",[\"~ucfb31a9c-83c2-806f-8007-9dbf43043be0\"]]]"
}
}
}
}

View File

@@ -0,0 +1,131 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"design-tokens/v1",
"variants/v1",
"layout/grid",
"fdata/pointer-map",
"fdata/objects-map",
"components/v2",
"fdata/shape-data-type"
]
},
"~:team-id": "~ud715d0a5-a44e-8056-8005-a79999e18b64",
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "test-bug-flex",
"~:revn": 114,
"~:modified-at": "~m1771846681183",
"~:vern": 0,
"~:id": "~u3a4d7ec7-c391-8146-8007-9a05c41da6b9",
"~:is-shared": false,
"~:migrations": {
"~#ordered-set": [
"legacy-2",
"legacy-3",
"legacy-5",
"legacy-6",
"legacy-7",
"legacy-8",
"legacy-9",
"legacy-10",
"legacy-11",
"legacy-12",
"legacy-13",
"legacy-14",
"legacy-16",
"legacy-17",
"legacy-18",
"legacy-19",
"legacy-25",
"legacy-26",
"legacy-27",
"legacy-28",
"legacy-29",
"legacy-31",
"legacy-32",
"legacy-33",
"legacy-34",
"legacy-36",
"legacy-37",
"legacy-38",
"legacy-39",
"legacy-40",
"legacy-41",
"legacy-42",
"legacy-43",
"legacy-44",
"legacy-45",
"legacy-46",
"legacy-47",
"legacy-48",
"legacy-49",
"legacy-50",
"legacy-51",
"legacy-52",
"legacy-53",
"legacy-54",
"legacy-55",
"legacy-56",
"legacy-57",
"legacy-59",
"legacy-62",
"legacy-65",
"legacy-66",
"legacy-67",
"0001-remove-tokens-from-groups",
"0002-normalize-bool-content-v2",
"0002-clean-shape-interactions",
"0003-fix-root-shape",
"0003-convert-path-content-v2",
"0005-deprecate-image-type",
"0006-fix-old-texts-fills",
"0008-fix-library-colors-v4",
"0009-clean-library-colors",
"0009-add-partial-text-touched-flags",
"0010-fix-swap-slots-pointing-non-existent-shapes",
"0011-fix-invalid-text-touched-flags",
"0012-fix-position-data",
"0013-fix-component-path",
"0013-clear-invalid-strokes-and-fills",
"0014-fix-tokens-lib-duplicate-ids",
"0014-clear-components-nil-objects",
"0015-fix-text-attrs-blank-strings",
"0015-clean-shadow-color",
"0016-copy-fills-from-position-data-to-text-node"
]
},
"~:version": 67,
"~:project-id": "~u76eab896-accf-81a5-8007-2b264ebe7817",
"~:created-at": "~m1771590560885",
"~:backend": "legacy-db",
"~:data": {
"~:pages": [
"~u95b23c15-79f9-81ba-8007-99d81b5290dd"
],
"~:pages-index": {
"~u95b23c15-79f9-81ba-8007-99d81b5290dd": {
"~#penpot/pointer": [
"~u3a4d7ec7-c391-8146-8007-9dd6c998fbc4",
{
"~:created-at": "~m1771846681187"
}
]
}
},
"~:id": "~u3a4d7ec7-c391-8146-8007-9a05c41da6b9",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
}
}
}

View File

@@ -1,4 +1,8 @@
export class BasePage {
static async init(page) {
await BasePage.mockConfigFlags(page, []);
}
/**
* Mocks multiple RPC calls in a single call.
*

View File

@@ -2,13 +2,8 @@ import { MockWebSocketHelper } from "../../helpers/MockWebSocketHelper";
import BasePage from "./BasePage";
export class BaseWebSocketPage extends BasePage {
/**
* This should be called on `test.beforeEach`.
*
* @param {Page} page
* @returns
*/
static async initWebSockets(page) {
static async init(page) {
await super.init(page);
await MockWebSocketHelper.init(page);
}

View File

@@ -3,54 +3,62 @@ import { BaseWebSocketPage } from "./BaseWebSocketPage";
export class DashboardPage extends BaseWebSocketPage {
static async init(page) {
await BaseWebSocketPage.initWebSockets(page);
await super.init(page);
await BaseWebSocketPage.mockRPC(
await super.mockConfigFlags(page, ["disable-onboarding"]);
await super.mockRPC(
page,
"get-teams",
"logged-in-user/get-teams-default.json",
);
await BaseWebSocketPage.mockRPC(
await super.mockRPC(
page,
"get-font-variants?team-id=*",
"workspace/get-font-variants-empty.json",
);
await BaseWebSocketPage.mockRPC(
await super.mockRPC(
page,
"get-projects?team-id=*",
"logged-in-user/get-projects-default.json",
);
await BaseWebSocketPage.mockRPC(
await super.mockRPC(
page,
"get-team-members?team-id=*",
"logged-in-user/get-team-members-your-penpot.json",
);
await BaseWebSocketPage.mockRPC(
await super.mockRPC(
page,
"get-team-users?team-id=*",
"logged-in-user/get-team-users-single-user.json",
);
await BaseWebSocketPage.mockRPC(
await super.mockRPC(
page,
"get-unread-comment-threads?team-id=*",
"logged-in-user/get-team-users-single-user.json",
);
await BaseWebSocketPage.mockRPC(
await super.mockRPC(
page,
"get-team-recent-files?team-id=*",
"logged-in-user/get-team-recent-files-empty.json",
);
await BaseWebSocketPage.mockRPC(
await super.mockRPC(
page,
"get-profiles-for-file-comments",
"workspace/get-profile-for-file-comments.json",
);
await BaseWebSocketPage.mockRPC(
await super.mockRPC(
page,
"get-builtin-templates",
"logged-in-user/get-built-in-templates-empty.json",
);
await super.mockRPC(
page,
"get-profile",
"logged-in-user/get-profile-logged-in.json",
);
}
static anyTeamId = "c7ce0794-0992-8105-8004-38e630f40f6d";

View File

@@ -1,6 +1,10 @@
import { BasePage } from "./BasePage";
export class LoginPage extends BasePage {
static async init(page) {
await super.init(page);
}
constructor(page) {
super(page);
this.loginButton = page.getByRole("button", { name: "Continue" });

View File

@@ -29,8 +29,13 @@ export class RegisterPage extends BasePage {
);
}
static async init(page) {
await BasePage.init(page);
}
static async initWithLoggedOutUser(page) {
await this.mockRPC(page, "get-profile", "get-profile-anonymous.json");
await BasePage.init(page);
await BasePage.mockRPC(page, "get-profile", "get-profile-anonymous.json");
}
}

View File

@@ -3,9 +3,9 @@ import { DashboardPage } from "./DashboardPage";
export class SubscriptionProfilePage extends DashboardPage {
static async init(page) {
await DashboardPage.initWebSockets(page);
await super.init(page);
await DashboardPage.mockRPC(
await super.mockRPC(
page,
"get-subscription-usage",
"subscription/get-subscription-usage.json",

View File

@@ -4,16 +4,6 @@ export class ViewerPage extends BaseWebSocketPage {
static anyFileId = "c7ce0794-0992-8105-8004-38f280443849";
static anyPageId = "c7ce0794-0992-8105-8004-38f28044384a";
/**
* This should be called on `test.beforeEach`.
*
* @param {Page} page
* @returns
*/
static async init(page) {
await BaseWebSocketPage.initWebSockets(page);
}
async setupLoggedInUser() {
await this.mockRPC(
"get-profile",

View File

@@ -45,24 +45,27 @@ export class WorkspacePage extends BaseWebSocketPage {
return this.waitForEditor();
}
stopEditing() {
return this.page.keyboard.press("Escape");
async stopEditing() {
await this.page.keyboard.press("Escape");
}
async moveToLeft(amount = 0) {
for (let i = 0; i < amount; i++) {
await this.page.keyboard.press("ArrowLeft");
}
await this.waitForIdle();
}
async moveToRight(amount = 0) {
for (let i = 0; i < amount; i++) {
await this.page.keyboard.press("ArrowRight");
}
await this.waitForIdle();
}
async moveFromStart(offset = 0) {
await this.page.keyboard.press("Home");
await this.waitForIdle();
await this.moveToRight(offset);
}
@@ -103,6 +106,10 @@ export class WorkspacePage extends BaseWebSocketPage {
changeLetterSpacing(newValue) {
return this.changeNumericInput(this.letterSpacing, newValue);
}
async waitForIdle() {
await this.page.evaluate(() => new Promise((resolve) => globalThis.requestIdleCallback(resolve)));
}
};
/**
@@ -112,9 +119,9 @@ export class WorkspacePage extends BaseWebSocketPage {
* @returns
*/
static async init(page) {
await BaseWebSocketPage.initWebSockets(page);
await super.init(page);
await BaseWebSocketPage.mockRPCs(page, {
await super.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",

View File

@@ -243,6 +243,46 @@ test("Renders a file with a closed path shape with multiple segments using strok
await expect(workspace.canvas).toHaveScreenshot();
});
test("Renders solid shadows after select all and zoom to selected", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("render-wasm/get-solid-shadows.json");
await workspace.goToWorkspace({
id: "93113137-fe66-80fb-8007-99ca9fd96841",
pageId: "93113137-fe66-80fb-8007-99ca9fd96842",
});
await workspace.waitForFirstRender();
await workspace.viewport.click();
await page.keyboard.press("ControlOrMeta+A");
const previousRenderCount = await workspace.getRenderCount();
await page.keyboard.press("f");
await workspace.waitForNextRender(previousRenderCount);
await workspace.hideUI();
await expect(workspace.canvas).toHaveScreenshot();
});
test("Renders strokes with solid shadows", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("render-wasm/get-solid-strokes-shadows.json");
await workspace.goToWorkspace({
id: "93113137-fe66-80fb-8007-99cfd5cbf361",
pageId: "93113137-fe66-80fb-8007-99cfd5cbf362",
});
await workspace.waitForFirstRender();
await workspace.hideUI();
await expect(workspace.canvas).toHaveScreenshot();
});
test("Renders a file with paths and svg attrs", async ({ page }) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 140 KiB

View File

@@ -3,11 +3,6 @@ import DashboardPage from "../pages/DashboardPage";
test.beforeEach(async ({ page }) => {
await DashboardPage.init(page);
await DashboardPage.mockRPC(
page,
"get-profile",
"logged-in-user/get-profile-logged-in-no-onboarding.json",
);
});
test.describe("Dashboard Deleted Page", () => {

View File

@@ -3,11 +3,6 @@ import DashboardPage from "../pages/DashboardPage";
test.beforeEach(async ({ page }) => {
await DashboardPage.init(page);
await DashboardPage.mockRPC(
page,
"get-profile",
"logged-in-user/get-profile-logged-in-no-onboarding.json",
);
});
test("BUG 10421 - Fix libraries context menu", async ({ page }) => {

View File

@@ -3,11 +3,6 @@ import DashboardPage from "../pages/DashboardPage";
test.beforeEach(async ({ page }) => {
await DashboardPage.init(page);
await DashboardPage.mockRPC(
page,
"get-profile",
"logged-in-user/get-profile-logged-in-no-onboarding.json",
);
});
test("BUG 12359 - Selected invitations count is not pluralized", async ({

View File

@@ -3,11 +3,7 @@ import DashboardPage from "../pages/DashboardPage";
test.beforeEach(async ({ page }) => {
await DashboardPage.init(page);
await DashboardPage.mockRPC(
page,
"get-profile",
"logged-in-user/get-profile-logged-in-no-onboarding.json",
);
await DashboardPage.mockRPC(
page,
"get-teams",

View File

@@ -3,11 +3,6 @@ import DashboardPage from "../pages/DashboardPage";
test.beforeEach(async ({ page }) => {
await DashboardPage.init(page);
await DashboardPage.mockRPC(
page,
"get-profile",
"logged-in-user/get-profile-logged-in-no-onboarding.json",
);
});
test("Dashboad page has title ", async ({ page }) => {

View File

@@ -2,6 +2,8 @@ import { test, expect } from "@playwright/test";
import { LoginPage } from "../pages/LoginPage";
test.beforeEach(async ({ page }) => {
await LoginPage.init(page);
const login = new LoginPage(page);
await login.initWithLoggedOutUser();

View File

@@ -4,6 +4,7 @@ import OnboardingPage from "../pages/OnboardingPage";
test.beforeEach(async ({ page }) => {
await DashboardPage.init(page);
await DashboardPage.mockConfigFlags(page, ["enable-onboarding"]);
await DashboardPage.mockRPC(
page,
"get-profile",

View File

@@ -3,11 +3,6 @@ import DashboardPage from "../pages/DashboardPage";
test.beforeEach(async ({ page }) => {
await DashboardPage.init(page);
await DashboardPage.mockRPC(
page,
"get-profile",
"logged-in-user/get-profile-logged-in-no-onboarding.json",
);
});
test("Navigate to penpot changelog from profile menu", async ({ page }) => {

View File

@@ -2,8 +2,6 @@ import { test, expect } from "@playwright/test";
import { Clipboard } from "../../helpers/Clipboard";
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
const timeToWait = 100;
test.beforeEach(async ({ page, context }) => {
await Clipboard.enable(context, Clipboard.Permission.ALL);
@@ -37,11 +35,13 @@ test("Create a new text shape from pasting text", async ({ page, context }) => {
await workspace.setupEmptyFile();
await workspace.mockRPC("update-file?id=*", "text-editor/update-file.json");
await workspace.goToWorkspace();
await workspace.moveButton.click();
await Clipboard.writeText(page, textToPaste);
await workspace.clickAt(190, 150);
await workspace.paste("keyboard");
await workspace.textEditor.stopEditing();
await expect(workspace.layers.getByText(textToPaste)).toBeVisible();
@@ -57,6 +57,7 @@ test("Create a new text shape from pasting text using context menu", async ({
});
await workspace.setupEmptyFile();
await workspace.goToWorkspace();
await workspace.moveButton.click();
await Clipboard.writeText(page, textToPaste);
@@ -138,7 +139,7 @@ test("Update a new text shape appending text by pasting text", async ({
await workspace.paste("keyboard");
await workspace.textEditor.stopEditing();
await workspace.waitForSelectedShapeName("Lorem ipsum dolor sit amet");
});
});
test.skip("Update a new text shape prepending text by pasting text", async ({
page,

View File

@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test";
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
import { BaseWebSocketPage } from "../pages/BaseWebSocketPage";
import { Clipboard } from "../../helpers/Clipboard";
@@ -7,7 +7,7 @@ test.beforeEach(async ({ page, context }) => {
await Clipboard.enable(context, Clipboard.Permission.ALL);
await WasmWorkspacePage.init(page);
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-variants.json");
await WasmWorkspacePage.mockConfigFlags(page, ["enable-feature-variants"]);
});
test.afterEach(async ({ context }) => {

View File

@@ -1,6 +1,5 @@
import { test, expect } from "@playwright/test";
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
import { presenceFixture } from "../../data/workspace/ws-notifications";
test.beforeEach(async ({ page }) => {
await WasmWorkspacePage.init(page);
@@ -106,7 +105,7 @@ test("BUG 11006 - Fix history panel shortcut", async ({ page }) => {
await workspacePage.goToWorkspace();
await page.keyboard.press("Control+Alt+h");
await page.keyboard.press("ControlOrMeta+Alt+h");
await expect(
workspacePage.rightSidebar.getByText("There are no versions yet"),

View File

@@ -55,3 +55,31 @@ test("BUG 13382 - Fix problem with flex layout", async ({ page }) => {
await expect(workspacePage.rightSidebar.getByTitle("Height").getByRole("textbox")).toHaveValue("340");
});
test("BUG 13468 - Fix problem with flex propagation", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.mockGetFile("workspace/get-file-13468.json");
await workspacePage.mockRPC(
"get-file-fragment?file-id=*&fragment-id=*",
"workspace/get-file-13468-fragment.json",
);
await workspacePage.mockRPC("update-file?id=*", "workspace/update-file-empty.json");
await workspacePage.goToWorkspace({
fileId: "3a4d7ec7-c391-8146-8007-9a05c41da6b9",
pageId: "95b23c15-79f9-81ba-8007-99d81b5290dd",
});
0
await workspacePage.clickToggableLayer("Parent");
await workspacePage.clickToggableLayer("Container");
await workspacePage.sidebar.getByRole('button', { name: 'Show' }).click();
await workspacePage.clickLeafLayer("Container");
await expect(workspacePage.rightSidebar.getByTitle("Height").getByRole("textbox")).toHaveValue("76");
});

View File

@@ -3,11 +3,6 @@ import DashboardPage from "../pages/DashboardPage";
test.beforeEach(async ({ page }) => {
await DashboardPage.init(page);
await DashboardPage.mockRPC(
page,
"get-profile",
"logged-in-user/get-profile-logged-in-no-onboarding.json",
);
});
test("User goes to an empty dashboard", async ({ page }) => {

View File

@@ -2,6 +2,8 @@ import { test, expect } from "@playwright/test";
import { LoginPage } from "../pages/LoginPage";
test.beforeEach(async ({ page }) => {
await LoginPage.init(page);
const login = new LoginPage(page);
await login.initWithLoggedOutUser();
await login.page.goto("/#/auth/login");

View File

@@ -20,8 +20,8 @@ importers:
specifier: workspace:./packages/mousetrap
version: link:packages/mousetrap
'@penpot/plugins-runtime':
specifier: 1.4.2
version: 1.4.2
specifier: link:../plugins/dist/plugins-runtime
version: link:../plugins/dist/plugins-runtime
'@penpot/svgo':
specifier: penpot/svgo#v3.2
version: svgo@https://codeload.github.com/penpot/svgo/tar.gz/8c9b0e32e9cb5f106085260bd9375f3c91a5010b
@@ -581,15 +581,6 @@ packages:
'@dabh/diagnostics@2.0.8':
resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==}
'@endo/cache-map@1.1.0':
resolution: {integrity: sha512-owFGshs/97PDw9oguZqU/px8Lv1d0KjAUtDUiPwKHNXRVUE/jyettEbRoTbNJR1OaI8biMn6bHr9kVJsOh6dXw==}
'@endo/env-options@1.1.11':
resolution: {integrity: sha512-p9OnAPsdqoX4YJsE98e3NBVhIr2iW9gNZxHhAI2/Ul5TdRfoOViItzHzTqrgUVopw6XxA1u1uS6CykLMDUxarA==}
'@endo/immutable-arraybuffer@1.1.2':
resolution: {integrity: sha512-u+NaYB2aqEugQ3u7w3c5QNkPogf8q/xGgsPaqdY6pUiGWtYiTiFspKFcha6+oeZhWXWQ23rf0KrUq0kfuzqYyQ==}
'@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
engines: {node: '>=12'}
@@ -1258,12 +1249,6 @@ packages:
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
engines: {node: '>= 10.0.0'}
'@penpot/plugin-types@1.4.2':
resolution: {integrity: sha512-O8wU6RSYE8bIVU7g8cSTYi32ppxs3R13dq7X3Nn9tmDaJjBOKOBpVLuoRPIp3fJC65fv8/7om0sdrtFoL5v19g==}
'@penpot/plugins-runtime@1.4.2':
resolution: {integrity: sha512-y9TDZOnb96JBW9E33dHKpmTMeAPXLtHDIZruUVjtM8hBJWZK7RCv+vAGDGxeoZJC/OB2YAHrCZG+mukePBzcuQ==}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -4636,9 +4621,6 @@ packages:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
ses@1.14.0:
resolution: {integrity: sha512-T07hNgOfVRTLZGwSS50RnhqrG3foWP+rM+Q5Du4KUQyMLFI3A8YA4RKl0jjZzhihC1ZvDGrWi/JMn4vqbgr/Jg==}
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -5499,9 +5481,6 @@ packages:
peerDependencies:
zod: ^3.25.0 || ^4.0.0
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
@@ -5775,12 +5754,6 @@ snapshots:
enabled: 2.0.0
kuler: 2.0.0
'@endo/cache-map@1.1.0': {}
'@endo/env-options@1.1.11': {}
'@endo/immutable-arraybuffer@1.1.2': {}
'@esbuild/aix-ppc64@0.21.5':
optional: true
@@ -6297,14 +6270,6 @@ snapshots:
'@parcel/watcher-win32-x64': 2.5.6
optional: true
'@penpot/plugin-types@1.4.2': {}
'@penpot/plugins-runtime@1.4.2':
dependencies:
'@penpot/plugin-types': 1.4.2
ses: 1.14.0
zod: 3.25.76
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -10000,12 +9965,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
ses@1.14.0:
dependencies:
'@endo/cache-map': 1.1.0
'@endo/env-options': 1.1.11
'@endo/immutable-arraybuffer': 1.1.2
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -10974,6 +10933,4 @@ snapshots:
dependencies:
zod: 4.3.6
zod@3.25.76: {}
zod@4.3.6: {}

View File

@@ -18,7 +18,7 @@
<meta name="twitter:creator" content="@penpotapp">
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)">
<link id="theme" href="css/main.css?version={{& version_tag}}" rel="stylesheet" type="text/css" />
<link href="css/ui.css?ts={{& ts}}" rel="stylesheet" type="text/css" />
<link href="css/ui.css?ts={{& version_tag}}" rel="stylesheet" type="text/css" />
{{#isDebug}}
<link href="css/debug.css?version={{& version_tag}}" rel="stylesheet" type="text/css" />
{{/isDebug}}

View File

@@ -4,4 +4,9 @@ TARGET=${1:-app};
set -ex
exec pnpm run watch:$TARGET
rm -rf node_modules;
corepack enable;
corepack install;
pnpm install;
pnpm run watch:$TARGET

View File

@@ -79,7 +79,7 @@
Structured tokens are non-primitive token types like `typography` or `box-shadow`."
[^js token-symbol]
(if (instance? js/Array (.-value token-symbol))
(mapv structured-token->penpot-map (.-value token-symbol))
(mapv tokenscript-symbols->penpot-unit (.-value token-symbol))
(let [entries (es6-iterator-seq (.entries (.-value token-symbol)))]
(into {} (map (fn [[k v :as V]]
[(keyword k) (tokenscript-symbols->penpot-unit v)])
@@ -88,7 +88,7 @@
(defn tokenscript-symbols->penpot-unit [^js v]
(cond
(structured-token? v) (structured-token->penpot-map v)
(list-symbol? v) (tokenscript-symbols->penpot-unit (.nth 1 v))
(list-symbol? v) (structured-token->penpot-map v)
(color-symbol? v) (.-value (.to v "hex"))
(rem-number-with-unit? v) (rem->px v)
:else (.-value v)))

View File

@@ -197,11 +197,12 @@
objects (:objects page)
undo-id (or (:undo-id options) (js/Symbol))
[all-parents changes] (-> (pcb/empty-changes it (:id page))
(cls/generate-delete-shapes fdata page objects ids
{:ignore-touched (:allow-altering-copies options)
:undo-group (:undo-group options)
:undo-id undo-id}))]
[all-parents changes]
(-> (pcb/empty-changes it (:id page))
(cls/generate-delete-shapes fdata page objects ids
{:ignore-touched (:allow-altering-copies options)
:undo-group (:undo-group options)
:undo-id undo-id}))]
(rx/of (dwu/start-undo-transaction undo-id)
(dc/detach-comment-thread ids)

View File

@@ -10,6 +10,7 @@
[app.common.attrs :as attrs]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.changes-builder :as pcb]
[app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt]
[app.common.geom.shapes :as gsh]
@@ -19,6 +20,7 @@
[app.common.types.shape.layout :as ctl]
[app.common.types.text :as txt]
[app.common.uuid :as uuid]
[app.main.data.changes :as dch]
[app.main.data.event :as ev]
[app.main.data.helpers :as dsh]
[app.main.data.workspace.common :as dwc]
@@ -916,11 +918,11 @@
(update-in state [:workspace-text-modifier shape-id] {:position-data position-data}))))
(defn v2-update-text-shape-content
[id content & {:keys [update-name? name finalize? save-undo?]
:or {update-name? false name nil finalize? false save-undo? true}}]
[id content & {:keys [update-name? name finalize? save-undo? original-content]
:or {update-name? false name nil finalize? false save-undo? true original-content nil}}]
(ptk/reify ::v2-update-text-shape-content
ptk/WatchEvent
(watch [_ state _]
(watch [it state _]
(if (features/active-feature? state "render-wasm/v1")
(let [objects (dsh/lookup-page-objects state)
shape (get objects id)
@@ -950,11 +952,11 @@
new-shape))
{:save-undo? save-undo? :undo-group (when new-shape? id)})
(let [modifiers (dwwt/resize-wasm-text-modifiers shape content)
options {:undo-group (when new-shape? id)}]
(if (and (not= :fixed (:grow-type shape)) finalize?)
(dwm/apply-wasm-modifiers modifiers options)
(dwm/set-wasm-modifiers modifiers options))))
(when-let [modifiers (dwwt/resize-wasm-text-modifiers shape content)]
(let [options {:undo-group (when new-shape? id)}]
(if (and (not= :fixed (:grow-type shape)) finalize?)
(dwm/apply-wasm-modifiers modifiers options)
(dwm/set-wasm-modifiers modifiers options)))))
(when finalize?
(rx/concat
@@ -970,7 +972,13 @@
{:save-undo? false}))
(dws/deselect-shape id)
(dwsh/delete-shapes #{id})))
(rx/of (dwt/finish-transform))))))
(rx/of
;; This commit is necesary for undo and component propagation
;; on finalization
(dch/commit-changes
(-> (pcb/empty-changes it (:current-page-id state))
(pcb/set-text-content id content original-content)))
(dwt/finish-transform))))))
(let [objects (dsh/lookup-page-objects state)
shape (get objects id)

View File

@@ -27,27 +27,28 @@
(resize-wasm-text-modifiers shape (:content shape)))
([{:keys [id points selrect grow-type] :as shape} content]
(wasm.api/use-shape id)
(wasm.api/set-shape-text-content id content)
(wasm.api/set-shape-text-images id content)
(when id
(wasm.api/use-shape id)
(wasm.api/set-shape-text-content id content)
(wasm.api/set-shape-text-images id content)
(let [dimension (wasm.api/get-text-dimensions)
width-scale (if (#{:fixed :auto-height} grow-type)
1.0
(/ (:width dimension) (:width selrect)))
height-scale (if (= :fixed grow-type)
1.0
(/ (:height dimension) (:height selrect)))
resize-v (gpt/point width-scale height-scale)
origin (first points)]
(let [dimension (wasm.api/get-text-dimensions)
width-scale (if (#{:fixed :auto-height} grow-type)
1.0
(/ (:width dimension) (:width selrect)))
height-scale (if (= :fixed grow-type)
1.0
(/ (:height dimension) (:height selrect)))
resize-v (gpt/point width-scale height-scale)
origin (first points)]
{id
{:modifiers
(ctm/resize-modifiers
resize-v
origin
(:transform shape (gmt/matrix))
(:transform-inverse shape (gmt/matrix)))}})))
{id
{:modifiers
(ctm/resize-modifiers
resize-v
origin
(:transform shape (gmt/matrix))
(:transform-inverse shape (gmt/matrix)))}}))))
(defn resize-wasm-text
"Resize a single text shape (auto-width/auto-height) by id.

View File

@@ -118,7 +118,8 @@
:update-name? update-name?
:name generated-name
:finalize? true
:save-undo? false))))
:save-undo? false
:original-content original-content))))
(let [container-node (mf/ref-val container-ref)]
(dom/set-style! container-node "opacity" 0)))

View File

@@ -540,7 +540,7 @@
[:values schema:layout-item-props-schema]
[:applied-tokens [:maybe [:map-of :keyword :string]]]
[:ids [::sm/vec ::sm/uuid]]
[:v-sizing {:optional true} [:maybe [:= :fill]]]])
[:v-sizing {:optional true} [:maybe [:enum :fill :fix :auto]]]])
(mf/defc layout-size-constraints*
{::mf/private true

View File

@@ -19,7 +19,6 @@
[app.main.data.workspace.media :as dwm]
[app.main.data.workspace.path :as dwdp]
[app.main.data.workspace.specialized-panel :as-alias dwsp]
[app.main.data.workspace.texts :as dwt]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
@@ -50,41 +49,42 @@
(mf/deps id blocked hidden type selected edition drawing-tool text-editing?
node-editing? grid-editing? drawing-path? create-comment? @z? @space?
panning read-only?)
(fn [bevent]
(fn [event]
;; We need to handle editor related stuff here because
;; handling on editor dom node does not works properly.
(let [target (dom/get-target bevent)
(let [target (dom/get-target event)
editor (txu/closest-text-editor-content target)]
;; Capture mouse pointer to detect the movements even if cursor
;; leaves the viewport or the browser itself
;; https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture
(if editor
(.setPointerCapture editor (.-pointerId bevent))
(.setPointerCapture target (.-pointerId bevent))))
(.setPointerCapture editor (.-pointerId event))
(.setPointerCapture target (.-pointerId event))))
(when (or (dom/class? (dom/get-target bevent) "viewport-controls")
(dom/class? (dom/get-target bevent) "viewport-selrect")
(dom/child? (dom/get-target bevent) (dom/query ".grid-layout-editor")))
(when (or (dom/class? (dom/get-target event) "viewport-controls")
(dom/class? (dom/get-target event) "viewport-selrect")
(dom/child? (dom/get-target event) (dom/query ".grid-layout-editor")))
(dom/stop-propagation bevent)
(dom/stop-propagation event)
(when-not @z?
(let [event (dom/event->native-event bevent)
ctrl? (kbd/ctrl? event)
meta? (kbd/meta? event)
shift? (kbd/shift? event)
alt? (kbd/alt? event)
mod? (kbd/mod? event)
(let [native-event (dom/event->native-event event)
ctrl? (kbd/ctrl? native-event)
meta? (kbd/meta? native-event)
shift? (kbd/shift? native-event)
alt? (kbd/alt? native-event)
mod? (kbd/mod? native-event)
off-pt (dom/get-offset-position native-event)
left-click? (and (not panning) (dom/left-mouse? bevent))
middle-click? (and (not panning) (dom/middle-mouse? bevent))]
left-click? (and (not panning) (dom/left-mouse? event))
middle-click? (and (not panning) (dom/middle-mouse? event))]
(cond
(or middle-click? (and left-click? @space?))
(do
(dom/prevent-default bevent)
(dom/prevent-default event)
(if mod?
(let [raw-pt (dom/get-client-position event)
(let [raw-pt (dom/get-client-position native-event)
pt (uwvv/point->viewport raw-pt)]
(st/emit! (dw/start-zooming pt)))
(st/emit! (dw/start-panning))))
@@ -94,18 +94,23 @@
(st/emit! (mse/->MouseEvent :down ctrl? shift? alt? meta?)
::dwsp/interrupt)
(when (wasm.api/text-editor-is-active?)
(wasm.api/text-editor-pointer-down (.-x off-pt) (.-y off-pt)))
(when (and (not= edition id) (or text-editing? grid-editing?))
(st/emit! (dw/clear-edition-mode))
;; FIXME: I think this is not completely correct because this
;; is going to happen even when clicking or selecting text.
;; Sync and stop WASM text editor when exiting edit mode
(when (and text-editing?
(features/active-feature? @st/state "render-wasm/v1")
wasm.wasm/context-initialized?)
(when-let [{:keys [shape-id content]} (wasm.api/text-editor-sync-content)]
(st/emit! (dwt/v2-update-text-shape-content
shape-id content
:update-name? true
:finalize? true)))
(wasm.api/text-editor-stop)))
#_(when (and text-editing?
(features/active-feature? @st/state "render-wasm/v1")
wasm.wasm/context-initialized?)
(when-let [{:keys [shape-id content]} (wasm.api/text-editor-sync-content)]
(st/emit! (dwt/v2-update-text-shape-content
shape-id content
:update-name? true
:finalize? true)))
(wasm.api/text-editor-stop)))
(when (and (not text-editing?)
(not blocked)
@@ -187,10 +192,14 @@
alt? (kbd/alt? event)
meta? (kbd/meta? event)
hovering? (some? @hover)
native-event (dom/event->native-event event)
off-pt (dom/get-offset-position native-event)
raw-pt (dom/get-client-position event)
pt (uwvv/point->viewport raw-pt)]
(st/emit! (mse/->MouseEvent :click ctrl? shift? alt? meta?))
;; FIXME: Maybe we can transform this into a cond instead
;; of multiple (when)s.
(when (and hovering?
(not @space?)
(not edition)
@@ -198,6 +207,8 @@
(not drawing-tool))
(st/emit! (dw/select-shape (:id @hover) shift?)))
;; FIXME: Maybe we can move into a function of the kind
;; "text-editor-on-click"
;; If clicking on a text shape and wasm render is enabled, forward cursor position
(when (and hovering?
(not @space?)
@@ -208,9 +219,7 @@
(when (and (= :text (:type hover-shape))
(features/active-feature? @st/state "text-editor-wasm/v1")
wasm.wasm/context-initialized?)
(let [raw-pt (dom/get-client-position event)]
;; FIXME
(wasm.api/text-editor-set-cursor-from-point (.-x raw-pt) (.-y raw-pt))))))
(wasm.api/text-editor-set-cursor-from-point (.-x off-pt) (.-y off-pt)))))
(when (and @z?
(not @space?)
@@ -261,6 +270,12 @@
wasm.wasm/context-initialized?)
(wasm.api/text-editor-start id)))
(and editable? (= id edition) (not read-only?)
(= type :text)
(features/active-feature? @st/state "text-editor-wasm/v1")
wasm.wasm/context-initialized?)
(wasm.api/text-editor-select-all)
(some? selected-shape)
(do
(reset! hover selected-shape)
@@ -310,20 +325,24 @@
;; Release pointer on mouse up
(.releasePointerCapture target (.-pointerId event)))
(let [event (dom/event->native-event event)
ctrl? (kbd/ctrl? event)
shift? (kbd/shift? event)
alt? (kbd/alt? event)
meta? (kbd/meta? event)
(let [native-event (dom/event->native-event event)
off-pt (dom/get-offset-position native-event)
ctrl? (kbd/ctrl? native-event)
shift? (kbd/shift? native-event)
alt? (kbd/alt? native-event)
meta? (kbd/meta? native-event)
left-click? (= 1 (.-which event))
middle-click? (= 2 (.-which event))]
left-click? (= 1 (.-which native-event))
middle-click? (= 2 (.-which native-event))]
(when left-click?
(st/emit! (mse/->MouseEvent :up ctrl? shift? alt? meta?)))
(st/emit! (mse/->MouseEvent :up ctrl? shift? alt? meta?))
(when (wasm.api/text-editor-is-active?)
(wasm.api/text-editor-pointer-up (.-x off-pt) (.-y off-pt))))
(when middle-click?
(dom/prevent-default event)
(dom/prevent-default native-event)
;; We store this so in Firefox the middle button won't do a paste of the content
(mf/set-ref-val! disable-paste-ref true)
@@ -381,7 +400,9 @@
(let [last-position (mf/use-var nil)]
(mf/use-fn
(fn [event]
(let [raw-pt (dom/get-client-position event)
(let [native-event (unchecked-get event "nativeEvent")
off-pt (dom/get-offset-position native-event)
raw-pt (dom/get-client-position event)
pt (uwvv/point->viewport raw-pt)
;; We calculate the delta because Safari's MouseEvent.movementX/Y drop
@@ -390,6 +411,12 @@
(gpt/subtract raw-pt @last-position)
(gpt/point 0 0))]
;; IMPORTANT! This function, right now it's called on EVERY pointermove. I think
;; in the future (when we handle the UI in the render) should be better to
;; have a "wasm.api/pointer-move" function that works as an entry point for
;; all the pointer-move events.
(wasm.api/text-editor-pointer-move (.-x off-pt) (.-y off-pt))
(rx/push! move-stream pt)
(reset! last-position raw-pt)
(st/emit! (mse/->PointerEvent :delta delta

View File

@@ -111,11 +111,6 @@
:modifier modifier
:zoom zoom}]))))
(defn- show-outline?
[shape]
(and (not (:hidden shape))
(not (:blocked shape))))
(mf/defc shape-outlines
{::mf/wrap-props false}
[props]
@@ -133,8 +128,7 @@
shapes (-> #{}
(into (comp (remove edition?)
(keep lookup)
(filter show-outline?))
(keep lookup))
(set/union selected hover))
(into (comp (remove edition?)
(keep lookup))

View File

@@ -224,8 +224,9 @@
show-gradient-handlers? (= (count selected) 1)
show-grids? (contains? layout :display-guides)
show-frame-outline? (= transform :move)
show-frame-outline? (and (= transform :move) (not panning))
show-outlines? (and (nil? transform)
(not panning)
(not edition)
(not drawing-obj)
(not (#{:comments :path :curve} drawing-tool)))
@@ -561,7 +562,7 @@
:shift? @shift?}])
[:> widgets/frame-titles*
{:objects (with-meta objects-modified nil)
{:objects objects-modified
:selected selected
:zoom zoom
:is-show-artboard-names show-artboard-names?

View File

@@ -27,6 +27,7 @@
[app.main.data.workspace.media :as dwm]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.features :as features]
[app.main.fonts :refer [fetch-font-css]]
[app.main.router :as rt]
[app.main.store :as st]
@@ -365,8 +366,10 @@
(cb/add-object shape))]
(st/emit! (ch/commit-changes changes)
(se/event plugin-id "create-shape" :type :text)
(dwwt/resize-wasm-text-debounce (:id shape)))
(se/event plugin-id "create-shape" :type :text))
(when (features/active-feature? @st/state "render-wasm/v1")
(st/emit! (dwwt/resize-wasm-text-debounce (:id shape))))
(shape/shape-proxy plugin-id (:id shape)))))

View File

@@ -58,7 +58,7 @@
origin
(if (= vers 1)
(-> plugin-url
(assoc :path "/")
(assoc :path "")
(str))
(-> plugin-url
(u/join ".")

View File

@@ -1305,7 +1305,8 @@
tokens)))}
:applyToken
{:schema [:tuple
{:enumerable false
:schema [:tuple
[:fn token-proxy?]
[:maybe [:set [:and ::sm/keyword [:fn cto/token-attr?]]]]]
:fn (fn [token attrs]

View File

@@ -144,14 +144,16 @@
(st/emit! (dwtl/delete-token set-id id)))
:applyToShapes
{:schema [:tuple
{:enumerable false
:schema [:tuple
[:vector [:fn shape-proxy?]]
[:maybe [:set [:and ::sm/keyword [:fn cto/token-attr?]]]]]
:fn (fn [shapes attrs]
(apply-token-to-shapes file-id set-id id (map #(obj/get % "$id") shapes) attrs))}
:applyToSelected
{:schema [:tuple [:maybe [:set [:and ::sm/keyword [:fn cto/token-attr?]]]]]
{:enumerable false
:schema [:tuple [:maybe [:set [:and ::sm/keyword [:fn cto/token-attr?]]]]]
:fn (fn [attrs]
(let [selected (get-in @st/state [:workspace-local :selected])]
(apply-token-to-shapes file-id set-id id selected attrs)))}))
@@ -236,14 +238,16 @@
(apply array))))}
:getTokenById
{:schema [:tuple ::sm/uuid]
{:enumerable false
:schema [:tuple ::sm/uuid]
:fn (fn [token-id]
(let [token (u/locate-token file-id id token-id)]
(when (some? token)
(token-proxy plugin-id file-id id token-id))))}
:addToken
{:schema (fn [args]
{:enumerable false
:schema (fn [args]
[:tuple (-> (cfo/make-token-schema
(-> (u/locate-tokens-lib file-id) (ctob/get-tokens id))
(cto/dtcg-token-type->token-type (-> args (first) (get "type"))))
@@ -353,13 +357,15 @@
{:this true :get (fn [_])}
:addSet
{:schema [:tuple [:fn token-set-proxy?]]
{:enumerable false
:schema [:tuple [:fn token-set-proxy?]]
:fn (fn [token-set]
(let [theme (u/locate-token-theme file-id id)]
(st/emit! (dwtl/update-token-theme id (ctob/enable-set theme (obj/get token-set :name))))))}
:removeSet
{:schema [:tuple [:fn token-set-proxy?]]
{:enumerable false
:schema [:tuple [:fn token-set-proxy?]]
:fn (fn [token-set]
(let [theme (u/locate-token-theme file-id id)]
(st/emit! (dwtl/update-token-theme id (ctob/disable-set theme (obj/get token-set :name))))))}
@@ -406,7 +412,8 @@
(apply array (map #(token-set-proxy plugin-id file-id (ctob/get-id %)) sets))))}
:addTheme
{:schema (fn [attrs]
{:enumerable false
:schema (fn [attrs]
[:tuple (-> (sm/schema (cfo/make-token-theme-schema
(u/locate-tokens-lib file-id)
(or (obj/get attrs "group") "")
@@ -419,7 +426,8 @@
(token-theme-proxy plugin-id file-id (:id theme))))}
:addSet
{:schema [:tuple (-> (sm/schema (cfo/make-token-set-schema
{:enumerable false
:schema [:tuple (-> (sm/schema (cfo/make-token-set-schema
(u/locate-tokens-lib file-id)
nil))
(sm/dissoc-key :id))] ;; We don't allow plugins to set the id
@@ -431,14 +439,16 @@
(token-set-proxy plugin-id file-id (ctob/get-id set))))}
:getThemeById
{:schema [:tuple ::sm/uuid]
{:enumerable false
:schema [:tuple ::sm/uuid]
:fn (fn [theme-id]
(let [theme (u/locate-token-theme file-id theme-id)]
(when (some? theme)
(token-theme-proxy plugin-id file-id theme-id))))}
:getSetById
{:schema [:tuple ::sm/uuid]
{:enumerable false
:schema [:tuple ::sm/uuid]
:fn (fn [set-id]
(let [set (u/locate-token-set file-id set-id)]
(when (some? set)

View File

@@ -87,7 +87,11 @@
(def text-editor-start text-editor/text-editor-start)
(def text-editor-stop text-editor/text-editor-stop)
(def text-editor-set-cursor-from-point text-editor/text-editor-set-cursor-from-point)
(def text-editor-pointer-down text-editor/text-editor-pointer-down)
(def text-editor-pointer-move text-editor/text-editor-pointer-move)
(def text-editor-pointer-up text-editor/text-editor-pointer-up)
(def text-editor-is-active? text-editor/text-editor-is-active?)
(def text-editor-select-all text-editor/text-editor-select-all)
(def text-editor-sync-content text-editor/text-editor-sync-content)
(def dpr
@@ -263,22 +267,6 @@
[attrs]
(text-editor/apply-style-to-selection attrs use-shape set-shape-text-content))
(defn update-text-rect!
[id]
(when wasm/context-initialized?
(mw/emit!
{:cmd :index/update-text-rect
:page-id (:current-page-id @st/state)
:shape-id id
:dimensions (get-text-dimensions id)})))
(defn- ensure-text-content
"Guarantee that the shape always sends a valid text tree to WASM. When the
content is nil (freshly created text) we fall back to
tc/default-text-content so the renderer receives typography information."
[content]
(or content (tc/v2-default-text-content)))
(defn set-parent-id
[id]
(let [buffer (uuid/get-u32 id)]
@@ -996,6 +984,22 @@
(render-finish)
(perf/end-measure "set-view-box::zoom")))))
(defn update-text-rect!
[id]
(when wasm/context-initialized?
(mw/emit!
{:cmd :index/update-text-rect
:page-id (:current-page-id @st/state)
:shape-id id
:dimensions (get-text-dimensions id)})))
(defn- ensure-text-content
"Guarantee that the shape always sends a valid text tree to WASM. When the
content is nil (freshly created text) we fall back to
tc/default-text-content so the renderer receives typography information."
[content]
(or content (tc/v2-default-text-content)))
(defn set-object
[shape]
(perf/begin-measure "set-object")

View File

@@ -27,6 +27,21 @@
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y)))
(defn text-editor-pointer-down
[x y]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_pointer_down" x y)))
(defn text-editor-pointer-move
[x y]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_pointer_move" x y)))
(defn text-editor-pointer-up
[x y]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_pointer_up" x y)))
(defn text-editor-update-blink
[timestamp-ms]
(when wasm/context-initialized?
@@ -83,9 +98,12 @@
(h/call wasm/internal-module "_text_editor_stop")))
(defn text-editor-is-active?
[]
(when wasm/context-initialized?
(not (zero? (h/call wasm/internal-module "_text_editor_is_active")))))
([id]
(when wasm/context-initialized?
(not (zero? (h/call wasm/internal-module "_text_editor_is_active_with_id" id)))))
([]
(when wasm/context-initialized?
(not (zero? (h/call wasm/internal-module "_text_editor_is_active"))))))
(defn text-editor-export-content
[]

View File

@@ -76,3 +76,4 @@ export function getFills(fillStyle) {
const [color, opacity] = getColor(fillStyle);
return `[["^ ","~:fill-color","${color}","~:fill-opacity",${opacity}]]`;
}

View File

@@ -162,12 +162,15 @@ class TextEditorPlayground {
}
this.#module.call("use_shape", ...textShape.id);
// FIXME: This function doesn't exists anymore.
/*
const caretPosition = this.#module.call(
"get_caret_position_at",
e.offsetX,
e.offsetY,
);
console.log("caretPosition", caretPosition);
*/
};
#onResize = (_entries) => {

View File

@@ -6,8 +6,8 @@
"ses": "^1.1.0",
"zod": "^3.22.4"
},
"module": "./index.mjs",
"typings": "./index.d.ts",
"module": "./dist/index.js",
"typings": "./dist/index.d.ts",
"type": "module",
"scripts": {
"build": "vite build",

View File

@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- esbuild

View File

@@ -39,6 +39,7 @@ pub use images::*;
const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3;
const MAX_BLOCKING_TIME_MS: i32 = 32;
const NODE_BATCH_THRESHOLD: i32 = 3;
const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0;
type ClipStack = Vec<(Rect, Option<Corners>, Matrix)>;
@@ -641,6 +642,7 @@ impl RenderState {
apply_to_current_surface: bool,
offset: Option<(f32, f32)>,
parent_shadows: Option<Vec<skia_safe::Paint>>,
spread: Option<f32>,
) {
let surface_ids = fills_surface_id as u32
| strokes_surface_id as u32
@@ -699,7 +701,14 @@ impl RenderState {
canvas.translate(translation);
});
fills::render(self, shape, &shape.fills, antialias, SurfaceId::Current);
fills::render(
self,
shape,
&shape.fills,
antialias,
SurfaceId::Current,
None,
);
// Pass strokes in natural order; stroke merging handles top-most ordering internally.
let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect();
@@ -709,6 +718,7 @@ impl RenderState {
&visible_strokes,
Some(SurfaceId::Current),
antialias,
spread,
);
self.surfaces.apply_mut(SurfaceId::Current as u32, |s| {
@@ -787,6 +797,25 @@ impl RenderState {
shape.to_mut().set_blur(None);
}
// For non-text, non-SVG shapes in the normal rendering path, apply blur
// via a single save_layer on each render surface
// Clip correctness is preserved
let blur_sigma_for_layers: Option<f32> = if !fast_mode
&& apply_to_current_surface
&& fills_surface_id == SurfaceId::Fills
&& !matches!(shape.shape_type, Type::Text(_))
&& !matches!(shape.shape_type, Type::SVGRaw(_))
{
if let Some(blur) = shape.blur.filter(|b| !b.hidden) {
shape.to_mut().set_blur(None);
Some(blur.value)
} else {
None
}
} else {
None
};
let center = shape.center();
let mut matrix = shape.transform;
matrix.post_translate(center);
@@ -1005,6 +1034,24 @@ impl RenderState {
s.canvas().concat(&matrix);
});
// Wrap ALL fill/stroke/shadow rendering so a single GPU blur pass calls
let blur_filter_for_layers: Option<skia::ImageFilter> = blur_sigma_for_layers
.and_then(|sigma| skia::image_filters::blur((sigma, sigma), None, None, None));
if let Some(ref filter) = blur_filter_for_layers {
let mut layer_paint = skia::Paint::default();
layer_paint.set_image_filter(filter.clone());
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&layer_paint);
self.surfaces
.canvas(fills_surface_id)
.save_layer(&layer_rec);
self.surfaces
.canvas(strokes_surface_id)
.save_layer(&layer_rec);
self.surfaces
.canvas(innershadows_surface_id)
.save_layer(&layer_rec);
}
let shape = &shape;
if shape.fills.is_empty()
@@ -1017,10 +1064,24 @@ impl RenderState {
{
if let Some(fills_to_render) = self.nested_fills.last() {
let fills_to_render = fills_to_render.clone();
fills::render(self, shape, &fills_to_render, antialias, fills_surface_id);
fills::render(
self,
shape,
&fills_to_render,
antialias,
fills_surface_id,
spread,
);
}
} else {
fills::render(self, shape, &shape.fills, antialias, fills_surface_id);
fills::render(
self,
shape,
&shape.fills,
antialias,
fills_surface_id,
spread,
);
}
// Skip stroke rendering for clipped frames - they are drawn in render_shape_exit
@@ -1035,6 +1096,7 @@ impl RenderState {
&visible_strokes,
Some(strokes_surface_id),
antialias,
spread,
);
if !fast_mode {
for stroke in &visible_strokes {
@@ -1057,7 +1119,12 @@ impl RenderState {
innershadows_surface_id,
);
}
// bools::debug_render_bool_paths(self, shape, shapes, modifiers, structure);
if blur_filter_for_layers.is_some() {
self.surfaces.canvas(innershadows_surface_id).restore();
self.surfaces.canvas(strokes_surface_id).restore();
self.surfaces.canvas(fills_surface_id).restore();
}
}
};
@@ -1149,6 +1216,11 @@ impl RenderState {
let _start = performance::begin_timed_log!("render_preview");
performance::begin_measure!("render_preview");
// Enable fast_mode during preview to skip expensive effects (blur, shadows).
// Restore the previous state afterward so the final render is full quality.
let current_fast_mode = self.options.is_fast_mode();
self.options.set_fast_mode(true);
// Skip tile rebuilding during preview - we'll do it at the end
// Just rebuild tiles for touched shapes and render synchronously
self.rebuild_touched_tiles(tree);
@@ -1156,6 +1228,8 @@ impl RenderState {
// Use the sync render path
self.start_render_loop(None, tree, timestamp, true)?;
self.options.set_fast_mode(current_fast_mode);
performance::end_measure!("render_preview");
performance::end_timed_log!("render_preview", _start);
@@ -1326,11 +1400,16 @@ impl RenderState {
paint.set_blend_mode(element.blend_mode().into());
paint.set_alpha_f(element.opacity());
if let Some(frame_blur) = Self::frame_clip_layer_blur(element) {
let scale = self.get_scale();
let sigma = frame_blur.value * scale;
if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) {
paint.set_image_filter(filter);
// Skip frame-level blur in fast mode (pan/zoom)
if !self.options.is_fast_mode() {
if let Some(frame_blur) = Self::frame_clip_layer_blur(element) {
let scale = self.get_scale();
let sigma = frame_blur.value * scale;
if let Some(filter) =
skia::image_filters::blur((sigma, sigma), None, None, None)
{
paint.set_image_filter(filter);
}
}
}
@@ -1417,6 +1496,7 @@ impl RenderState {
true,
None,
None,
None,
);
}
@@ -1517,9 +1597,7 @@ impl RenderState {
Self::combine_blur_values(self.combined_layer_blur(shape.blur), extra_layer_blur);
let blur_filter = combined_blur
.and_then(|blur| skia::image_filters::blur((blur.value, blur.value), None, None, None));
// Legacy path is only stable up to 1.0 zoom: the canvas is scaled and the shadow
// filter is evaluated in that scaled space, so for scale > 1 it over-inflates blur/spread.
// We also disable it when combined layer blur is present to avoid incorrect composition.
let use_low_zoom_path = scale <= 1.0 && combined_blur.is_none();
if use_low_zoom_path {
@@ -1573,14 +1651,10 @@ impl RenderState {
return;
}
if use_low_zoom_path {
let mut shadow_paint = skia::Paint::default();
shadow_paint.set_image_filter(drop_filter);
shadow_paint.set_blend_mode(skia::BlendMode::SrcOver);
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&shadow_paint);
// blur=0 at high zoom: draw directly on DropShadows with geometric spread (no filter).
if scale > 1.0 && shadow.blur <= 0.0 {
let drop_canvas = self.surfaces.canvas(SurfaceId::DropShadows);
drop_canvas.save_layer(&layer_rec);
drop_canvas.save();
drop_canvas.scale((scale, scale));
drop_canvas.translate(translation);
@@ -1595,6 +1669,7 @@ impl RenderState {
false,
Some(shadow.offset),
None,
Some(shadow.spread),
);
});
@@ -1602,20 +1677,75 @@ impl RenderState {
return;
}
let filter_result =
filters::render_into_filter_surface(self, bounds, |state, temp_surface| {
{
let canvas = state.surfaces.canvas(temp_surface);
// Create filter with blur only (no offset, no spread - handled geometrically)
let blur_only_filter = if transformed_shadow.blur > 0.0 {
Some(skia::image_filters::blur(
(transformed_shadow.blur, transformed_shadow.blur),
None,
None,
None,
))
} else {
None
};
let mut shadow_paint = skia::Paint::default();
shadow_paint.set_image_filter(drop_filter.clone());
shadow_paint.set_blend_mode(skia::BlendMode::SrcOver);
let mut shadow_paint = skia::Paint::default();
if let Some(blur_filter) = blur_only_filter {
shadow_paint.set_image_filter(blur_filter);
}
shadow_paint.set_blend_mode(skia::BlendMode::SrcOver);
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&shadow_paint);
canvas.save_layer(&layer_rec);
}
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&shadow_paint);
// Low zoom path: use blur filter but apply offset and spread geometrically
if use_low_zoom_path {
let drop_canvas = self.surfaces.canvas(SurfaceId::DropShadows);
drop_canvas.save_layer(&layer_rec);
drop_canvas.scale((scale, scale));
drop_canvas.translate(translation);
self.with_nested_blurs_suppressed(|state| {
state.render_shape(
&plain_shape,
clip_bounds,
SurfaceId::DropShadows,
SurfaceId::DropShadows,
SurfaceId::DropShadows,
SurfaceId::DropShadows,
false,
Some(shadow.offset), // Offset is geometric
None,
Some(shadow.spread), // Spread is geometric
);
});
self.surfaces.canvas(SurfaceId::DropShadows).restore();
return;
}
// Adaptive downscale for large blur values (lossless GPU optimization).
// Bounds above were computed from the original sigma so filter surface coverage is correct.
// Maximum downscale is 1/BLUR_DOWNSCALE_THRESHOLD (i.e. 8x): beyond that the
// filter surface becomes too small and quality degrades noticeably.
const MIN_BLUR_DOWNSCALE: f32 = 1.0 / BLUR_DOWNSCALE_THRESHOLD;
let blur_downscale = if shadow.blur > BLUR_DOWNSCALE_THRESHOLD {
(BLUR_DOWNSCALE_THRESHOLD / shadow.blur).max(MIN_BLUR_DOWNSCALE)
} else {
1.0
};
// High zoom with blur: use render_into_filter_surface to ensure blur has enough space
// Apply spread geometrically to avoid dilate filter rounding issues
let filter_result = filters::render_into_filter_surface(
self,
bounds,
blur_downscale,
|state, temp_surface| {
let canvas = state.surfaces.canvas(temp_surface);
canvas.save_layer(&layer_rec);
state.with_nested_blurs_suppressed(|state| {
// Apply offset and spread geometrically
state.render_shape(
&plain_shape,
clip_bounds,
@@ -1624,16 +1754,15 @@ impl RenderState {
temp_surface,
temp_surface,
false,
Some(shadow.offset),
Some(shadow.offset), // Offset is geometric
None,
Some(shadow.spread), // Spread is geometric
);
});
{
let canvas = state.surfaces.canvas(temp_surface);
canvas.restore();
}
});
state.surfaces.canvas(temp_surface).restore();
},
);
if let Some((mut surface, filter_scale)) = filter_result {
let drop_canvas = self.surfaces.canvas(SurfaceId::DropShadows);
@@ -1720,6 +1849,7 @@ impl RenderState {
if shadow_shape.hidden {
continue;
}
let nested_clip_bounds =
node_render_state.get_nested_shadow_clip_bounds(element, shadow);
@@ -1767,6 +1897,7 @@ impl RenderState {
true,
None,
Some(vec![new_shadow_paint.clone()]),
None,
);
});
self.surfaces.canvas(SurfaceId::DropShadows).restore();
@@ -1780,7 +1911,6 @@ impl RenderState {
self.surfaces
.canvas(SurfaceId::DropShadows)
.draw_paint(&paint);
self.surfaces.canvas(SurfaceId::DropShadows).restore();
}
@@ -1979,6 +2109,7 @@ impl RenderState {
true,
None,
None,
None,
);
self.surfaces

View File

@@ -97,6 +97,7 @@ pub fn render(
fills: &[Fill],
antialias: bool,
surface_id: SurfaceId,
spread: Option<f32>,
) {
if fills.is_empty() {
return;
@@ -107,7 +108,7 @@ pub fn render(
let has_image_fills = fills.iter().any(|f| matches!(f, Fill::Image(_)));
if has_image_fills {
for fill in fills.iter().rev() {
render_single_fill(render_state, shape, fill, antialias, surface_id);
render_single_fill(render_state, shape, fill, antialias, surface_id, spread);
}
return;
}
@@ -124,7 +125,7 @@ pub fn render(
|state, temp_surface| {
let mut filtered_paint = paint.clone();
filtered_paint.set_image_filter(image_filter.clone());
draw_fill_to_surface(state, shape, temp_surface, &filtered_paint);
draw_fill_to_surface(state, shape, temp_surface, &filtered_paint, spread);
},
) {
return;
@@ -133,7 +134,7 @@ pub fn render(
}
}
draw_fill_to_surface(render_state, shape, surface_id, &paint);
draw_fill_to_surface(render_state, shape, surface_id, &paint, spread);
}
/// Draws a single paint (with a merged shader) to the appropriate surface
@@ -143,18 +144,23 @@ fn draw_fill_to_surface(
shape: &Shape,
surface_id: SurfaceId,
paint: &Paint,
spread: Option<f32>,
) {
match &shape.shape_type {
Type::Rect(_) | Type::Frame(_) => {
render_state.surfaces.draw_rect_to(surface_id, shape, paint);
render_state
.surfaces
.draw_rect_to(surface_id, shape, paint, spread);
}
Type::Circle => {
render_state
.surfaces
.draw_circle_to(surface_id, shape, paint);
.draw_circle_to(surface_id, shape, paint, spread);
}
Type::Path(_) | Type::Bool(_) => {
render_state.surfaces.draw_path_to(surface_id, shape, paint);
render_state
.surfaces
.draw_path_to(surface_id, shape, paint, spread);
}
Type::Group(_) => {}
_ => unreachable!("This shape should not have fills"),
@@ -167,6 +173,7 @@ fn render_single_fill(
fill: &Fill,
antialias: bool,
surface_id: SurfaceId,
spread: Option<f32>,
) {
let mut paint = fill.to_paint(&shape.selrect, antialias);
if let Some(image_filter) = shape.image_filter(1.) {
@@ -185,6 +192,7 @@ fn render_single_fill(
antialias,
temp_surface,
&filtered_paint,
spread,
);
},
) {
@@ -194,7 +202,15 @@ fn render_single_fill(
}
}
draw_single_fill_to_surface(render_state, shape, fill, antialias, surface_id, &paint);
draw_single_fill_to_surface(
render_state,
shape,
fill,
antialias,
surface_id,
&paint,
spread,
);
}
fn draw_single_fill_to_surface(
@@ -204,6 +220,7 @@ fn draw_single_fill_to_surface(
antialias: bool,
surface_id: SurfaceId,
paint: &Paint,
spread: Option<f32>,
) {
match (fill, &shape.shape_type) {
(Fill::Image(image_fill), _) => {
@@ -217,15 +234,19 @@ fn draw_single_fill_to_surface(
);
}
(_, Type::Rect(_) | Type::Frame(_)) => {
render_state.surfaces.draw_rect_to(surface_id, shape, paint);
render_state
.surfaces
.draw_rect_to(surface_id, shape, paint, spread);
}
(_, Type::Circle) => {
render_state
.surfaces
.draw_circle_to(surface_id, shape, paint);
.draw_circle_to(surface_id, shape, paint, spread);
}
(_, Type::Path(_)) | (_, Type::Bool(_)) => {
render_state.surfaces.draw_path_to(surface_id, shape, paint);
render_state
.surfaces
.draw_path_to(surface_id, shape, paint, spread);
}
(_, Type::Group(_)) => {
// Groups can have fills but they propagate them to their children

View File

@@ -40,7 +40,9 @@ pub fn render_with_filter_surface<F>(
where
F: FnOnce(&mut RenderState, SurfaceId),
{
if let Some((mut surface, scale)) = render_into_filter_surface(render_state, bounds, draw_fn) {
if let Some((mut surface, scale)) =
render_into_filter_surface(render_state, bounds, 1.0, draw_fn)
{
let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface);
// If we scaled down, we need to scale the source rect and adjust the destination
@@ -69,9 +71,15 @@ where
/// down so that everything fits; the returned `scale` tells the caller how much the
/// content was reduced so it can be re-scaled on compositing. The `draw_fn` should
/// render the untransformed shape (i.e. in document coordinates) onto `SurfaceId::Filter`.
///
/// `extra_downscale` is an additional scale factor applied on top of the overflow-fit scale.
/// Use values < 1.0 to pre-downscale before applying Gaussian blur filters, which dramatically
/// reduces GPU kernel work for large blur sigmas (Gaussian blur is scale-equivariant, so the
/// caller must also reduce the sigma proportionally). Pass 1.0 for no extra downscale.
pub fn render_into_filter_surface<F>(
render_state: &mut RenderState,
bounds: Rect,
extra_downscale: f32,
draw_fn: F,
) -> Option<(skia::Surface, f32)>
where
@@ -86,16 +94,28 @@ where
let bounds_width = bounds.width().ceil().max(1.0) as i32;
let bounds_height = bounds.height().ceil().max(1.0) as i32;
// Minimum scale floor for fit_scale alone; prevents extreme downscaling when
// the shape is much larger than the filter surface.
const MIN_FIT_SCALE: f32 = 0.1;
// Absolute minimum for the combined scale (fit × extra_downscale). Below this
// the offscreen surface would have sub-pixel dimensions and produce artifacts or
// crashes. At 0.03 a shape must be at least ~34 px wide to render as a single
// pixel, which is a safe lower bound in practice.
const MIN_COMBINED_SCALE: f32 = 0.03;
// Calculate scale factor if bounds exceed filter surface size
let scale = if bounds_width > filter_width || bounds_height > filter_height {
let fit_scale = if bounds_width > filter_width || bounds_height > filter_height {
let scale_x = filter_width as f32 / bounds_width as f32;
let scale_y = filter_height as f32 / bounds_height as f32;
// Use the smaller scale to ensure everything fits
scale_x.min(scale_y).max(0.1) // Clamp to minimum 0.1 to avoid extreme scaling
scale_x.min(scale_y).max(MIN_FIT_SCALE)
} else {
1.0
};
// Combine overflow-fit scale with caller-requested extra downscale
let scale = (fit_scale * extra_downscale).max(MIN_COMBINED_SCALE);
{
let canvas = render_state.surfaces.canvas(filter_id);
canvas.clear(skia::Color::TRANSPARENT);

View File

@@ -47,6 +47,7 @@ pub fn render_stroke_inner_shadows(
Some(surface_id),
filter.as_ref(),
antialias,
None, // Inner shadows don't use spread
)
}
}
@@ -106,15 +107,19 @@ fn render_shadow_paint(
) {
match &shape.shape_type {
Type::Rect(_) | Type::Frame(_) => {
render_state.surfaces.draw_rect_to(surface_id, shape, paint);
render_state
.surfaces
.draw_rect_to(surface_id, shape, paint, None);
}
Type::Circle => {
render_state
.surfaces
.draw_circle_to(surface_id, shape, paint);
.draw_circle_to(surface_id, shape, paint, None);
}
Type::Path(_) | Type::Bool(_) => {
render_state.surfaces.draw_path_to(surface_id, shape, paint);
render_state
.surfaces
.draw_path_to(surface_id, shape, paint, None);
}
_ => {}
}

View File

@@ -526,6 +526,7 @@ pub fn render(
strokes: &[&Stroke],
surface_id: Option<SurfaceId>,
antialias: bool,
spread: Option<f32>,
) {
if strokes.is_empty() {
return;
@@ -540,6 +541,10 @@ pub fn render(
// edges semi-transparent and revealing strokes underneath.
if let Some(image_filter) = shape.image_filter(1.) {
let mut content_bounds = shape.selrect;
// Expand for spread if provided
if let Some(s) = spread.filter(|&s| s > 0.0) {
content_bounds.outset((s, s));
}
let max_margin = strokes
.iter()
.map(|s| s.bounds_width(shape.is_open()))
@@ -583,6 +588,7 @@ pub fn render(
antialias,
true,
true,
spread,
);
}
@@ -595,12 +601,28 @@ pub fn render(
// No blur or filter surface unavailable — draw strokes individually.
for stroke in strokes.iter().rev() {
render_single(render_state, shape, stroke, surface_id, None, antialias);
render_single(
render_state,
shape,
stroke,
surface_id,
None,
antialias,
spread,
);
}
return;
}
render_merged(render_state, shape, strokes, surface_id, antialias, false);
render_merged(
render_state,
shape,
strokes,
surface_id,
antialias,
false,
spread,
);
}
fn strokes_share_geometry(strokes: &[&Stroke]) -> bool {
@@ -620,6 +642,7 @@ fn render_merged(
surface_id: Option<SurfaceId>,
antialias: bool,
bypass_filter: bool,
spread: Option<f32>,
) {
let representative = *strokes
.last()
@@ -635,6 +658,10 @@ fn render_merged(
if !bypass_filter {
if let Some(image_filter) = blur_filter.clone() {
let mut content_bounds = shape.selrect;
// Expand for spread if provided
if let Some(s) = spread.filter(|&s| s > 0.0) {
content_bounds.outset((s, s));
}
let stroke_margin = representative.bounds_width(shape.is_open());
if stroke_margin > 0.0 {
content_bounds.inset((-stroke_margin, -stroke_margin));
@@ -660,7 +687,15 @@ fn render_merged(
canvas.save_layer(&layer_rec);
});
render_merged(state, shape, strokes, Some(temp_surface), antialias, true);
render_merged(
state,
shape,
strokes,
Some(temp_surface),
antialias,
true,
spread,
);
state.surfaces.apply_mut(temp_surface as u32, |surface| {
surface.canvas().restore();
@@ -676,11 +711,19 @@ fn render_merged(
// via SrcOver), matching the non-merged path where strokes[0] is drawn last (on top).
let fills: Vec<Fill> = strokes.iter().map(|s| s.fill.clone()).collect();
let merged = merge_fills(&fills, shape.selrect);
// Expand selrect if spread is provided
let selrect = if let Some(s) = spread.filter(|&s| s > 0.0) {
let mut r = shape.selrect;
r.outset((s, s));
r
} else {
shape.selrect
};
let merged = merge_fills(&fills, selrect);
let scale = render_state.get_scale();
let target_surface = surface_id.unwrap_or(SurfaceId::Strokes);
let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface);
let selrect = shape.selrect;
let svg_attrs = shape.svg_attrs.as_ref();
let path_transform = shape.to_path_transform();
@@ -747,6 +790,7 @@ pub fn render_single(
surface_id: Option<SurfaceId>,
shadow: Option<&ImageFilter>,
antialias: bool,
spread: Option<f32>,
) {
render_single_internal(
render_state,
@@ -757,6 +801,7 @@ pub fn render_single(
antialias,
false,
false,
spread,
);
}
@@ -770,10 +815,15 @@ fn render_single_internal(
antialias: bool,
bypass_filter: bool,
skip_blur: bool,
spread: Option<f32>,
) {
if !bypass_filter {
if let Some(image_filter) = shape.image_filter(1.) {
let mut content_bounds = shape.selrect;
// Expand for spread if provided
if let Some(s) = spread.filter(|&s| s > 0.0) {
content_bounds.outset((s, s));
}
let stroke_margin = stroke.bounds_width(shape.is_open());
if stroke_margin > 0.0 {
content_bounds.inset((-stroke_margin, -stroke_margin));
@@ -799,6 +849,7 @@ fn render_single_internal(
antialias,
true,
true,
spread,
);
},
) {
@@ -867,7 +918,21 @@ fn render_single_internal(
shape_type @ (Type::Path(_) | Type::Bool(_)) => {
if let Some(path) = shape_type.path() {
let is_open = path.is_open();
let paint = stroke.to_stroked_paint(is_open, &selrect, svg_attrs, antialias);
let mut paint =
stroke.to_stroked_paint(is_open, &selrect, svg_attrs, antialias);
// Apply spread by increasing stroke width
if let Some(s) = spread.filter(|&s| s > 0.0) {
let current_width = paint.stroke_width();
// Path stroke kinds are built differently:
// - Center uses the stroke width directly.
// - Inner/Outer use a doubled width plus clipping/clearing logic.
// Compensate spread so visual growth is comparable across kinds.
let spread_growth = match stroke.render_kind(is_open) {
StrokeKind::Center => s * 2.0,
StrokeKind::Inner | StrokeKind::Outer => s * 4.0,
};
paint.set_stroke_width(current_width + spread_growth);
}
draw_stroke_on_path(
canvas,
stroke,

View File

@@ -74,7 +74,7 @@ impl Surfaces {
let margins = skia::ISize::new(extra_tile_dims.width / 4, extra_tile_dims.height / 4);
let target = gpu_state.create_target_surface(width, height);
let filter = gpu_state.create_surface_with_dimensions("filter".to_string(), width, height);
let filter = gpu_state.create_surface_with_isize("filter".to_string(), extra_tile_dims);
let cache = gpu_state.create_surface_with_dimensions("cache".to_string(), width, height);
let current = gpu_state.create_surface_with_isize("current".to_string(), extra_tile_dims);
let drop_shadows =
@@ -355,24 +355,62 @@ impl Surfaces {
));
}
pub fn draw_rect_to(&mut self, id: SurfaceId, shape: &Shape, paint: &Paint) {
pub fn draw_rect_to(
&mut self,
id: SurfaceId,
shape: &Shape,
paint: &Paint,
spread: Option<f32>,
) {
let rect = if let Some(s) = spread.filter(|&s| s > 0.0) {
let mut r = shape.selrect;
r.outset((s, s));
r
} else {
shape.selrect
};
if let Some(corners) = shape.shape_type.corners() {
let rrect = RRect::new_rect_radii(shape.selrect, &corners);
let rrect = RRect::new_rect_radii(rect, &corners);
self.canvas_and_mark_dirty(id).draw_rrect(rrect, paint);
} else {
self.canvas_and_mark_dirty(id)
.draw_rect(shape.selrect, paint);
self.canvas_and_mark_dirty(id).draw_rect(rect, paint);
}
}
pub fn draw_circle_to(&mut self, id: SurfaceId, shape: &Shape, paint: &Paint) {
self.canvas_and_mark_dirty(id)
.draw_oval(shape.selrect, paint);
pub fn draw_circle_to(
&mut self,
id: SurfaceId,
shape: &Shape,
paint: &Paint,
spread: Option<f32>,
) {
let rect = if let Some(s) = spread.filter(|&s| s > 0.0) {
let mut r = shape.selrect;
r.outset((s, s));
r
} else {
shape.selrect
};
self.canvas_and_mark_dirty(id).draw_oval(rect, paint);
}
pub fn draw_path_to(&mut self, id: SurfaceId, shape: &Shape, paint: &Paint) {
pub fn draw_path_to(
&mut self,
id: SurfaceId,
shape: &Shape,
paint: &Paint,
spread: Option<f32>,
) {
if let Some(path) = shape.get_skia_path() {
self.canvas_and_mark_dirty(id).draw_path(&path, paint);
let canvas = self.canvas_and_mark_dirty(id);
if let Some(s) = spread.filter(|&s| s > 0.0) {
// Draw path as a thick stroke to get outset (expanded) silhouette
let mut stroke_paint = paint.clone();
stroke_paint.set_stroke_width(s * 2.0);
canvas.draw_path(&path, &stroke_paint);
} else {
canvas.draw_path(&path, paint);
}
}
}

View File

@@ -45,7 +45,11 @@ fn render_cursor(
paint.set_color(editor_state.theme.cursor_color);
paint.set_anti_alias(true);
let shape_matrix = shape.get_matrix();
canvas.save();
canvas.concat(&shape_matrix);
canvas.draw_rect(rect, &paint);
canvas.restore();
}
fn render_selection(
@@ -65,9 +69,14 @@ fn render_selection(
paint.set_blend_mode(BlendMode::Multiply);
paint.set_color(editor_state.theme.selection_color);
paint.set_anti_alias(true);
let shape_matrix = shape.get_matrix();
canvas.save();
canvas.concat(&shape_matrix);
for rect in rects {
canvas.draw_rect(rect, &paint);
}
canvas.restore();
}
fn vertical_align_offset(
@@ -99,12 +108,10 @@ fn calculate_cursor_rect(
return None;
}
let selrect = shape.selrect();
let mut y_offset = vertical_align_offset(shape, &layout_paragraphs);
for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
if idx == cursor.paragraph {
let char_pos = cursor.char_offset;
let char_pos = cursor.offset;
// For cursor, we get a zero-width range at the position
// We need to handle edge cases:
// - At start of paragraph: use position 0
@@ -157,8 +164,8 @@ fn calculate_cursor_rect(
};
return Some(Rect::from_xywh(
selrect.x() + cursor_x,
selrect.y() + y_offset,
cursor_x,
y_offset,
editor_state.theme.cursor_width,
cursor_height,
));
@@ -182,7 +189,6 @@ fn calculate_selection_rects(
let paragraphs = text_content.paragraphs();
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
let selrect = shape.selrect();
let mut y_offset = vertical_align_offset(shape, &layout_paragraphs);
for (para_idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
@@ -203,13 +209,13 @@ fn calculate_selection_rects(
.sum();
let range_start = if para_idx == start.paragraph {
start.char_offset
start.offset
} else {
0
};
let range_end = if para_idx == end.paragraph {
end.char_offset
end.offset
} else {
para_char_count
};
@@ -225,8 +231,8 @@ fn calculate_selection_rects(
for text_box in text_boxes {
let r = text_box.rect;
rects.push(Rect::from_xywh(
selrect.x() + r.left(),
selrect.y() + y_offset + r.top(),
r.left(),
y_offset + r.top(),
r.width(),
r.height(),
));

View File

@@ -258,6 +258,18 @@ pub fn all_with_ancestors(
}
impl Shape {
pub fn get_relative_point(
point: &Point,
view_matrix: &Matrix,
shape_matrix: &Matrix,
) -> Option<Point> {
let inv_view_matrix = view_matrix.invert()?;
let inv_shape_matrix = shape_matrix.invert()?;
let transform_matrix: Matrix = Matrix::concat(&inv_shape_matrix, &inv_view_matrix);
let shape_relative_point = transform_matrix.map_point(*point);
Some(shape_relative_point)
}
pub fn new(id: Uuid) -> Self {
Self {
id,

View File

@@ -300,20 +300,7 @@ fn propagate_reflow(
Type::Frame(Frame {
layout: Some(_), ..
}) => {
let mut skip_reflow = false;
if shape.is_layout_horizontal_fill() || shape.is_layout_vertical_fill() {
if let Some(parent_id) = shape.parent_id {
if parent_id != Uuid::nil() && !reflown.contains(&parent_id) {
// If this is a fill layout but the parent has not been reflown yet
// we wait for the next iteration for reflow
skip_reflow = true;
}
}
}
if !skip_reflow {
layout_reflows.insert(*id);
}
layout_reflows.insert(*id);
}
Type::Group(Group { masked: true }) => {
let children_ids = shape.children_ids(true);

View File

@@ -14,6 +14,7 @@ use skia_safe::{
textlayout::ParagraphBuilder,
textlayout::ParagraphStyle,
textlayout::PositionWithAffinity,
textlayout::Affinity,
Contains,
};
@@ -112,29 +113,55 @@ impl TextContentSize {
}
}
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, Default)]
pub struct TextPositionWithAffinity {
pub position_with_affinity: PositionWithAffinity,
pub paragraph: i32,
#[allow(dead_code)]
pub span: i32,
pub offset: i32,
pub position_with_affinity: PositionWithAffinity,
pub paragraph: usize,
pub offset: usize,
}
impl PartialEq for TextPositionWithAffinity {
fn eq(&self, other: &Self) -> bool {
self.paragraph == other.paragraph
&& self.offset == other.offset
}
}
impl TextPositionWithAffinity {
pub fn new(
position_with_affinity: PositionWithAffinity,
paragraph: i32,
span: i32,
offset: i32,
paragraph: usize,
offset: usize,
) -> Self {
Self {
position_with_affinity,
paragraph,
span,
offset,
}
}
pub fn empty() -> Self {
Self {
position_with_affinity: PositionWithAffinity {
position: 0,
affinity: Affinity::Downstream,
},
paragraph: 0,
offset: 0,
}
}
pub fn new_without_affinity(paragraph: usize, offset: usize) -> Self {
Self {
position_with_affinity: PositionWithAffinity {
position: offset as i32,
affinity: Affinity::Downstream,
},
paragraph,
offset
}
}
}
#[derive(Debug)]
@@ -421,14 +448,19 @@ impl TextContent {
self.bounds = Rect::from_ltrb(p1.x, p1.y, p2.x, p2.y);
}
pub fn get_caret_position_at(&self, point: &Point) -> Option<TextPositionWithAffinity> {
pub fn get_caret_position_from_shape_coords(
&self,
point: &Point,
) -> Option<TextPositionWithAffinity> {
let mut offset_y = 0.0;
let layout_paragraphs = self.layout.paragraphs.iter().flatten();
let mut paragraph_index: i32 = -1;
let mut span_index: i32 = -1;
let mut paragraph_index: usize = 0;
// IMPORTANT! I'm keeping this because I think it should be better to have the span index
// cached the same way we keep the paragraph index.
#[allow(dead_code)]
let mut _span_index: usize = 0;
for layout_paragraph in layout_paragraphs {
paragraph_index += 1;
let start_y = offset_y;
let end_y = offset_y + layout_paragraph.height();
@@ -449,16 +481,15 @@ impl TextContent {
// Computed position keeps the current position in terms
// of number of characters of text. This is used to know
// in which span we are.
let mut computed_position = 0;
let mut span_offset = 0;
let mut computed_position: usize = 0;
let mut span_offset: usize = 0;
// If paragraph has no spans, default to span 0, offset 0
if paragraph.children().is_empty() {
span_index = 0;
_span_index = 0;
span_offset = 0;
} else {
for span in paragraph.children() {
span_index += 1;
let length = span.text.chars().count();
let start_position = computed_position;
let end_position = computed_position + length;
@@ -475,22 +506,23 @@ impl TextContent {
&& end_position >= current_position
{
span_offset =
position_with_affinity.position - start_position as i32;
position_with_affinity.position as usize - start_position;
break;
}
computed_position += length;
_span_index += 1;
}
}
return Some(TextPositionWithAffinity::new(
position_with_affinity,
paragraph_index,
span_index,
span_offset,
position_with_affinity.position,
));
}
}
offset_y += layout_paragraph.height();
paragraph_index += 1;
}
// Handle completely empty text shapes: if there are no paragraphs or all paragraphs
@@ -507,7 +539,6 @@ impl TextContent {
return Some(TextPositionWithAffinity::new(
default_position,
0, // paragraph 0
0, // span 0
0, // offset 0
));
}
@@ -515,6 +546,16 @@ impl TextContent {
None
}
pub fn get_caret_position_from_screen_coords(
&self,
point: &Point,
view_matrix: &Matrix,
shape_matrix: &Matrix,
) -> Option<TextPositionWithAffinity> {
let shape_rel_point = Shape::get_relative_point(point, view_matrix, shape_matrix)?;
self.get_caret_position_from_shape_coords(&shape_rel_point)
}
/// Builds the ParagraphBuilders necessary to render
/// this text.
pub fn paragraph_builder_group_from_text(

View File

@@ -1,37 +1,16 @@
#![allow(dead_code)]
use crate::shapes::TextPositionWithAffinity;
use crate::shapes::{TextContent, TextPositionWithAffinity};
use crate::uuid::Uuid;
use skia_safe::Color;
/// Cursor position within text content.
/// Uses character offsets for precise positioning.
#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)]
pub struct TextCursor {
pub paragraph: usize,
pub char_offset: usize,
}
impl TextCursor {
pub fn new(paragraph: usize, char_offset: usize) -> Self {
Self {
paragraph,
char_offset,
}
}
pub fn zero() -> Self {
Self {
paragraph: 0,
char_offset: 0,
}
}
}
use skia_safe::{
textlayout::{Affinity, PositionWithAffinity},
Color,
};
#[derive(Debug, Clone, Copy, Default)]
pub struct TextSelection {
pub anchor: TextCursor,
pub focus: TextCursor,
pub anchor: TextPositionWithAffinity,
pub focus: TextPositionWithAffinity,
}
impl TextSelection {
@@ -39,10 +18,10 @@ impl TextSelection {
Self::default()
}
pub fn from_cursor(cursor: TextCursor) -> Self {
pub fn from_position_with_affinity(position: TextPositionWithAffinity) -> Self {
Self {
anchor: cursor,
focus: cursor,
anchor: position,
focus: position,
}
}
@@ -54,12 +33,12 @@ impl TextSelection {
!self.is_collapsed()
}
pub fn set_caret(&mut self, cursor: TextCursor) {
pub fn set_caret(&mut self, cursor: TextPositionWithAffinity) {
self.anchor = cursor;
self.focus = cursor;
}
pub fn extend_to(&mut self, cursor: TextCursor) {
pub fn extend_to(&mut self, cursor: TextPositionWithAffinity) {
self.focus = cursor;
}
@@ -71,24 +50,24 @@ impl TextSelection {
self.focus = self.anchor;
}
pub fn start(&self) -> TextCursor {
pub fn start(&self) -> TextPositionWithAffinity {
if self.anchor.paragraph < self.focus.paragraph {
self.anchor
} else if self.anchor.paragraph > self.focus.paragraph {
self.focus
} else if self.anchor.char_offset <= self.focus.char_offset {
} else if self.anchor.offset <= self.focus.offset {
self.anchor
} else {
self.focus
}
}
pub fn end(&self) -> TextCursor {
pub fn end(&self) -> TextPositionWithAffinity {
if self.anchor.paragraph > self.focus.paragraph {
self.anchor
} else if self.anchor.paragraph < self.focus.paragraph {
self.focus
} else if self.anchor.char_offset >= self.focus.char_offset {
} else if self.anchor.offset >= self.focus.offset {
self.anchor
} else {
self.focus
@@ -99,7 +78,7 @@ impl TextSelection {
/// Events that the text editor can emit for frontend synchronization
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum EditorEvent {
pub enum TextEditorEvent {
None = 0,
ContentChanged = 1,
SelectionChanged = 2,
@@ -122,10 +101,13 @@ pub struct TextEditorState {
pub theme: TextEditorTheme,
pub selection: TextSelection,
pub is_active: bool,
// This property indicates that we've started
// selecting something with the pointer.
pub is_pointer_selection_active: bool,
pub active_shape_id: Option<Uuid>,
pub cursor_visible: bool,
pub last_blink_time: f64,
pending_events: Vec<EditorEvent>,
pending_events: Vec<TextEditorEvent>,
}
impl TextEditorState {
@@ -138,6 +120,7 @@ impl TextEditorState {
},
selection: TextSelection::new(),
is_active: false,
is_pointer_selection_active: false,
active_shape_id: None,
cursor_visible: true,
last_blink_time: 0.0,
@@ -151,6 +134,7 @@ impl TextEditorState {
self.cursor_visible = true;
self.last_blink_time = 0.0;
self.selection = TextSelection::new();
self.is_pointer_selection_active = false;
self.pending_events.clear();
}
@@ -158,21 +142,77 @@ impl TextEditorState {
self.is_active = false;
self.active_shape_id = None;
self.cursor_visible = false;
self.is_pointer_selection_active = false;
self.pending_events.clear();
self.reset_blink();
}
pub fn set_caret_from_position(&mut self, position: TextPositionWithAffinity) {
let cursor = TextCursor::new(position.paragraph as usize, position.offset as usize);
self.selection.set_caret(cursor);
self.reset_blink();
self.push_event(EditorEvent::SelectionChanged);
pub fn start_pointer_selection(&mut self) -> bool {
if self.is_pointer_selection_active {
return false;
}
self.is_pointer_selection_active = true;
true
}
pub fn extend_selection_from_position(&mut self, position: TextPositionWithAffinity) {
let cursor = TextCursor::new(position.paragraph as usize, position.offset as usize);
self.selection.extend_to(cursor);
pub fn stop_pointer_selection(&mut self) -> bool {
if !self.is_pointer_selection_active {
return false;
}
self.is_pointer_selection_active = false;
true
}
pub fn select_all(&mut self, content: &TextContent) -> bool {
self.is_pointer_selection_active = false;
self.set_caret_from_position(TextPositionWithAffinity::new(
PositionWithAffinity {
position: 0,
affinity: Affinity::Downstream,
},
0,
0,
0,
0,
));
let num_paragraphs = (content.paragraphs().len() - 1) as i32;
let Some(last_paragraph) = content.paragraphs().last() else {
return false;
};
let num_spans = (last_paragraph.children().len() - 1) as i32;
let Some(last_text_span) = last_paragraph.children().last() else {
return false;
};
let mut offset = 0;
for span in last_paragraph.children() {
offset += span.text.len();
}
self.extend_selection_from_position(TextPositionWithAffinity::new(
PositionWithAffinity {
position: offset as i32,
affinity: Affinity::Upstream,
},
num_paragraphs,
num_spans,
last_text_span.text.len() as i32,
offset as i32,
));
self.reset_blink();
self.push_event(EditorEvent::SelectionChanged);
self.push_event(crate::state::EditorEvent::SelectionChanged);
true
}
pub fn set_caret_from_position(&mut self, position: &TextPositionWithAffinity) {
self.selection.set_caret(*position);
self.reset_blink();
self.push_event(TextEditorEvent::SelectionChanged);
}
pub fn extend_selection_from_position(&mut self, position: &TextPositionWithAffinity) {
self.selection.extend_to(*position);
self.reset_blink();
self.push_event(TextEditorEvent::SelectionChanged);
}
pub fn update_blink(&mut self, timestamp_ms: f64) {
@@ -198,41 +238,17 @@ impl TextEditorState {
self.last_blink_time = 0.0;
}
pub fn push_event(&mut self, event: EditorEvent) {
pub fn push_event(&mut self, event: TextEditorEvent) {
if self.pending_events.last() != Some(&event) {
self.pending_events.push(event);
}
}
pub fn poll_event(&mut self) -> EditorEvent {
self.pending_events.pop().unwrap_or(EditorEvent::None)
pub fn poll_event(&mut self) -> TextEditorEvent {
self.pending_events.pop().unwrap_or(TextEditorEvent::None)
}
pub fn has_pending_events(&self) -> bool {
!self.pending_events.is_empty()
}
pub fn set_caret_position_from(
&mut self,
text_position_with_affinity: TextPositionWithAffinity,
) {
self.set_caret_from_position(text_position_with_affinity);
}
}
/// TODO: Remove legacy code
#[derive(Debug, PartialEq, Clone, Copy)]
pub struct TextNodePosition {
pub paragraph: i32,
pub span: i32,
}
impl TextNodePosition {
pub fn new(paragraph: i32, span: i32) -> Self {
Self { paragraph, span }
}
pub fn is_invalid(&self) -> bool {
self.paragraph < 0 || self.span < 0
}
}

View File

@@ -1,16 +1,13 @@
use macros::ToJs;
use super::{fills::RawFillData, fonts::RawFontStyle};
use crate::math::{Matrix, Point};
use crate::mem::{self, SerializableResult};
use crate::shapes::{
self, GrowType, Shape, TextAlign, TextDecoration, TextDirection, TextTransform, Type,
};
use crate::utils::{uuid_from_u32, uuid_from_u32_quartet};
use crate::{
with_current_shape, with_current_shape_mut, with_state, with_state_mut,
with_state_mut_current_shape, STATE,
};
use crate::{with_current_shape, with_current_shape_mut, with_state, with_state_mut, STATE};
const RAW_SPAN_DATA_SIZE: usize = std::mem::size_of::<RawTextSpan>();
const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::<RawParagraphData>();
@@ -388,32 +385,6 @@ pub extern "C" fn update_shape_text_layout_for(a: u32, b: u32, c: u32, d: u32) {
});
}
#[no_mangle]
pub extern "C" fn get_caret_position_at(x: f32, y: f32) -> i32 {
with_state_mut_current_shape!(state, |shape: &Shape| {
if let Type::Text(text_content) = &shape.shape_type {
let mut matrix = Matrix::new_identity();
let shape_matrix = shape.get_concatenated_matrix(&state.shapes);
let view_matrix = state.render_state.viewbox.get_matrix();
if let Some(inv_view_matrix) = view_matrix.invert() {
matrix.post_concat(&inv_view_matrix);
matrix.post_concat(&shape_matrix);
let mapped_point = matrix.map_point(Point::new(x, y));
if let Some(position_with_affinity) =
text_content.get_caret_position_at(&mapped_point)
{
return position_with_affinity.position_with_affinity.position;
}
}
} else {
panic!("Trying to get caret position of a shape that it's not a text shape");
}
});
-1
}
const RAW_POSITION_DATA_SIZE: usize = size_of::<shapes::PositionData>();
impl From<[u8; RAW_POSITION_DATA_SIZE]> for shapes::PositionData {

View File

@@ -1,12 +1,11 @@
use macros::ToJs;
use crate::math::{Matrix, Point, Rect};
use crate::mem;
use crate::shapes::{Paragraph, Shape, TextContent, Type, VerticalAlign};
use crate::state::{TextCursor, TextSelection};
use crate::shapes::{Paragraph, Shape, TextContent, TextPositionWithAffinity, Type, VerticalAlign};
use crate::state::{TextSelection};
use crate::utils::uuid_from_u32_quartet;
use crate::utils::uuid_to_u32_quartet;
use crate::{with_state, with_state_mut, STATE};
use macros::ToJs;
#[derive(PartialEq, ToJs)]
#[repr(u8)]
@@ -54,6 +53,17 @@ pub extern "C" fn text_editor_is_active() -> bool {
with_state!(state, { state.text_editor_state.is_active })
}
#[no_mangle]
pub extern "C" fn text_editor_is_active_with_id(a: u32, b: u32, c: u32, d: u32) -> bool {
with_state!(state, {
let shape_id = uuid_from_u32_quartet(a, b, c, d);
let Some(active_shape_id) = state.text_editor_state.active_shape_id else {
return false;
};
state.text_editor_state.is_active && active_shape_id == shape_id
})
}
#[no_mangle]
pub extern "C" fn text_editor_get_active_shape_id(buffer_ptr: *mut u32) {
with_state!(state, {
@@ -70,45 +80,25 @@ pub extern "C" fn text_editor_get_active_shape_id(buffer_ptr: *mut u32) {
}
#[no_mangle]
pub extern "C" fn text_editor_select_all() {
pub extern "C" fn text_editor_select_all() -> bool {
with_state_mut!(state, {
if !state.text_editor_state.is_active {
return;
return false;
}
let Some(shape_id) = state.text_editor_state.active_shape_id else {
return;
return false;
};
let Some(shape) = state.shapes.get(&shape_id) else {
return;
return false;
};
let Type::Text(text_content) = &shape.shape_type else {
return;
return false;
};
let paragraphs = text_content.paragraphs();
if paragraphs.is_empty() {
return;
}
let last_para_idx = paragraphs.len() - 1;
let last_para = &paragraphs[last_para_idx];
let total_chars: usize = last_para
.children()
.iter()
.map(|span| span.text.chars().count())
.sum();
use crate::state::TextCursor;
state.text_editor_state.selection.anchor = TextCursor::new(0, 0);
state.text_editor_state.selection.focus = TextCursor::new(last_para_idx, total_chars);
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::EditorEvent::SelectionChanged);
});
state.text_editor_state.select_all(text_content)
})
}
#[no_mangle]
@@ -121,146 +111,127 @@ pub extern "C" fn text_editor_poll_event() -> u8 {
// ============================================================================
#[no_mangle]
pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) {
pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) {
with_state_mut!(state, {
if !state.text_editor_state.is_active {
return;
}
let Some(shape_id) = state.text_editor_state.active_shape_id else {
return;
};
let (shape_matrix, view_matrix, selrect, vertical_align) = {
let Some(shape) = state.shapes.get(&shape_id) else {
return;
};
(
shape.get_concatenated_matrix(&state.shapes),
state.render_state.viewbox.get_matrix(),
shape.selrect(),
shape.vertical_align(),
)
};
let Some(inv_view_matrix) = view_matrix.invert() else {
let Some(shape) = state.shapes.get(&shape_id) else {
return;
};
let Some(inv_shape_matrix) = shape_matrix.invert() else {
let Type::Text(text_content) = &shape.shape_type else {
return;
};
let mut matrix = Matrix::new_identity();
matrix.post_concat(&inv_view_matrix);
matrix.post_concat(&inv_shape_matrix);
let mapped_point = matrix.map_point(Point::new(x, y));
let Some(shape) = state.shapes.get_mut(&shape_id) else {
return;
};
let Type::Text(text_content) = &mut shape.shape_type else {
return;
};
if text_content.layout.paragraphs.is_empty() && !text_content.paragraphs().is_empty() {
let bounds = text_content.bounds;
text_content.update_layout(bounds);
}
// Calculate vertical alignment offset (same as in render/text_editor.rs)
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum();
let vertical_offset = match vertical_align {
crate::shapes::VerticalAlign::Center => (selrect.height() - total_height) / 2.0,
crate::shapes::VerticalAlign::Bottom => selrect.height() - total_height,
_ => 0.0,
};
// Adjust point: subtract selrect offset and vertical alignment
// The text layout expects coordinates where (0, 0) is the top-left of the text content
let adjusted_point = Point::new(
mapped_point.x - selrect.x(),
mapped_point.y - selrect.y() - vertical_offset,
);
if let Some(position) = text_content.get_caret_position_at(&adjusted_point) {
let point = Point::new(x, y);
let view_matrix: Matrix = state.render_state.viewbox.get_matrix();
let shape_matrix = shape.get_matrix();
state.text_editor_state.start_pointer_selection();
if let Some(position) =
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
{
state.text_editor_state.set_caret_from_position(position);
}
});
}
#[no_mangle]
pub extern "C" fn text_editor_extend_selection_to_point(x: f32, y: f32) {
pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) {
with_state_mut!(state, {
if !state.text_editor_state.is_active {
return;
}
let view_matrix: Matrix = state.render_state.viewbox.get_matrix();
let point = Point::new(x, y);
let Some(shape_id) = state.text_editor_state.active_shape_id else {
return;
};
let Some(shape) = state.shapes.get(&shape_id) else {
return;
};
let shape_matrix = shape.get_matrix();
let Some(_shape_rel_point) = Shape::get_relative_point(&point, &view_matrix, &shape_matrix)
else {
return;
};
if !state.text_editor_state.is_pointer_selection_active {
return;
}
let Type::Text(text_content) = &shape.shape_type else {
return;
};
if let Some(position) =
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
{
state
.text_editor_state
.extend_selection_from_position(position);
}
});
}
#[no_mangle]
pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) {
with_state_mut!(state, {
if !state.text_editor_state.is_active {
return;
}
let view_matrix: Matrix = state.render_state.viewbox.get_matrix();
let point = Point::new(x, y);
let Some(shape_id) = state.text_editor_state.active_shape_id else {
return;
};
let Some(shape) = state.shapes.get(&shape_id) else {
return;
};
let shape_matrix = shape.get_matrix();
let Some(_shape_rel_point) = Shape::get_relative_point(&point, &view_matrix, &shape_matrix)
else {
return;
};
if !state.text_editor_state.is_pointer_selection_active {
return;
}
let Type::Text(text_content) = &shape.shape_type else {
return;
};
if let Some(position) =
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
{
state
.text_editor_state
.extend_selection_from_position(&position);
}
state.text_editor_state.stop_pointer_selection();
});
}
#[no_mangle]
pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) {
with_state_mut!(state, {
if !state.text_editor_state.is_active {
return;
}
let view_matrix: Matrix = state.render_state.viewbox.get_matrix();
let point = Point::new(x, y);
let Some(shape_id) = state.text_editor_state.active_shape_id else {
return;
};
let (shape_matrix, view_matrix, selrect, vertical_align) = {
let Some(shape) = state.shapes.get(&shape_id) else {
return;
};
(
shape.get_concatenated_matrix(&state.shapes),
state.render_state.viewbox.get_matrix(),
shape.selrect(),
shape.vertical_align(),
)
};
let Some(inv_view_matrix) = view_matrix.invert() else {
let Some(shape) = state.shapes.get(&shape_id) else {
return;
};
let Some(inv_shape_matrix) = shape_matrix.invert() else {
let shape_matrix = shape.get_matrix();
let Type::Text(text_content) = &shape.shape_type else {
return;
};
let mut matrix = Matrix::new_identity();
matrix.post_concat(&inv_view_matrix);
matrix.post_concat(&inv_shape_matrix);
let mapped_point = matrix.map_point(Point::new(x, y));
let Some(shape) = state.shapes.get_mut(&shape_id) else {
return;
};
let Type::Text(text_content) = &mut shape.shape_type else {
return;
};
if text_content.layout.paragraphs.is_empty() && !text_content.paragraphs().is_empty() {
let bounds = text_content.bounds;
text_content.update_layout(bounds);
}
// Calculate vertical alignment offset (same as in render/text_editor.rs)
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum();
let vertical_offset = match vertical_align {
crate::shapes::VerticalAlign::Center => (selrect.height() - total_height) / 2.0,
crate::shapes::VerticalAlign::Bottom => selrect.height() - total_height,
_ => 0.0,
};
// Adjust point: subtract selrect offset and vertical alignment
let adjusted_point = Point::new(
mapped_point.x - selrect.x(),
mapped_point.y - selrect.y() - vertical_offset,
);
if let Some(position) = text_content.get_caret_position_at(&adjusted_point) {
state
.text_editor_state
.extend_selection_from_position(position);
if let Some(position) =
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
{
state.text_editor_state.set_caret_from_position(&position);
}
});
}
@@ -305,7 +276,7 @@ pub extern "C" fn text_editor_insert_text() {
let cursor = state.text_editor_state.selection.focus;
if let Some(new_offset) = insert_text_at_cursor(text_content, &cursor, &text) {
let new_cursor = TextCursor::new(cursor.paragraph, new_offset);
let new_cursor = TextPositionWithAffinity::new_without_affinity(cursor.paragraph, new_offset);
state.text_editor_state.selection.set_caret(new_cursor);
}
@@ -315,10 +286,10 @@ pub extern "C" fn text_editor_insert_text() {
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::EditorEvent::ContentChanged);
.push_event(crate::state::TextEditorEvent::ContentChanged);
state
.text_editor_state
.push_event(crate::state::EditorEvent::NeedsLayout);
.push_event(crate::state::TextEditorEvent::NeedsLayout);
state.render_state.mark_touched(shape_id);
});
@@ -365,10 +336,10 @@ pub extern "C" fn text_editor_delete_backward() {
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::EditorEvent::ContentChanged);
.push_event(crate::state::TextEditorEvent::ContentChanged);
state
.text_editor_state
.push_event(crate::state::EditorEvent::NeedsLayout);
.push_event(crate::state::TextEditorEvent::NeedsLayout);
state.render_state.mark_touched(shape_id);
});
@@ -413,10 +384,10 @@ pub extern "C" fn text_editor_delete_forward() {
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::EditorEvent::ContentChanged);
.push_event(crate::state::TextEditorEvent::ContentChanged);
state
.text_editor_state
.push_event(crate::state::EditorEvent::NeedsLayout);
.push_event(crate::state::TextEditorEvent::NeedsLayout);
state.render_state.mark_touched(shape_id);
});
@@ -452,7 +423,7 @@ pub extern "C" fn text_editor_insert_paragraph() {
let cursor = state.text_editor_state.selection.focus;
if split_paragraph_at_cursor(text_content, &cursor) {
let new_cursor = TextCursor::new(cursor.paragraph + 1, 0);
let new_cursor = TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0);
state.text_editor_state.selection.set_caret(new_cursor);
}
@@ -462,10 +433,10 @@ pub extern "C" fn text_editor_insert_paragraph() {
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::EditorEvent::ContentChanged);
.push_event(crate::state::TextEditorEvent::ContentChanged);
state
.text_editor_state
.push_event(crate::state::EditorEvent::NeedsLayout);
.push_event(crate::state::TextEditorEvent::NeedsLayout);
state.render_state.mark_touched(shape_id);
});
@@ -523,7 +494,7 @@ pub extern "C" fn text_editor_move_cursor(direction: CursorDirection, extend_sel
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::EditorEvent::SelectionChanged);
.push_event(crate::state::TextEditorEvent::SelectionChanged);
});
}
@@ -740,12 +711,12 @@ pub extern "C" fn text_editor_export_selection() -> *mut u8 {
.map(|span| span.text.chars().count())
.sum();
let range_start = if para_idx == start.paragraph {
start.char_offset
start.offset
} else {
0
};
let range_end = if para_idx == end.paragraph {
end.char_offset
end.offset
} else {
para_char_count
};
@@ -793,9 +764,9 @@ pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> u32 {
let sel = &state.text_editor_state.selection;
unsafe {
*buffer_ptr = sel.anchor.paragraph as u32;
*buffer_ptr.add(1) = sel.anchor.char_offset as u32;
*buffer_ptr.add(1) = sel.anchor.offset as u32;
*buffer_ptr.add(2) = sel.focus.paragraph as u32;
*buffer_ptr.add(3) = sel.focus.char_offset as u32;
*buffer_ptr.add(3) = sel.focus.offset as u32;
}
1
})
@@ -805,7 +776,7 @@ pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> u32 {
// HELPERS: Cursor & Selection
// ============================================================================
fn get_cursor_rect(text_content: &TextContent, cursor: &TextCursor, shape: &Shape) -> Option<Rect> {
fn get_cursor_rect(text_content: &TextContent, cursor: &TextPositionWithAffinity, shape: &Shape) -> Option<Rect> {
let paragraphs = text_content.paragraphs();
if cursor.paragraph >= paragraphs.len() {
return None;
@@ -823,7 +794,7 @@ fn get_cursor_rect(text_content: &TextContent, cursor: &TextCursor, shape: &Shap
let mut y_offset = valign_offset;
for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
if idx == cursor.paragraph {
let char_pos = cursor.char_offset;
let char_pos = cursor.offset;
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
let rects = laid_out_para.get_rects_for_range(
@@ -898,13 +869,13 @@ fn get_selection_rects(
.map(|span| span.text.chars().count())
.sum();
let range_start = if para_idx == start.paragraph {
start.char_offset
start.offset
} else {
0
};
let range_end = if para_idx == end.paragraph {
end.char_offset
end.offset
} else {
para_char_count
};
@@ -943,40 +914,40 @@ fn paragraph_char_count(para: &Paragraph) -> usize {
}
/// Clamp a cursor position to valid bounds within the text content.
fn clamp_cursor(cursor: TextCursor, paragraphs: &[Paragraph]) -> TextCursor {
fn clamp_cursor(position: TextPositionWithAffinity, paragraphs: &[Paragraph]) -> TextPositionWithAffinity {
if paragraphs.is_empty() {
return TextCursor::new(0, 0);
return TextPositionWithAffinity::new_without_affinity(0, 0);
}
let para_idx = cursor.paragraph.min(paragraphs.len() - 1);
let para_idx = position.paragraph.min(paragraphs.len() - 1);
let para_len = paragraph_char_count(&paragraphs[para_idx]);
let char_offset = cursor.char_offset.min(para_len);
let char_offset = position.offset.min(para_len);
TextCursor::new(para_idx, char_offset)
TextPositionWithAffinity::new_without_affinity(para_idx, char_offset)
}
/// Move cursor left by one character.
fn move_cursor_backward(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor {
if cursor.char_offset > 0 {
TextCursor::new(cursor.paragraph, cursor.char_offset - 1)
fn move_cursor_backward(cursor: &TextPositionWithAffinity, paragraphs: &[Paragraph]) -> TextPositionWithAffinity {
if cursor.offset > 0 {
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, cursor.offset - 1)
} else if cursor.paragraph > 0 {
let prev_para = cursor.paragraph - 1;
let char_count = paragraph_char_count(&paragraphs[prev_para]);
TextCursor::new(prev_para, char_count)
TextPositionWithAffinity::new_without_affinity(prev_para, char_count)
} else {
*cursor
}
}
/// Move cursor right by one character.
fn move_cursor_forward(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor {
fn move_cursor_forward(cursor: &TextPositionWithAffinity, paragraphs: &[Paragraph]) -> TextPositionWithAffinity {
let para = &paragraphs[cursor.paragraph];
let char_count = paragraph_char_count(para);
if cursor.char_offset < char_count {
TextCursor::new(cursor.paragraph, cursor.char_offset + 1)
if cursor.offset < char_count {
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, cursor.offset + 1)
} else if cursor.paragraph < paragraphs.len() - 1 {
TextCursor::new(cursor.paragraph + 1, 0)
TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0)
} else {
*cursor
}
@@ -984,52 +955,52 @@ fn move_cursor_forward(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCur
/// Move cursor up by one line.
fn move_cursor_up(
cursor: &TextCursor,
cursor: &TextPositionWithAffinity,
paragraphs: &[Paragraph],
_text_content: &TextContent,
_shape: &Shape,
) -> TextCursor {
) -> TextPositionWithAffinity {
// TODO: Implement proper line-based navigation using line metrics
if cursor.paragraph > 0 {
let prev_para = cursor.paragraph - 1;
let char_count = paragraph_char_count(&paragraphs[prev_para]);
let new_offset = cursor.char_offset.min(char_count);
TextCursor::new(prev_para, new_offset)
let new_offset = cursor.offset.min(char_count);
TextPositionWithAffinity::new_without_affinity(prev_para, new_offset)
} else {
TextCursor::new(cursor.paragraph, 0)
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0)
}
}
/// Move cursor down by one line.
fn move_cursor_down(
cursor: &TextCursor,
cursor: &TextPositionWithAffinity,
paragraphs: &[Paragraph],
_text_content: &TextContent,
_shape: &Shape,
) -> TextCursor {
) -> TextPositionWithAffinity {
// TODO: Implement proper line-based navigation using line metrics
if cursor.paragraph < paragraphs.len() - 1 {
let next_para = cursor.paragraph + 1;
let char_count = paragraph_char_count(&paragraphs[next_para]);
let new_offset = cursor.char_offset.min(char_count);
TextCursor::new(next_para, new_offset)
let new_offset = cursor.offset.min(char_count);
TextPositionWithAffinity::new_without_affinity(next_para, new_offset)
} else {
let char_count = paragraph_char_count(&paragraphs[cursor.paragraph]);
TextCursor::new(cursor.paragraph, char_count)
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count)
}
}
/// Move cursor to start of current line.
fn move_cursor_line_start(cursor: &TextCursor, _paragraphs: &[Paragraph]) -> TextCursor {
fn move_cursor_line_start(cursor: &TextPositionWithAffinity, _paragraphs: &[Paragraph]) -> TextPositionWithAffinity {
// TODO: Implement proper line-start using line metrics
TextCursor::new(cursor.paragraph, 0)
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0)
}
/// Move cursor to end of current line.
fn move_cursor_line_end(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor {
fn move_cursor_line_end(cursor: &TextPositionWithAffinity, paragraphs: &[Paragraph]) -> TextPositionWithAffinity {
// TODO: Implement proper line-end using line metrics
let char_count = paragraph_char_count(&paragraphs[cursor.paragraph]);
TextCursor::new(cursor.paragraph, char_count)
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count)
}
// ============================================================================
@@ -1057,7 +1028,7 @@ fn find_span_at_offset(para: &Paragraph, char_offset: usize) -> Option<(usize, u
/// Insert text at a cursor position. Returns the new character offset after insertion.
fn insert_text_at_cursor(
text_content: &mut TextContent,
cursor: &TextCursor,
cursor: &TextPositionWithAffinity,
text: &str,
) -> Option<usize> {
let paragraphs = text_content.paragraphs_mut();
@@ -1077,7 +1048,7 @@ fn insert_text_at_cursor(
return Some(text.chars().count());
}
let (span_idx, offset_in_span) = find_span_at_offset(para, cursor.char_offset)?;
let (span_idx, offset_in_span) = find_span_at_offset(para, cursor.offset)?;
let children = para.children_mut();
let span = &mut children[span_idx];
@@ -1092,7 +1063,7 @@ fn insert_text_at_cursor(
new_text.insert_str(byte_offset, text);
span.set_text(new_text);
Some(cursor.char_offset + text.chars().count())
Some(cursor.offset + text.chars().count())
}
/// Delete a range of text specified by a selection.
@@ -1108,18 +1079,18 @@ fn delete_selection_range(text_content: &mut TextContent, selection: &TextSelect
if start.paragraph == end.paragraph {
delete_range_in_paragraph(
&mut paragraphs[start.paragraph],
start.char_offset,
end.char_offset,
start.offset,
end.offset,
);
} else {
let start_para_len = paragraph_char_count(&paragraphs[start.paragraph]);
delete_range_in_paragraph(
&mut paragraphs[start.paragraph],
start.char_offset,
start.offset,
start_para_len,
);
delete_range_in_paragraph(&mut paragraphs[end.paragraph], 0, end.char_offset);
delete_range_in_paragraph(&mut paragraphs[end.paragraph], 0, end.offset);
if end.paragraph < paragraphs.len() {
let end_para_children: Vec<_> =
@@ -1218,13 +1189,13 @@ fn delete_range_in_paragraph(para: &mut Paragraph, start_offset: usize, end_offs
}
/// Delete the character before the cursor. Returns the new cursor position.
fn delete_char_before(text_content: &mut TextContent, cursor: &TextCursor) -> Option<TextCursor> {
if cursor.char_offset > 0 {
fn delete_char_before(text_content: &mut TextContent, cursor: &TextPositionWithAffinity) -> Option<TextPositionWithAffinity> {
if cursor.offset > 0 {
let paragraphs = text_content.paragraphs_mut();
let para = &mut paragraphs[cursor.paragraph];
let delete_pos = cursor.char_offset - 1;
delete_range_in_paragraph(para, delete_pos, cursor.char_offset);
Some(TextCursor::new(cursor.paragraph, delete_pos))
let delete_pos = cursor.offset - 1;
delete_range_in_paragraph(para, delete_pos, cursor.offset);
Some(TextPositionWithAffinity::new_without_affinity(cursor.paragraph, delete_pos))
} else if cursor.paragraph > 0 {
let prev_para_idx = cursor.paragraph - 1;
let paragraphs = text_content.paragraphs_mut();
@@ -1240,14 +1211,14 @@ fn delete_char_before(text_content: &mut TextContent, cursor: &TextCursor) -> Op
paragraphs.remove(cursor.paragraph);
Some(TextCursor::new(prev_para_idx, prev_para_len))
Some(TextPositionWithAffinity::new_without_affinity(prev_para_idx, prev_para_len))
} else {
None
}
}
/// Delete the character after the cursor.
fn delete_char_after(text_content: &mut TextContent, cursor: &TextCursor) {
fn delete_char_after(text_content: &mut TextContent, cursor: &TextPositionWithAffinity) {
let paragraphs = text_content.paragraphs_mut();
if cursor.paragraph >= paragraphs.len() {
return;
@@ -1255,9 +1226,9 @@ fn delete_char_after(text_content: &mut TextContent, cursor: &TextCursor) {
let para_len = paragraph_char_count(&paragraphs[cursor.paragraph]);
if cursor.char_offset < para_len {
if cursor.offset < para_len {
let para = &mut paragraphs[cursor.paragraph];
delete_range_in_paragraph(para, cursor.char_offset, cursor.char_offset + 1);
delete_range_in_paragraph(para, cursor.offset, cursor.offset + 1);
} else if cursor.paragraph < paragraphs.len() - 1 {
let next_para_idx = cursor.paragraph + 1;
let next_children: Vec<_> = paragraphs[next_para_idx].children_mut().drain(..).collect();
@@ -1270,7 +1241,7 @@ fn delete_char_after(text_content: &mut TextContent, cursor: &TextCursor) {
}
/// Split a paragraph at the cursor position. Returns true if split was successful.
fn split_paragraph_at_cursor(text_content: &mut TextContent, cursor: &TextCursor) -> bool {
fn split_paragraph_at_cursor(text_content: &mut TextContent, cursor: &TextPositionWithAffinity) -> bool {
let paragraphs = text_content.paragraphs_mut();
if cursor.paragraph >= paragraphs.len() {
return false;
@@ -1278,7 +1249,7 @@ fn split_paragraph_at_cursor(text_content: &mut TextContent, cursor: &TextCursor
let para = &paragraphs[cursor.paragraph];
let Some((span_idx, offset_in_span)) = find_span_at_offset(para, cursor.char_offset) else {
let Some((span_idx, offset_in_span)) = find_span_at_offset(para, cursor.offset) else {
return false;
};

View File

@@ -14,7 +14,7 @@ pushd $_SCRIPT_DIR;
cargo watch \
--why \
-i "_tmp*"
-i "_tmp*" \
-x "build $CARGO_PARAMS" \
-s "./build" \
-s "echo 'DONE\n'";