Compare commits

..

51 Commits

Author SHA1 Message Date
Aitor Moreno
55539e83bd WIP 2026-02-27 13:58:44 +01:00
Aitor Moreno
740e790585 🎉 Add active-features? helper function (#8490) 2026-02-27 12:12:27 +01:00
Elena Torró
ed23c55550 Merge pull request #8483 from penpot/superalex-fix-opacity-for-dotted-strokes
🐛 Fix opacity for dotted strokes
2026-02-26 13:41:43 +01:00
Alejandro Alonso
5b5c868a87 🐛 Fix opacity for dotted strokes 2026-02-26 13:31:12 +01:00
Alejandro Alonso
1a3ac6bdf8 Merge pull request #8475 from penpot/elenatorro-13524-fix-token-highlight
🐛 Fix rotation token highlight and its application on the text-ed…
2026-02-26 13:00:45 +01:00
Elena Torró
de5d4f4292 Merge pull request #8460 from penpot/azazeln28-refactor-text-cursor
♻️ Refactor TextCursor and TextPositionWithAffinity
2026-02-26 12:29:43 +01:00
Elena Torro
2bd7c10e09 🔧 Fix variable name from wrong merge 2026-02-26 12:19:20 +01:00
Elena Torro
495371c079 🐛 Fix rotation token highlight and its application on the text-editor-v2 2026-02-26 11:57:11 +01:00
Elena Torró
75b1c0c1b1 Merge pull request #8280 from penpot/niwinz-layers-sidebar-changes
 Add serveral performance optimization to layers sidebar
2026-02-26 11:37:57 +01:00
Andrey Antukh
5ea4b03108 📎 Fix e2e tests 2026-02-26 11:13:31 +01:00
Andrey Antukh
0fef5b7e5d Memoize variant props on layer-item 2026-02-26 11:13:31 +01:00
Andrey Antukh
8a1fdd9dd1 Reduce watchers for layer-item rename mechanism 2026-02-26 11:13:31 +01:00
Andrey Antukh
a080a9e646 Add micro optimizations to layer-item component 2026-02-26 11:13:31 +01:00
Andrey Antukh
a728d5a5f2 💄 Add minor cosmetic changes to filters-tree component 2026-02-26 11:13:30 +01:00
Andrey Antukh
6072234230 Add more selective debouncing for layers-tree 2026-02-26 11:13:30 +01:00
Andrey Antukh
41f2877801 Reduce allocation on layers-tree component 2026-02-26 11:13:30 +01:00
Andrey Antukh
e2576d049a 💄 Add minor cosmetic changes on event listening 2026-02-26 11:13:30 +01:00
Andrey Antukh
4db9c373e6 💄 Fix component naming style related to layer-item 2026-02-26 11:13:30 +01:00
Andrey Antukh
09a9407867 💄 Change props naming on layer-item and related components 2026-02-26 11:13:30 +01:00
Andrey Antukh
7be03e2ea6 Remove usage of use-var on layer-item
Focus on use more basic primitves on performance
sensitive components
2026-02-26 11:13:30 +01:00
Aitor Moreno
05165ce014 🐛 Fix board title cropped using wrong side 2026-02-26 09:35:56 +01:00
Aitor Moreno
96677713fc 🐛 Fix 45 rotated board doesn't show title properly 2026-02-26 09:34:15 +01:00
alonso.torres
f41eca12f4 🐛 Fix problem with frame title movement 2026-02-25 14:14:08 +01:00
Alejandro Alonso
baa44119f4 Merge pull request #8468 from penpot/azazeln28-issue-13315-fix-text-alignment-options
🐛 Fix text alignment options
2026-02-25 13:15:13 +01:00
Aitor Moreno
7d3e434167 Merge pull request #8457 from penpot/superalex-improve-cursor-blinking
🎉 Improve cursor blinking
2026-02-25 12:50:05 +01:00
Aitor Moreno
40233e3316 🐛 Fix text alignment options 2026-02-25 12:47:07 +01:00
Alejandro Alonso
e2b5f936f5 🐛 Fix stroke artifacts 2026-02-25 11:27:05 +01:00
Elena Torró
614c6ed300 Merge pull request #8461 from penpot/superalex-fix-auto-width-affects-text-selection
🐛 Fix auto width affects text selection
2026-02-25 11:22:38 +01:00
Alejandro Alonso
4975f28a3d 🐛 Fix auto width affects text selection 2026-02-25 11:11:45 +01:00
Alejandro Alonso
f5109c7df2 🎉 Refactor caret blinking to reduce CPU usage 2026-02-25 11:08:56 +01:00
Elena Torró
12a1cb1d32 Merge pull request #8451 from penpot/superalex-update-skia-version
🎉 Update skia version
2026-02-25 11:07:00 +01:00
Alejandro Alonso
84ba6f0002 🎉 Update skia version 2026-02-25 10:30:29 +01:00
Aitor Moreno
a12b59d101 ♻️ Refactor TextCursor and TextPositionWithAffinity 2026-02-25 09:59:02 +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
Alejandro Alonso
a7ab506c5c 🐛 Fix blur 0 artifacts 2026-02-20 13:37:27 +01:00
Andrés Moya
3d41dc276e 🐛 Fix resolve tokens with tokenscript when type is font family 2026-02-20 12:41:17 +01:00
alonso.torres
cee974a906 🐛 Fix problem with tokens in plugins 2026-02-18 17:20:46 +01:00
88 changed files with 6873 additions and 1155 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)
@@ -28,6 +31,7 @@
- Fix boolean operators in menu for boards [Taiga #13174](https://tree.taiga.io/project/penpot/issue/13174)
- Fix viewer can update library [Taiga #13186](https://tree.taiga.io/project/penpot/issue/13186)
- Fix remove fill affects different element than selected [Taiga #13128](https://tree.taiga.io/project/penpot/issue/13128)
- Fix 45 rotated board titles rendered incorrectly [Taiga #13306](https://tree.taiga.io/project/penpot/issue/13306)
## 2.13.3

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

@@ -58,4 +58,3 @@
(when (nil? (:data file))
(migrate-file conn file)))
(db/exec-one! conn ["drop table page cascade;"])))

View File

@@ -115,21 +115,25 @@
(defn get-frames
"Retrieves all frame objects as vector"
([objects] (get-frames objects nil))
([objects {:keys [skip-components? skip-copies?]
([objects {:keys [skip-components? skip-copies? ignore-index?]
:or {skip-components? false
skip-copies? false}}]
(->> (or (-> objects meta ::index-frames)
(let [lookup (d/getf objects)
xform (comp (remove #(= uuid/zero %))
(keep lookup)
(filter cfh/frame-shape?))]
(->> (keys objects)
(sequence xform))))
(remove #(or (and ^boolean skip-components?
^boolean (ctk/instance-head? %))
(and ^boolean skip-copies?
(and ^boolean (ctk/instance-head? %)
(not ^boolean (ctk/main-instance? %)))))))))
skip-copies? false
ignore-index? false}}]
(let [frame-index
(if (and (not ignore-index?) (-> objects meta ::index-frames))
(-> objects meta ::index-frames)
(let [lookup (d/getf objects)
xform (comp (remove #(= uuid/zero %))
(keep lookup)
(filter cfh/frame-shape?))]
(->> (keys objects)
(sequence xform))))]
(->> frame-index
(remove #(or (and ^boolean skip-components?
^boolean (ctk/instance-head? %))
(and ^boolean skip-copies?
(and ^boolean (ctk/instance-head? %)
(not ^boolean (ctk/main-instance? %))))))))))
(defn get-frames-ids
"Retrieves all frame ids as vector"

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

@@ -0,0 +1,814 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"plugins/runtime",
"design-tokens/v1",
"variants/v1",
"layout/grid",
"styles/v2",
"fdata/pointer-map",
"fdata/objects-map",
"render-wasm/v1",
"components/v2",
"fdata/shape-data-type"
]
},
"~:team-id": "~ueba8fa2e-4140-8084-8005-448635d7a724",
"~: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": "gaps",
"~:revn": 79,
"~:modified-at": "~m1771855365377",
"~:vern": 0,
"~:id": "~ueffcbebc-b8c8-802f-8007-9a0b2e2c863f",
"~: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": "~ueba8fa2e-4140-8084-8005-448635da32b4",
"~:created-at": "~m1771591980210",
"~:backend": "legacy-db",
"~:data": {
"~:pages": [
"~ueffcbebc-b8c8-802f-8007-9a0b2e2c8640"
],
"~:pages-index": {
"~ueffcbebc-b8c8-802f-8007-9a0b2e2c8640": {
"~:objects": {
"~u00000000-0000-0000-0000-000000000000": {
"~#shape": {
"~:y": 0,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
}
},
"~:rotation": 0,
"~:name": "Root Frame",
"~:width": 0.01,
"~:type": "~:frame",
"~:points": [
{
"~#point": {
"~:x": 0,
"~:y": 0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0.01
}
},
{
"~#point": {
"~:x": 0,
"~:y": 0.01
}
}
],
"~:r2": 0,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
}
},
"~:r3": 0,
"~:r1": 0,
"~:id": "~u00000000-0000-0000-0000-000000000000",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 0,
"~:proportion": 1,
"~:r4": 0,
"~:selrect": {
"~#rect": {
"~:x": 0,
"~:y": 0,
"~:width": 0.01,
"~:height": 0.01,
"~:x1": 0,
"~:y1": 0,
"~:x2": 0.01,
"~:y2": 0.01
}
},
"~:fills": [
{
"~:fill-color": "#FFFFFF",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 0.01,
"~:flip-y": null,
"~:shapes": [
"~u36e8a3ad-2b63-8008-8007-9a0b2f24ca4e",
"~ufbc43ead-a2ce-8058-8007-9a0daf843e09",
"~ufbc43ead-a2ce-8058-8007-9a0dbe2f49b8",
"~u5bebb998-d617-801b-8007-9a3fbd5cc804",
"~u80e2fa5a-cd1c-8043-8007-9d8aaca49f40"
]
}
},
"~ufbc43ead-a2ce-8058-8007-9a0dbe2f49b8": {
"~#shape": {
"~:y": null,
"~:transform": {
"~#matrix": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
}
},
"~:rotation": 0,
"~:grow-type": "~:fixed",
"~:content": {
"~#penpot/path-data": "~bAQAAAAAAAAAAAAAAAAAAAAAAAAD/f5dEM2EsRAIAAAAAAAAAAAAAAAAAAAAAAAAAUhmnRABACkQCAAAAAAAAAAAAAAAAAAAAAAAAAP8/vET//01EAgAAAAAAAAAAAAAAAAAAAAAAAAD/f5dEM2EsRA=="
},
"~:name": "Path",
"~:width": null,
"~:type": "~:path",
"~:points": [
{
"~#point": {
"~:x": 1212.00003372852,
"~:y": 553.000012923003
}
},
{
"~#point": {
"~:x": 1506.00004755679,
"~:y": 553.000012923003
}
},
{
"~#point": {
"~:x": 1506.00004755679,
"~:y": 823.999993849517
}
},
{
"~#point": {
"~:x": 1212.00003372852,
"~:y": 823.999993849517
}
}
],
"~:r2": 0,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
}
},
"~:r3": 0,
"~:r1": 0,
"~:id": "~ufbc43ead-a2ce-8058-8007-9a0dbe2f49b8",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [
{
"~:stroke-alignment": "~:inner",
"~:stroke-style": "~:solid",
"~:stroke-color": "#000000",
"~:stroke-opacity": 1,
"~:stroke-width": 10
}
],
"~:x": null,
"~:proportion": 1,
"~:shadow": [],
"~:r4": 0,
"~:selrect": {
"~#rect": {
"~:x": 1212.00003372852,
"~:y": 553.000012923003,
"~:width": 294.000013828278,
"~:height": 270.999980926514,
"~:x1": 1212.00003372852,
"~:y1": 553.000012923003,
"~:x2": 1506.00004755679,
"~:y2": 823.999993849517
}
},
"~:fills": [
{
"~:fill-color": "#ffffff",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": null,
"~:flip-y": null
}
},
"~u36e8a3ad-2b63-8008-8007-9a0b2f24ca4e": {
"~#shape": {
"~:y": 122.000001761754,
"~:transform": {
"~#matrix": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
}
},
"~:rotation": 0,
"~:hide-in-viewer": false,
"~:name": "Rectangle",
"~:width": 463.999987447937,
"~:type": "~:rect",
"~:points": [
{
"~#point": {
"~:x": 694.000014750112,
"~:y": 122.000001761754
}
},
{
"~#point": {
"~:x": 1158.00000219805,
"~:y": 122.000001761754
}
},
{
"~#point": {
"~:x": 1158.00000219805,
"~:y": 499.999980116278
}
},
{
"~#point": {
"~:x": 694.000014750112,
"~:y": 499.999980116278
}
}
],
"~:r2": 0,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
}
},
"~:r3": 0,
"~:r1": 0,
"~:id": "~u36e8a3ad-2b63-8008-8007-9a0b2f24ca4e",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [
{
"~:stroke-alignment": "~:inner",
"~:stroke-style": "~:solid",
"~:stroke-color": "#000000",
"~:stroke-opacity": 1,
"~:stroke-width": 100
},
{
"~:stroke-alignment": "~:outer",
"~:stroke-style": "~:solid",
"~:stroke-color": "#000000",
"~:stroke-opacity": 1,
"~:stroke-width": 100
}
],
"~:x": 694.000014750113,
"~:proportion": 1,
"~:shadow": [],
"~:r4": 0,
"~:selrect": {
"~#rect": {
"~:x": 694.000014750113,
"~:y": 122.000001761754,
"~:width": 463.999987447937,
"~:height": 377.999978354524,
"~:x1": 694.000014750113,
"~:y1": 122.000001761754,
"~:x2": 1158.00000219805,
"~:y2": 499.999980116278
}
},
"~:fills": [
{
"~:fill-color": "#ffffff",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 377.999978354524,
"~:flip-y": null
}
},
"~ufbc43ead-a2ce-8058-8007-9a0daf843e09": {
"~#shape": {
"~:y": 262.999997589325,
"~:transform": {
"~#matrix": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
}
},
"~:rotation": 0,
"~:grow-type": "~:fixed",
"~:hide-in-viewer": false,
"~:name": "Ellipse",
"~:width": 266.000036716461,
"~:type": "~:circle",
"~:points": [
{
"~#point": {
"~:x": 1271.00000137752,
"~:y": 262.999997589325
}
},
{
"~#point": {
"~:x": 1537.00003809398,
"~:y": 262.999997589325
}
},
{
"~#point": {
"~:x": 1537.00003809398,
"~:y": 483.000033828949
}
},
{
"~#point": {
"~:x": 1271.00000137752,
"~:y": 483.000033828949
}
}
],
"~:r2": 0,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
}
},
"~:r3": 0,
"~:r1": 0,
"~:id": "~ufbc43ead-a2ce-8058-8007-9a0daf843e09",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [
{
"~:stroke-alignment": "~:inner",
"~:stroke-style": "~:solid",
"~:stroke-color": "#000000",
"~:stroke-opacity": 1,
"~:stroke-width": 10
}
],
"~:x": 1271.00000137752,
"~:proportion": 1,
"~:shadow": [
{
"~:id": "~u9c6321b5-aeab-809f-8007-971f9e232191",
"~:style": "~:drop-shadow",
"~:color": {
"~:color": "#000000",
"~:opacity": 1
},
"~:offset-x": 4,
"~:offset-y": 4,
"~:blur": 0,
"~:spread": 0,
"~:hidden": true
}
],
"~:r4": 0,
"~:selrect": {
"~#rect": {
"~:x": 1271.00000137752,
"~:y": 262.999997589325,
"~:width": 266.000036716461,
"~:height": 220.000036239624,
"~:x1": 1271.00000137752,
"~:y1": 262.999997589325,
"~:x2": 1537.00003809398,
"~:y2": 483.000033828949
}
},
"~:fills": [
{
"~:fill-color": "#ffffff",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 220.000036239624,
"~:flip-y": null
}
},
"~u80e2fa5a-cd1c-8043-8007-9d8aaca49f40": {
"~#shape": {
"~:y": -286.999972473494,
"~:transform": {
"~#matrix": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
}
},
"~:rotation": 0,
"~:grow-type": "~:auto-width",
"~:content": {
"~:type": "root",
"~:key": "1srkh8oc2vd",
"~:children": [
{
"~:type": "paragraph-set",
"~:children": [
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:children": [
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:font-id": "sourcesanspro",
"~:key": "170uyffw5ph",
"~:font-size": "400",
"~:font-weight": "400",
"~:typography-ref-file": null,
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#ffffff",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro",
"~:text": "HELLO"
}
],
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:key": "psg8ayj675",
"~:font-size": "400",
"~:font-weight": "400",
"~:typography-ref-file": null,
"~:text-direction": "ltr",
"~:type": "paragraph",
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#ffffff",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro"
}
]
}
],
"~:vertical-align": "top"
},
"~:hide-in-viewer": false,
"~:name": "HELLO",
"~:width": 1116.00003953244,
"~:type": "~:text",
"~:points": [
{
"~#point": {
"~:x": 545.000013504691,
"~:y": -286.999972473494
}
},
{
"~#point": {
"~:x": 1661.00005303713,
"~:y": -286.999972473494
}
},
{
"~#point": {
"~:x": 1661.00005303713,
"~:y": 193.000017549648
}
},
{
"~#point": {
"~:x": 545.000013504691,
"~:y": 193.000017549648
}
}
],
"~:transform-inverse": {
"~#matrix": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
}
},
"~:id": "~u80e2fa5a-cd1c-8043-8007-9d8aaca49f40",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:position-data": [
{
"~:y": 211.980041503906,
"~:line-height": "1.2",
"~:font-style": "normal",
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:font-size": "400",
"~:font-weight": "400",
"~:text-direction": "ltr",
"~:width": 1115.22998046875,
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:x": 545,
"~:fills": [
{
"~:fill-color": "#ffffff",
"~:fill-opacity": 1
}
],
"~:direction": "ltr",
"~:font-family": "sourcesanspro",
"~:height": 517.960021972656,
"~:text": "HELLO"
}
],
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [
{
"~:stroke-style": "~:solid",
"~:stroke-alignment": "~:inner",
"~:stroke-width": 5,
"~:stroke-color": "#000000",
"~:stroke-opacity": 1
}
],
"~:x": 545.000013504691,
"~:selrect": {
"~#rect": {
"~:x": 545.000013504691,
"~:y": -286.999972473494,
"~:width": 1116.00003953244,
"~:height": 479.999990023141,
"~:x1": 545.000013504691,
"~:y1": -286.999972473494,
"~:x2": 1661.00005303713,
"~:y2": 193.000017549648
}
},
"~:flip-x": null,
"~:height": 479.999990023141,
"~:flip-y": null
}
},
"~u5bebb998-d617-801b-8007-9a3fbd5cc804": {
"~#shape": {
"~:y": 543.00001095581,
"~:transform": {
"~#matrix": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
}
},
"~:rotation": 0,
"~:hide-in-viewer": false,
"~:name": "Rectangle",
"~:width": 463.999987447937,
"~:type": "~:rect",
"~:points": [
{
"~#point": {
"~:x": 693.999990768432,
"~:y": 543.00001095581
}
},
{
"~#point": {
"~:x": 1157.99997821637,
"~:y": 543.00001095581
}
},
{
"~#point": {
"~:x": 1157.99997821637,
"~:y": 920.999989310334
}
},
{
"~#point": {
"~:x": 693.999990768432,
"~:y": 920.999989310334
}
}
],
"~:r2": 0,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
}
},
"~:r3": 0,
"~:r1": 0,
"~:id": "~u5bebb998-d617-801b-8007-9a3fbd5cc804",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [
{
"~:stroke-alignment": "~:inner",
"~:stroke-style": "~:solid",
"~:stroke-color": "#000000",
"~:stroke-opacity": 1,
"~:stroke-width": 100
}
],
"~:x": 693.999990768432,
"~:proportion": 1,
"~:shadow": [],
"~:r4": 0,
"~:selrect": {
"~#rect": {
"~:x": 693.999990768432,
"~:y": 543.00001095581,
"~:width": 463.999987447937,
"~:height": 377.999978354524,
"~:x1": 693.999990768432,
"~:y1": 543.00001095581,
"~:x2": 1157.99997821637,
"~:y2": 920.999989310334
}
},
"~:fills": [
{
"~:fill-color": "#ffffff",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 377.999978354524,
"~:flip-y": null
}
}
},
"~:id": "~ueffcbebc-b8c8-802f-8007-9a0b2e2c8640",
"~:name": "Page 1",
"~:background": "#000000"
}
},
"~:id": "~ueffcbebc-b8c8-802f-8007-9a0b2e2c863f",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
}
}
}

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

@@ -404,6 +404,8 @@ export class WorkspacePage extends BaseWebSocketPage {
return content !== "";
}, { timeout: 1000 });
await this.page.waitForTimeout(3000);
}
/**
@@ -417,7 +419,8 @@ export class WorkspacePage extends BaseWebSocketPage {
await this.viewport.click({ button: "right" });
return this.page.getByText("Paste", { exact: true }).click();
}
return this.page.keyboard.press("ControlOrMeta+V");
await this.page.keyboard.press("ControlOrMeta+V");
await this.page.waitForTimeout(3000);
}
async panOnViewportAt(x, y, width, height) {

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();
@@ -392,3 +432,27 @@ test("Keeps component visible when focusing after creating it", async ({
await workspace.hideUI();
await expect(workspace.canvas).toHaveScreenshot();
});
test("Check inner stroke artifacts", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("render-wasm/get-file-inner-strokes-artifacts.json");
await workspace.goToWorkspace({
id: "effcbebc-b8c8-802f-8007-9a0b2e2c863f",
pageId: "effcbebc-b8c8-802f-8007-9a0b2e2c8640",
});
await workspace.waitForFirstRenderWithoutUI();
const previousRenderCount = await workspace.getRenderCount();
await page.keyboard.press("ControlOrMeta++");
await workspace.waitForNextRender(previousRenderCount);
// Stricter comparison: artifacts are very subtle
await expect(workspace.canvas).toHaveScreenshot({
maxDiffPixelRatio: 0,
threshold: 0.1,
});
});

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

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

@@ -383,24 +383,26 @@ test("User cut paste a component with path inside a variant", async ({
const variant = await findVariant(workspacePage, 0);
//Create a component
// Create a component
await workspacePage.ellipseShapeButton.click();
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
await workspacePage.clickLeafLayer("Ellipse");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
await workspacePage.page.waitForTimeout(3000);
//Rename the component
// Rename the component
await workspacePage.layers.getByText("Ellipse").dblclick();
await workspacePage.page
.getByTestId("layer-item")
.getByRole("textbox")
.pressSequentially("button / hover");
await workspacePage.page.keyboard.press("Enter");
await workspacePage.page.waitForTimeout(3000);
//Cut the component
// Cut the component
await workspacePage.cut("keyboard");
//Paste the component inside the variant
// Paste the component inside the variant
await variant.container.click();
await workspacePage.paste("keyboard");
@@ -427,6 +429,7 @@ test("User drag and drop a component with path inside a variant", async ({
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
await workspacePage.clickLeafLayer("Ellipse");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
await workspacePage.page.waitForTimeout(3000);
//Rename the component
await workspacePage.layers.getByText("Ellipse").dblclick();

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

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

@@ -620,61 +620,68 @@
ptk/WatchEvent
(watch [_ state _]
;; We do not allow to apply tokens while text editor is open.
(when (empty? (get state :workspace-editor-state))
(let [attributes-to-remove
;; Remove atomic typography tokens when applying composite and vice-verca
(cond
(ctt/typography-token-keys (:type token)) (set/union attributes-to-remove ctt/typography-keys)
(ctt/typography-keys (:type token)) (set/union attributes-to-remove ctt/typography-token-keys)
:else attributes-to-remove)]
(when-let [tokens (some-> (dsh/lookup-file-data state)
(get :tokens-lib)
(ctob/get-tokens-in-active-sets))]
(->> (if (contains? cf/flags :tokenscript)
(rx/of (ts/resolve-tokens tokens))
(sd/resolve-tokens tokens))
(rx/mapcat
(fn [resolved-tokens]
(let [undo-id (js/Symbol)
objects (dsh/lookup-page-objects state)
selected-shapes (select-keys objects shape-ids)
;; The classic text editor sets :workspace-editor-state; the WASM text editor
;; does not, so we also check :workspace-local :edition for text shapes.
(let [edition (get-in state [:workspace-local :edition])
objects (dsh/lookup-page-objects state)
text-editing? (and (some? edition)
(= :text (:type (get objects edition))))]
(when (and (empty? (get state :workspace-editor-state))
(not text-editing?))
(let [attributes-to-remove
;; Remove atomic typography tokens when applying composite and vice-verca
(cond
(ctt/typography-token-keys (:type token)) (set/union attributes-to-remove ctt/typography-keys)
(ctt/typography-keys (:type token)) (set/union attributes-to-remove ctt/typography-token-keys)
:else attributes-to-remove)]
(when-let [tokens (some-> (dsh/lookup-file-data state)
(get :tokens-lib)
(ctob/get-tokens-in-active-sets))]
(->> (if (contains? cf/flags :tokenscript)
(rx/of (ts/resolve-tokens tokens))
(sd/resolve-tokens tokens))
(rx/mapcat
(fn [resolved-tokens]
(let [undo-id (js/Symbol)
objects (dsh/lookup-page-objects state)
selected-shapes (select-keys objects shape-ids)
shapes (->> selected-shapes
(filter (fn [[_ shape]]
(or
(and (ctsl/any-layout-immediate-child? objects shape)
(some ctt/spacing-margin-keys attributes))
(and (ctt/any-appliable-attr-for-shape? attributes (:type shape) (:layout shape))
(all-attrs-appliable-for-token? attributes (:type token)))))))
shape-ids (d/nilv (keys shapes) [])
any-variant? (->> shapes vals (some ctk/is-variant?) boolean)
shapes (->> selected-shapes
(filter (fn [[_ shape]]
(or
(and (ctsl/any-layout-immediate-child? objects shape)
(some ctt/spacing-margin-keys attributes))
(and (ctt/any-appliable-attr-for-shape? attributes (:type shape) (:layout shape))
(all-attrs-appliable-for-token? attributes (:type token)))))))
shape-ids (d/nilv (keys shapes) [])
any-variant? (->> shapes vals (some ctk/is-variant?) boolean)
resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value])
resolved-value (if (contains? cf/flags :tokenscript)
(ts/tokenscript-symbols->penpot-unit resolved-value)
resolved-value)
tokenized-attributes (cfo/attributes-map attributes token)
type (:type token)]
(rx/concat
(rx/of
(st/emit! (ev/event {::ev/name "apply-tokens"
:type type
:applied-to attributes
:applied-to-variant any-variant?}))
(dwu/start-undo-transaction undo-id)
(dwsh/update-shapes shape-ids (fn [shape]
(cond-> shape
attributes-to-remove
(update :applied-tokens #(apply (partial dissoc %) attributes-to-remove))
:always
(update :applied-tokens merge tokenized-attributes)))))
(when on-update-shape
(let [res (on-update-shape resolved-value shape-ids attributes)]
;; Composed updates return observables and need to be executed differently
(if (rx/observable? res)
res
(rx/of res))))
(rx/of (dwu/commit-undo-transaction undo-id)))))))))))))
resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value])
resolved-value (if (contains? cf/flags :tokenscript)
(ts/tokenscript-symbols->penpot-unit resolved-value)
resolved-value)
tokenized-attributes (cfo/attributes-map attributes token)
type (:type token)]
(rx/concat
(rx/of
(st/emit! (ev/event {::ev/name "apply-tokens"
:type type
:applied-to attributes
:applied-to-variant any-variant?}))
(dwu/start-undo-transaction undo-id)
(dwsh/update-shapes shape-ids (fn [shape]
(cond-> shape
attributes-to-remove
(update :applied-tokens #(apply (partial dissoc %) attributes-to-remove))
:always
(update :applied-tokens merge tokenized-attributes)))))
(when on-update-shape
(let [res (on-update-shape resolved-value shape-ids attributes)]
;; Composed updates return observables and need to be executed differently
(if (rx/observable? res)
res
(rx/of res))))
(rx/of (dwu/commit-undo-transaction undo-id))))))))))))))
(defn apply-spacing-token-separated
"Handles edge-case for spacing token when applying token via toggle button.

View File

@@ -548,7 +548,7 @@
modif-tree
(dwm/build-modif-tree ids objects get-modifier)]
(rx/of (dwm/apply-wasm-modifiers modif-tree)))
(rx/of (dwm/apply-wasm-modifiers modif-tree :ignore-touched (:ignore-touched options))))
(let [page-id (or (:page-id options)
(:current-page-id state))

View File

@@ -86,6 +86,24 @@
:else
(enabled-by-flags? state feature))))
(defn active-features?
"Given a state and a set of features, check if the features are all enabled."
([state a]
(js/console.warn "Please, use active-feature? instead")
(active-feature? state a))
([state a b]
(and ^boolean (active-feature? state a)
^boolean (active-feature? state b)))
([state a b c]
(and ^boolean (active-feature? state a)
^boolean (active-feature? state b)
^boolean (active-feature? state c)))
([state a b c & others]
(and ^boolean (active-feature? state a)
^boolean (active-feature? state b)
^boolean (active-feature? state c)
^boolean (every? #(active-feature? state %) others))))
(def ^:private features-ref
(l/derived (l/key :features) st/state))

View File

@@ -183,9 +183,6 @@
[id]
(l/derived #(contains? % id) selected-shapes))
(def highlighted-shapes
(l/derived :highlighted workspace-local))
(def export-in-progress?
(l/derived :export-in-progress? export))

View File

@@ -12,7 +12,7 @@
[app.common.types.component :as ctk]
[app.main.data.viewer :as dv]
[app.main.store :as st]
[app.main.ui.workspace.sidebar.layer-item :refer [layer-item-inner]]
[app.main.ui.workspace.sidebar.layer-item :refer [layer-item-inner*]]
[app.util.dom :as dom]
[app.util.keyboard :as kbd]
[okulary.core :as l]
@@ -26,7 +26,6 @@
(mf/defc layer-item
[{:keys [item selected objects depth component-child? hide-toggle?] :as props}]
(let [id (:id item)
hidden? (:hidden item)
selected? (contains? selected id)
item-ref (mf/use-ref nil)
depth (+ depth 1)
@@ -68,18 +67,17 @@
(when (and (= (count selected) 1) selected?)
(dom/scroll-into-view-if-needed! (mf/ref-val item-ref) true))))
[:& layer-item-inner
[:> layer-item-inner*
{:ref item-ref
:item item
:depth depth
:read-only? true
:highlighted? false
:selected? selected?
:component-tree? component-tree?
:hidden? hidden?
:filtered? false
:expanded? expanded?
:hide-toggle? hide-toggle?
:is-read-only true
:is-highlighted false
:is-selected selected?
:is-component-tree component-tree?
:is-filtered false
:is-expanded expanded?
:hide-toggle hide-toggle?
:on-select-shape select-shape
:on-toggle-collapse toggle-collapse}

View File

@@ -131,7 +131,8 @@
on-style-change
(fn [event]
(let [styles (styles/get-styles-from-event event)]
(let [
styles (styles/get-styles-from-event event)]
(st/emit! (dwt/v2-update-text-editor-styles shape-id styles))))
on-needs-layout

View File

@@ -73,12 +73,12 @@
}
.grow-type-auto-width {
[data-itype="inline"],
[data-itype="span"],
[data-itype="paragraph"] {
white-space: nowrap;
}
[data-itype="inline"] {
[data-itype="span"] {
white-space-collapse: preserve;
}
}

View File

@@ -0,0 +1,338 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.workspace.shapes.text.v3-editor
"Contenteditable DOM element for WASM text editor input"
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.common.geom.shapes :as gsh]
[app.common.math :as mth]
[app.main.data.helpers :as dsh]
[app.main.data.workspace.texts :as dwt]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.css-cursors :as cur]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.text-editor :as text-editor]
[app.util.dom :as dom]
[app.util.object :as obj]
[cuerdas.core :as str]
[rumext.v2 :as mf]
[app.main.ui.ds.foundations.assets.icon :as i]))
(def caret-blink-interval-ms 250)
(defn- sync-wasm-text-editor-content!
"Sync WASM text editor content back to the shape via the standard
commit pipeline. Called after every text-modifying input."
[& {:keys [finalize?]}]
(when-let [{:keys [shape-id content]}
(text-editor/text-editor-sync-content)]
(st/emit! (dwt/v2-update-text-shape-content
shape-id content
:update-name? true
:finalize? finalize?))))
(defn- font-family-from-font-id [font-id]
(if (str/includes? font-id "gfont-noto-sans")
(let [lang (str/replace font-id #"gfont\-noto\-sans\-" "")]
(if (>= (count lang) 3) (str/capital lang) (str/upper lang)))
"Noto Color Emoji"))
(mf/defc text-editor
"Contenteditable element positioned over the text shape to capture input events."
{::mf/wrap-props false}
[props]
(let [shape (obj/get props "shape")
shape-id (dm/get-prop shape :id)
clip-id (dm/str "text-edition-clip" shape-id)
contenteditable-ref (mf/use-ref nil)
composing? (mf/use-state false)
fallback-fonts (wasm.api/fonts-from-text-content (:content shape) false)
fallback-families (map (fn [font]
(font-family-from-font-id (:font-id font))) fallback-fonts)
;; Calculate screen position from shape bounds
bounds (gsh/shape->rect shape)
x (mth/min (dm/get-prop bounds :x)
(dm/get-prop shape :x))
y (mth/min (dm/get-prop bounds :y)
(dm/get-prop shape :y))
width (mth/max (dm/get-prop bounds :width)
(dm/get-prop shape :width))
height (mth/max (dm/get-prop bounds :height)
(dm/get-prop shape :height))
[{:keys [x y width height]} transform]
(let [{:keys [width height]} (wasm.api/get-text-dimensions shape-id)
selrect-transform (mf/deref refs/workspace-selrect)
[selrect transform] (dsh/get-selrect selrect-transform shape)
selrect-height (:height selrect)
selrect-width (:width selrect)
max-width (max width selrect-width)
max-height (max height selrect-height)
valign (-> shape :content :vertical-align)
y (:y selrect)
y (case valign
"bottom" (+ y (- selrect-height height))
"center" (+ y (/ (- selrect-height height) 2))
y)]
[(assoc selrect :y y :width max-width :height max-height) transform])
on-composition-start
(mf/use-fn
(fn [_event]
(reset! composing? true)))
on-composition-end
(mf/use-fn
(fn [^js event]
(reset! composing? false)
(let [data (.-data event)]
(when data
(text-editor/text-editor-insert-text data)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-composition"))
(when-let [node (mf/ref-val contenteditable-ref)]
(set! (.-textContent node) "")))))
on-paste
(mf/use-fn
(fn [^js event]
(dom/prevent-default event)
(let [clipboard-data (.-clipboardData event)
text (.getData clipboard-data "text/plain")]
(when (and text (seq text))
(text-editor/text-editor-insert-text text)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-paste"))
(when-let [node (mf/ref-val contenteditable-ref)]
(set! (.-textContent node) "")))))
on-copy
(mf/use-fn
(fn [^js event]
(when (text-editor/text-editor-is-active?)
(dom/prevent-default event)
(when (text-editor/text-editor-get-selection)
(let [text (text-editor/text-editor-export-selection)]
(.setData (.-clipboardData event) "text/plain" text))))))
on-key-down
(mf/use-fn
(fn [^js event]
(js/console.log (.-type event) (.-key event))
(when (and (text-editor/text-editor-is-active?)
(not @composing?))
(let [key (.-key event)
ctrl? (or (.-ctrlKey event) (.-metaKey event))
shift? (.-shiftKey event)]
(cond
;; Escape: finalize and stop
(= key "Escape")
(do
(dom/prevent-default event)
(sync-wasm-text-editor-content! :finalize? true)
(text-editor/text-editor-stop))
;; Ctrl+A: select all (key is "a" or "A" depending on platform)
(and ctrl? (= (str/lower key) "a"))
(do
(dom/prevent-default event)
(text-editor/text-editor-select-all)
(wasm.api/request-render "text-select-all"))
;; Enter
(= key "Enter")
(do
(dom/prevent-default event)
(text-editor/text-editor-insert-paragraph)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-paragraph"))
;; Backspace
(= key "Backspace")
(do
(dom/prevent-default event)
(text-editor/text-editor-delete-backward)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-delete-backward"))
;; Delete
(= key "Delete")
(do
(dom/prevent-default event)
(text-editor/text-editor-delete-forward)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-delete-forward"))
;; Arrow keys
(= key "ArrowLeft")
(do
(dom/prevent-default event)
(text-editor/text-editor-move-cursor 0 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "ArrowRight")
(do
(dom/prevent-default event)
(text-editor/text-editor-move-cursor 1 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "ArrowUp")
(do
(dom/prevent-default event)
(text-editor/text-editor-move-cursor 2 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "ArrowDown")
(do
(dom/prevent-default event)
(text-editor/text-editor-move-cursor 3 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "Home")
(do
(dom/prevent-default event)
(text-editor/text-editor-move-cursor 4 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "End")
(do
(dom/prevent-default event)
(text-editor/text-editor-move-cursor 5 shift?)
(wasm.api/request-render "text-cursor-move"))
;; Let contenteditable handle text input via on-input
:else nil)))))
on-input
(mf/use-fn
(fn [^js event]
(js/console.log "event" event)
(let [native-event (.-nativeEvent event)
input-type (.-inputType native-event)
data (.-data native-event)]
;; Skip composition-related input events - composition-end handles those
(when (and (not @composing?)
(not= input-type "insertCompositionText"))
(when (and data (seq data))
(text-editor/text-editor-insert-text data)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-input"))
(when-let [node (mf/ref-val contenteditable-ref)]
(set! (.-textContent node) ""))))))
on-pointer-down
(mf/use-fn
(fn [^js event]
(js/console.log (.-type event))
(let [native-event (dom/event->native-event event)
off-pt (dom/get-offset-position native-event)]
(wasm.api/text-editor-pointer-down (.-x off-pt) (.-y off-pt)))))
on-pointer-move
(mf/use-fn
(fn [^js event]
(js/console.log (.-type event))
(let [native-event (dom/event->native-event event)
off-pt (dom/get-offset-position native-event)]
(wasm.api/text-editor-pointer-move (.-x off-pt) (.-y off-pt)))))
on-pointer-up
(mf/use-fn
(fn [^js event]
(js/console.log (.-type event))
(let [native-event (dom/event->native-event event)
off-pt (dom/get-offset-position native-event)]
(wasm.api/text-editor-pointer-up (.-x off-pt) (.-y off-pt)))))
on-click
(mf/use-fn
(fn [^js event]
(js/console.log (.-type event))
(let [native-event (dom/event->native-event event)
off-pt (dom/get-offset-position native-event)]
(wasm.api/text-editor-set-cursor-from-offset (.-x off-pt) (.-y off-pt)))))
on-focus
(mf/use-fn
(fn [^js event]
(js/console.log (.-type event) event)))
on-blur
(mf/use-fn
(fn [^js event]
(js/console.log (.-type event) event)
(sync-wasm-text-editor-content!)
(wasm.api/text-editor-stop)))
style #js {:pointerEvents "all"
"--editor-container-width" (dm/str width "px")
"--editor-container-height" (dm/str height "px")
"--fallback-families" (if (seq fallback-families) (dm/str (str/join ", " fallback-families)) "sourcesanspro")}]
;; Focus contenteditable on mount
(mf/use-effect
(mf/deps contenteditable-ref)
(fn []
(when-let [node (mf/ref-val contenteditable-ref)]
(js/console.log "focusing")
(.focus node))))
(mf/use-effect
(fn []
(let [timeout-id (atom nil)
schedule-blink (fn schedule-blink []
(when (text-editor/text-editor-is-active?)
(wasm.api/request-render "cursor-blink"))
(reset! timeout-id (js/setTimeout schedule-blink caret-blink-interval-ms)))]
(schedule-blink)
(fn []
(when @timeout-id
(js/clearTimeout @timeout-id))))))
;; Composition and input events
[:g.text-editor {:clip-path (dm/fmt "url(#%)" clip-id)
:transform (dm/str transform)
:data-testid "text-editor"}
[:defs
[:clipPath {:id clip-id}
[:rect {:x x :y y :width width :height height}]]]
[:foreignObject {:x x :y y :width width :height height}
[:div {:on-click on-click
:on-pointer-down on-pointer-down
:on-pointer-move on-pointer-move
:on-pointer-up on-pointer-up
:class (stl/css :text-editor)
:style style}
[:div
{:ref contenteditable-ref
:contentEditable true
:suppressContentEditableWarning true
:on-composition-start on-composition-start
:on-composition-end on-composition-end
:on-key-down on-key-down
:on-input on-input
:on-paste on-paste
:on-copy on-copy
:on-focus on-focus
:on-blur on-blur
;; FIXME on-click
;; :on-click on-click
:id "text-editor-wasm-input"
:class (dm/str (cur/get-dynamic "text" (:rotation shape))
" "
(stl/css :text-editor-container))
:data-testid "text-editor-container"}]]]]))

View File

@@ -0,0 +1,12 @@
.text-editor {
height: 100%;
}
.text-editor-container {
height: 100%;
position: absolute;
opacity: 0;
overflow: hidden;
white-space: pre;
}

View File

@@ -10,6 +10,7 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.math :as mth]
[app.common.types.component :as ctk]
[app.common.types.components-list :as ctkl]
[app.common.types.container :as ctn]
@@ -37,6 +38,8 @@
(defonce ^:private sidebar-hover-queue (atom {:enter #{} :leave #{}}))
(defonce ^:private sidebar-hover-pending? (atom false))
(def ^:const default-chunk-size 50)
(defn- schedule-sidebar-hover-flush []
(when (compare-and-set! sidebar-hover-pending? false true)
(ts/raf
@@ -48,12 +51,11 @@
(when (seq enter)
(apply st/emit! (map dw/highlight-shape enter))))))))
(mf/defc layer-item-inner
{::mf/wrap-props false}
[{:keys [item depth parent-size name-ref children ref style
(mf/defc layer-item-inner*
[{:keys [item depth parent-size name-ref children ref style rename-id
;; Flags
read-only? highlighted? selected? component-tree?
filtered? expanded? dnd-over? dnd-over-top? dnd-over-bot? hide-toggle?
is-read-only is-highlighted is-selected is-component-tree
is-filtered is-expanded dnd-over dnd-over-top dnd-over-bot hide-toggle
;; Callbacks
on-select-shape on-context-menu on-pointer-enter on-pointer-leave on-zoom-to-selected
on-toggle-collapse on-enable-drag on-disable-drag on-toggle-visibility on-toggle-blocking]}]
@@ -64,7 +66,7 @@
hidden? (:hidden item)
has-shapes? (-> item :shapes seq boolean)
touched? (-> item :touched seq boolean)
parent-board? (and (cfh/frame-shape? item)
root-board? (and (cfh/frame-shape? item)
(= uuid/zero (:parent-id item)))
absolute? (ctl/item-absolute? item)
is-variant? (ctk/is-variant? item)
@@ -73,9 +75,11 @@
variant-name (when is-variant? (:variant-name item))
variant-error (when is-variant? (:variant-error item))
data (deref refs/workspace-data)
component (ctkl/get-component data (:component-id item))
variant-properties (:variant-properties component)
component-id (get item :component-id)
data (mf/deref refs/workspace-data)
variant-properties (-> (ctkl/get-component data component-id)
(get :variant-properties))
icon-shape (usi/get-shape-icon item)]
[:*
@@ -85,30 +89,30 @@
:on-context-menu on-context-menu
:data-testid "layer-row"
:role "checkbox"
:aria-checked selected?
:aria-checked is-selected
:class (stl/css-case
:layer-row true
:highlight highlighted?
:highlight is-highlighted
:component (ctk/instance-head? item)
:masked (:masked-group item)
:selected selected?
:selected is-selected
:type-frame (cfh/frame-shape? item)
:type-bool (cfh/bool-shape? item)
:type-comp (or component-tree? is-variant-container?)
:type-comp (or is-component-tree is-variant-container?)
:hidden hidden?
:dnd-over dnd-over?
:dnd-over-top dnd-over-top?
:dnd-over-bot dnd-over-bot?
:root-board parent-board?)
:dnd-over dnd-over
:dnd-over-top dnd-over-top
:dnd-over-bot dnd-over-bot
:root-board root-board?)
:style style}
[:span {:class (stl/css-case
:tab-indentation true
:filtered filtered?)
:filtered is-filtered)
:style {"--depth" depth}}]
[:div {:class (stl/css-case
:element-list-body true
:filtered filtered?
:selected selected?
:filtered is-filtered
:selected is-selected
:icon-layer (= (:type item) :icon))
:style {"--depth" depth}
:on-pointer-enter on-pointer-enter
@@ -117,12 +121,12 @@
(if (< 0 (count (:shapes item)))
[:div {:class (stl/css :button-content)}
(when (and (not hide-toggle?) (not filtered?))
(when (and (not hide-toggle) (not is-filtered))
[:button {:class (stl/css-case
:toggle-content true
:inverse expanded?)
:inverse is-expanded)
:data-testid "toggle-content"
:aria-expanded expanded?
:aria-expanded is-expanded
:on-click on-toggle-collapse}
deprecated-icon/arrow])
@@ -133,7 +137,7 @@
[:> icon* {:icon-id icon-shape :size "s" :data-testid (str "icon-" icon-shape)}]]]
[:div {:class (stl/css :button-content)}
(when (not ^boolean filtered?)
(when (not ^boolean is-filtered)
[:span {:class (stl/css :toggle-content)}])
[:div {:class (stl/css :icon-shape)
:on-double-click on-zoom-to-selected}
@@ -142,25 +146,26 @@
[:> icon* {:icon-id icon-shape :size "s" :data-testid (str "icon-" icon-shape)}]]])
[:> layer-name* {:ref name-ref
:rename-id rename-id
:shape-id id
:shape-name name
:is-shape-touched touched?
:disabled-double-click read-only?
:disabled-double-click is-read-only
:on-start-edit on-disable-drag
:on-stop-edit on-enable-drag
:depth depth
:is-blocked blocked?
:parent-size parent-size
:is-selected selected?
:type-comp (or component-tree? is-variant-container?)
:is-selected is-selected
:type-comp (or is-component-tree is-variant-container?)
:type-frame (cfh/frame-shape? item)
:variant-id variant-id
:variant-name variant-name
:variant-properties variant-properties
:variant-error variant-error
:component-id (:id component)
:component-id component-id
:is-hidden hidden?}]]
(when (not read-only?)
(when (not ^boolean is-read-only)
[:div {:class (stl/css-case
:element-actions true
:is-parent has-shapes?
@@ -185,41 +190,86 @@
children]))
;; Memoized for performance
(mf/defc layer-item
{::mf/props :obj
::mf/wrap [mf/memo]}
[{:keys [index item selected objects sortable? filtered? depth parent-size component-child? highlighted style render-children?]
:or {render-children? true}}]
(let [id (:id item)
blocked? (:blocked item)
hidden? (:hidden item)
(mf/defc layer-item*
{::mf/wrap [mf/memo]}
[{:keys [index item selected objects rename-id
is-sortable is-filtered depth is-component-child
highlighted style render-children parent-size]
:or {render-children true}}]
(let [id (get item :id)
blocked? (get item :blocked)
hidden? (get item :hidden)
shapes (get item :shapes)
shapes (mf/with-memo [shapes objects]
(loop [counter 0
shapes (seq shapes)
result (list)]
(if-let [id (first shapes)]
(if-let [obj (get objects id)]
(do
;; NOTE: this is a bit hacky, but reduces substantially
;; the allocation; If we use enumeration, we allocate
;; new sequence and add one iteration on each render,
;; independently if objects are changed or not. If we
;; store counter on metadata, we still need to create a
;; new allocation for each shape; with this method we
;; bypass this by mutating a private property on the
;; object removing extra allocation and extra iteration
;; on every request.
(unchecked-set obj "__$__counter" counter)
(recur (inc counter)
(rest shapes)
(conj result obj)))
(recur (inc counter)
(rest shapes)
result))
(-> result vec not-empty))))
drag-disabled* (mf/use-state false)
drag-disabled? (deref drag-disabled*)
scroll-to-middle? (mf/use-var true)
scroll-middle-ref (mf/use-ref true)
expanded-iref (mf/with-memo [id]
(-> (l/in [:expanded id])
(l/derived refs/workspace-local)))
expanded? (mf/deref expanded-iref)
(l/derived #(dm/get-in % [:expanded id]) refs/workspace-local))
is-expanded (mf/deref expanded-iref)
selected? (contains? selected id)
highlighted? (contains? highlighted id)
is-selected (contains? selected id)
is-highlighted (contains? highlighted id)
container? (or (cfh/frame-shape? item)
(cfh/group-shape? item))
read-only? (mf/use-ctx ctx/workspace-read-only?)
parent-board? (and (cfh/frame-shape? item)
is-read-only (mf/use-ctx ctx/workspace-read-only?)
root-board? (and (cfh/frame-shape? item)
(= uuid/zero (:parent-id item)))
name-node-ref (mf/use-ref)
depth (+ depth 1)
is-component-tree (or ^boolean is-component-child
^boolean (ctk/instance-root? item)
^boolean (ctk/instance-head? item))
enable-drag (mf/use-fn #(reset! drag-disabled* false))
disable-drag (mf/use-fn #(reset! drag-disabled* true))
;; Lazy loading of child elements via IntersectionObserver
children-count* (mf/use-state 0)
children-count (deref children-count*)
lazy-ref (mf/use-ref nil)
observer-ref (mf/use-ref nil)
toggle-collapse
(mf/use-fn
(mf/deps expanded?)
(mf/deps is-expanded)
(fn [event]
(dom/stop-propagation event)
(if (and expanded? (kbd/shift? event))
(if (and is-expanded (kbd/shift? event))
(st/emit! (dwc/collapse-all))
(st/emit! (dwc/toggle-collapse id)))))
@@ -244,13 +294,13 @@
select-shape
(mf/use-fn
(mf/deps id filtered? objects)
(mf/deps id is-filtered objects)
(fn [event]
(dom/prevent-default event)
(reset! scroll-to-middle? false)
(mf/set-ref-val! scroll-middle-ref false)
(cond
(kbd/shift? event)
(if filtered?
(if is-filtered
(st/emit! (dw/shift-select-shapes id objects))
(st/emit! (dw/shift-select-shapes id)))
@@ -285,11 +335,11 @@
on-context-menu
(mf/use-fn
(mf/deps item read-only?)
(mf/deps item is-read-only)
(fn [event]
(dom/prevent-default event)
(dom/stop-propagation event)
(when-not read-only?
(when-not is-read-only
(let [pos (dom/get-client-position event)]
(st/emit! (dw/show-shape-context-menu {:position pos :shape item}))))))
@@ -302,7 +352,7 @@
on-drop
(mf/use-fn
(mf/deps id objects expanded? selected)
(mf/deps id objects is-expanded selected)
(fn [side _data]
(let [single? (= (count selected) 1)
same? (and single? (= (first selected) id))]
@@ -315,32 +365,34 @@
(= side :center)
id
(and expanded? (= side :bot) (d/not-empty? (:shapes shape)))
(and is-expanded (= side :bot) (d/not-empty? (:shapes shape)))
id
:else
(cfh/get-parent-id objects id))
[parent-id _] (ctn/find-valid-parent-and-frame-ids parent-id objects (map #(get objects %) selected) false files)
[parent-id _]
(ctn/find-valid-parent-and-frame-ids parent-id objects (map #(get objects %) selected) false files)
parent (get objects parent-id)
current-index (d/index-of (:shapes parent) id)
to-index (cond
(= side :center) 0
(and expanded? (= side :bot) (d/not-empty? (:shapes shape))) (count (:shapes parent))
(and is-expanded (= side :bot) (d/not-empty? (:shapes shape))) (count (:shapes parent))
;; target not found in parent (while lazy loading)
(neg? current-index) nil
(= side :top) (inc current-index)
:else current-index)]
(when (some? to-index)
(st/emit! (dw/relocate-selected-shapes parent-id to-index))))))))
on-hold
(mf/use-fn
(mf/deps id expanded?)
(mf/deps id is-expanded)
(fn []
(when-not expanded?
(when-not is-expanded
(st/emit! (dwc/toggle-collapse id)))))
zoom-to-selected
@@ -361,112 +413,114 @@
:data {:id (:id item)
:index index
:name (:name item)}
:draggable? (and
sortable?
(not read-only?)
(not (ctn/has-any-copy-parent? objects item)))) ;; We don't want to change the structure of component copies
;; We don't want to change the structure of component copies
:draggable? (and ^boolean is-sortable
^boolean (not is-read-only)
^boolean (not (ctn/has-any-copy-parent? objects item))))]
ref (mf/use-ref)
depth (+ depth 1)
component-tree? (or component-child? (ctk/instance-root? item) (ctk/instance-head? item))
enable-drag (mf/use-fn #(reset! drag-disabled* false))
disable-drag (mf/use-fn #(reset! drag-disabled* true))
;; Lazy loading of child elements via IntersectionObserver
children-count* (mf/use-state 0)
children-count (deref children-count*)
lazy-ref (mf/use-ref nil)
observer-var (mf/use-var nil)
chunk-size 50]
(mf/with-effect [selected? selected]
(mf/with-effect [is-selected selected]
(let [single? (= (count selected) 1)
node (mf/ref-val ref)
scroll-node (dom/get-parent-with-data node "scroll-container")
parent-node (dom/get-parent-at node 2)
first-child-node (dom/get-first-child parent-node)
node (mf/ref-val name-node-ref)
scroll-node (dom/get-parent-with-data node "scroll-container")
parent-node (dom/get-parent-at node 2)
first-child-node (dom/get-first-child parent-node)
scroll-to-middle? (mf/ref-val scroll-middle-ref)
subid
(when (and single? selected? @scroll-to-middle?)
(when (and ^boolean single?
^boolean is-selected
^boolean scroll-to-middle?)
(ts/schedule
100
#(when (and node scroll-node)
(let [scroll-distance-ratio (dom/get-scroll-distance-ratio node scroll-node)
scroll-behavior (if (> scroll-distance-ratio 1) "instant" "smooth")]
(dom/scroll-into-view-if-needed! first-child-node #js {:block "center" :behavior scroll-behavior :inline "start"})
(reset! scroll-to-middle? true)))))]
(mf/set-ref-val! scroll-middle-ref true)))))]
#(when (some? subid)
(rx/dispose! subid))))
;; Setup scroll-driven lazy loading when expanded
;; and ensures selected children are loaded immediately
(mf/with-effect [expanded? (:shapes item) selected]
(let [shapes-vec (:shapes item)
total (count shapes-vec)]
(if expanded?
(mf/with-effect [is-expanded shapes selected]
(let [total (count shapes)]
(if ^boolean is-expanded
(let [;; Children are rendered in reverse order, so index 0 in render = last in shapes-vec
;; Find if any selected id is a direct child and get its render index
selected-child-render-idx
(when (and (> total chunk-size) (seq selected))
(let [shapes-reversed (vec (reverse shapes-vec))]
(some (fn [sel-id]
(let [idx (.indexOf shapes-reversed sel-id)]
(when (>= idx 0) idx)))
selected)))
(when (> total default-chunk-size)
(some (fn [sel-id]
(let [idx (.indexOf shapes sel-id)]
(when (>= idx 0) idx)))
selected))
;; Load at least enough to include the selected child plus extra
;; for context (so it can be centered in the scroll view)
min-count (if selected-child-render-idx
(+ selected-child-render-idx chunk-size)
chunk-size)
current @children-count*
new-count (min total (max current chunk-size min-count))]
min-count
(if selected-child-render-idx
(+ selected-child-render-idx default-chunk-size)
default-chunk-size)
current-count
@children-count*
new-count
(mth/min total (mth/max current-count default-chunk-size min-count))]
(reset! children-count* new-count))
(reset! children-count* 0))))
(reset! children-count* 0))
(fn []
(when-let [obs (mf/ref-val observer-ref)]
(.disconnect obs)
(mf/set-ref-val! obs nil)))))
;; Re-observe sentinel whenever children-count changes (sentinel moves)
;; and (shapes item) to reconnect observer after shape changes
(mf/with-effect [children-count expanded? (:shapes item)]
(let [total (count (:shapes item))
node (mf/ref-val ref)
scroll-node (dom/get-parent-with-data node "scroll-container")
lazy-node (mf/ref-val lazy-ref)]
(mf/with-effect [children-count is-expanded shapes]
(let [total (count shapes)
name-node (mf/ref-val name-node-ref)
scroll-node (dom/get-parent-with-data name-node "scroll-container")
lazy-node (mf/ref-val lazy-ref)]
;; Disconnect previous observer
(when-let [obs ^js @observer-var]
(when-let [obs (mf/ref-val observer-ref)]
(.disconnect obs)
(reset! observer-var nil))
(mf/set-ref-val! observer-ref nil))
;; Setup new observer if there are more children to load
(when (and expanded?
(< children-count total)
scroll-node
lazy-node)
(when (and ^boolean is-expanded
^boolean (< children-count total)
^boolean scroll-node
^boolean lazy-node)
(let [cb (fn [entries]
(when (and (seq entries)
(.-isIntersecting (first entries)))
(when (and (pos? (alength entries))
(.-isIntersecting ^js (aget entries 0)))
;; Load next chunk when sentinel intersects
(let [current @children-count*
next-count (min total (+ current chunk-size))]
(let [next-count (mth/min total (+ children-count default-chunk-size))]
(reset! children-count* next-count))))
observer (js/IntersectionObserver. cb #js {:root scroll-node})]
(.observe observer lazy-node)
(reset! observer-var observer)))))
(mf/set-ref-val! observer-ref observer)))))
[:& layer-item-inner
[:> layer-item-inner*
{:ref dref
:item item
:depth depth
:parent-size parent-size
:name-ref ref
:read-only? read-only?
:highlighted? highlighted?
:selected? selected?
:component-tree? component-tree?
:filtered? filtered?
:expanded? expanded?
:dnd-over? (= (:over dprops) :center)
:dnd-over-top? (= (:over dprops) :top)
:dnd-over-bot? (= (:over dprops) :bot)
:name-ref name-node-ref
:rename-id rename-id
:is-read-only is-read-only
:is-highlighted is-highlighted
:is-selected is-selected
:is-component-tree is-component-tree
:is-filtered is-filtered
:is-expanded is-expanded
:dnd-over (= (:over dprops) :center)
:dnd-over-top (= (:over dprops) :top)
:dnd-over-bot (= (:over dprops) :bot)
:on-select-shape select-shape
:on-context-menu on-context-menu
:on-pointer-enter on-pointer-enter
@@ -479,29 +533,28 @@
:on-toggle-blocking toggle-blocking
:style style}
(when (and render-children?
(:shapes item)
expanded?)
(when (and ^boolean render-children
^boolean shapes
^boolean is-expanded)
[:div {:class (stl/css-case
:element-children true
:parent-selected selected?
:sticky-children parent-board?)
:parent-selected is-selected
:sticky-children root-board?)
:data-testid (dm/str "children-" id)}
(let [all-children (reverse (d/enumerate (:shapes item)))
visible (take children-count all-children)]
(for [[index id] visible]
(when-let [item (get objects id)]
[:& layer-item
{:item item
:highlighted highlighted
:selected selected
:index index
:objects objects
:key (dm/str id)
:sortable? sortable?
:depth depth
:parent-size parent-size
:component-child? component-tree?}])))
(when (< children-count (count (:shapes item)))
(for [item (take children-count shapes)]
[:> layer-item*
{:item item
:rename-id rename-id
:highlighted highlighted
:selected selected
:index (unchecked-get item "__$__counter")
:objects objects
:key (dm/str (get item :id))
:is-sortable is-sortable
:depth depth
:parent-size parent-size
:is-component-child is-component-tree}])
(when (< children-count (count shapes))
[:div {:ref lazy-ref
:class (stl/css :lazy-load-sentinel)}])])]))

View File

@@ -16,39 +16,35 @@
[app.util.dom :as dom]
[app.util.keyboard :as kbd]
[cuerdas.core :as str]
[okulary.core :as l]
[rumext.v2 :as mf]))
(def ^:private space-for-icons 110)
(def lens:shape-for-rename
(-> (l/in [:workspace-local :shape-for-rename])
(l/derived st/state)))
(def ^:private ^:const space-for-icons 110)
(mf/defc layer-name*
{::mf/wrap-props false
::mf/forward-ref true}
[{:keys [shape-id shape-name is-shape-touched disabled-double-click
[{:keys [shape-id rename-id shape-name is-shape-touched disabled-double-click
on-start-edit on-stop-edit depth parent-size is-selected
type-comp type-frame component-id is-hidden is-blocked
variant-id variant-name variant-properties variant-error]} external-ref]
variant-id variant-name variant-properties variant-error ref]}]
(let [edition* (mf/use-state false)
edition? (deref edition*)
local-ref (mf/use-ref)
ref (d/nilv external-ref local-ref)
ref (d/nilv ref local-ref)
shape-for-rename (mf/deref lens:shape-for-rename)
shape-name
(if variant-id
(d/nilv variant-error variant-name)
shape-name)
shape-name (if variant-id
(d/nilv variant-error variant-name)
shape-name)
default-value
(mf/with-memo [variant-id variant-error variant-properties]
(if variant-id
(or variant-error (ctv/properties-map->formula variant-properties))
shape-name))
default-value (if variant-id
(or variant-error (ctv/properties-map->formula variant-properties))
shape-name)
has-path? (str/includes? shape-name "/")
has-path?
(str/includes? shape-name "/")
start-edit
(mf/use-fn
@@ -85,10 +81,11 @@
(when (kbd/enter? event) (accept-edit))
(when (kbd/esc? event) (cancel-edit))))
parent-size (dm/str (- parent-size space-for-icons) "px")]
parent-size
(dm/str (- parent-size space-for-icons) "px")]
(mf/with-effect [shape-for-rename edition? start-edit shape-id]
(when (and (= shape-for-rename shape-id)
(mf/with-effect [rename-id edition? start-edit shape-id]
(when (and (= rename-id shape-id)
(not ^boolean edition?))
(start-edit)))
@@ -110,21 +107,24 @@
:auto-focus true
:id (dm/str "layer-name-" shape-id)
:default-value (d/nilv default-value "")}]
[:*
[:span
{:class (stl/css-case
:element-name true
:left-ellipsis has-path?
:selected is-selected
:hidden is-hidden
:type-comp type-comp
:type-frame type-frame)
:id (dm/str "layer-name-" shape-id)
:style {"--depth" depth "--parent-size" parent-size}
:ref ref
:on-double-click start-edit}
(if (dbg/enabled? :show-ids)
(str (d/nilv shape-name "") " | " (str/slice (str shape-id) 24))
[:span {:class (stl/css-case
:element-name true
:left-ellipsis has-path?
:selected is-selected
:hidden is-hidden
:type-comp type-comp
:type-frame type-frame)
:id (dm/str "layer-name-" shape-id)
:style {"--depth" depth "--parent-size" parent-size}
:ref ref
:on-double-click start-edit}
(if ^boolean (dbg/enabled? :show-ids)
(dm/str (d/nilv shape-name "") " | " (str/slice (str shape-id) 24))
(d/nilv shape-name ""))]
(when (and (dbg/enabled? :show-touched) ^boolean is-shape-touched)
(when (and ^boolean (dbg/enabled? :show-touched)
^boolean is-shape-touched)
[:span {:class (stl/css :element-name-touched)} "*"])])))

View File

@@ -21,7 +21,7 @@
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.hooks :as hooks]
[app.main.ui.notifications.badge :refer [badge-notification]]
[app.main.ui.workspace.sidebar.layer-item :refer [layer-item]]
[app.main.ui.workspace.sidebar.layer-item :refer [layer-item*]]
[app.util.dom :as dom]
[app.util.globals :as globals]
[app.util.i18n :as i18n :refer [tr]]
@@ -31,92 +31,160 @@
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[goog.events :as events]
[rumext.v2 :as mf])
(:import
goog.events.EventType))
[okulary.core :as l]
[rumext.v2 :as mf]))
(def ^:private ref:highlighted-shapes
(l/derived (fn [local]
(-> local
(get :highlighted)
(not-empty)))
refs/workspace-local))
(def ^:private ref:shape-for-rename
(l/derived (l/key :shape-for-rename) refs/workspace-local))
(defn- use-selected-shapes
"A convencience hook wrapper for get selected shapes"
[]
(let [selected (mf/deref refs/selected-shapes)]
(hooks/use-equal-memo selected)))
;; This components is a piece for sharding equality check between top
;; level frames and try to avoid rerender frames that are does not
;; affected by the selected set.
(mf/defc frame-wrapper
{::mf/props :obj}
(mf/defc frame-wrapper*
[{:keys [selected] :as props}]
(let [pending-selected (mf/use-var selected)
current-selected (mf/use-state selected)
props (mf/spread-object props {:selected @current-selected})
(let [pending-selected-ref
(mf/use-ref selected)
current-selected
(mf/use-state selected)
props
(mf/spread-object props {:selected @current-selected})
set-selected
(mf/use-memo
(fn []
(throttle-fn
50
#(when-let [pending-selected @pending-selected]
(reset! current-selected pending-selected)))))]
(mf/with-memo []
(throttle-fn 50 #(when-let [pending-selected (mf/ref-val pending-selected-ref)]
(reset! current-selected pending-selected))))]
(mf/with-effect [selected set-selected]
(reset! pending-selected selected)
(set-selected)
(mf/set-ref-val! pending-selected-ref selected)
(^function set-selected)
(fn []
(reset! pending-selected nil)
#(rx/dispose! set-selected)))
(mf/set-ref-val! pending-selected-ref nil)
(rx/dispose! set-selected)))
[:> layer-item props]))
[:> layer-item* props]))
(mf/defc layers-tree*
{::mf/wrap [mf/memo]}
[{:keys [objects is-filtered parent-size] :as props}]
(let [selected (use-selected-shapes)
highlighted (mf/deref ref:highlighted-shapes)
root (get objects uuid/zero)
rename-id (mf/deref ref:shape-for-rename)
shapes (get root :shapes)
shapes (mf/with-memo [shapes objects]
(loop [counter 0
shapes (seq shapes)
result (list)]
(if-let [id (first shapes)]
(if-let [obj (get objects id)]
(do
;; NOTE: this is a bit hacky, but reduces substantially
;; the allocation; If we use enumeration, we allocate
;; new sequence and add one iteration on each render,
;; independently if objects are changed or not. If we
;; store counter on metadata, we still need to create a
;; new allocation for each shape; with this method we
;; bypass this by mutating a private property on the
;; object removing extra allocation and extra iteration
;; on every request.
(unchecked-set obj "__$__counter" counter)
(recur (inc counter)
(rest shapes)
(conj result obj)))
(recur (inc counter)
(rest shapes)
result))
result)))]
(mf/defc layers-tree
{::mf/wrap [mf/memo #(mf/throttle % 200)]
::mf/wrap-props false}
[{:keys [objects filtered? parent-size] :as props}]
(let [selected (mf/deref refs/selected-shapes)
selected (hooks/use-equal-memo selected)
highlighted (mf/deref refs/highlighted-shapes)
highlighted (hooks/use-equal-memo highlighted)
root (get objects uuid/zero)]
[:div {:class (stl/css :element-list) :data-testid "layer-item"}
[:> hooks/sortable-container* {}
(for [[index id] (reverse (d/enumerate (:shapes root)))]
(when-let [obj (get objects id)]
(if (cfh/frame-shape? obj)
[:& frame-wrapper
{:item obj
:selected selected
:highlighted highlighted
:index index
:objects objects
:key id
:sortable? true
:filtered? filtered?
:parent-size parent-size
:depth -1}]
[:& layer-item
{:item obj
:selected selected
:highlighted highlighted
:index index
:objects objects
:key id
:sortable? true
:filtered? filtered?
:depth -1
:parent-size parent-size}])))]]))
(for [obj shapes]
(if (cfh/frame-shape? obj)
[:> frame-wrapper*
{:item obj
:rename-id rename-id
:selected selected
:highlighted highlighted
:index (unchecked-get obj "__$__counter")
:objects objects
:key (dm/str (get obj :id))
:is-sortable true
:is-filtered is-filtered
:parent-size parent-size
:depth -1}]
[:> layer-item*
{:item obj
:rename-id rename-id
:selected selected
:highlighted highlighted
:index (unchecked-get obj "__$__counter")
:objects objects
:key (dm/str (get obj :id))
:is-sortable true
:is-filtered is-filtered
:depth -1
:parent-size parent-size}]))]]))
(mf/defc filters-tree
{::mf/wrap [mf/memo #(mf/throttle % 200)]
::mf/wrap-props false}
(mf/defc layers-tree-wrapper*
{::mf/private true}
[{:keys [objects] :as props}]
;; This is a performance sensitive componet, so we use lower-level primitives for
;; reduce residual allocation for this specific case
(let [state-tmp (mf/useState objects)
objects' (aget state-tmp 0)
set-objects (aget state-tmp 1)
subject-s (mf/with-memo []
(rx/subject))
changes-s (mf/with-memo [subject-s]
(->> subject-s
(rx/debounce 500)))
props (mf/spread-props props {:objects objects'})]
(mf/with-effect [objects subject-s]
(rx/push! subject-s objects))
(mf/with-effect [changes-s]
(let [sub (rx/subscribe changes-s set-objects)]
#(rx/dispose! sub)))
[:> layers-tree* props]))
(mf/defc filters-tree*
{::mf/wrap [mf/memo #(mf/throttle % 300)]
::mf/private true}
[{:keys [objects parent-size]}]
(let [selected (mf/deref refs/selected-shapes)
selected (hooks/use-equal-memo selected)
root (get objects uuid/zero)]
(let [selected (use-selected-shapes)
root (get objects uuid/zero)]
[:ul {:class (stl/css :element-list)}
(for [[index id] (d/enumerate (:shapes root))]
(when-let [obj (get objects id)]
[:& layer-item
[:> layer-item*
{:item obj
:selected selected
:index index
:objects objects
:key id
:sortable? false
:filtered? true
:is-sortable false
:is-filtered true
:depth -1
:parent-size parent-size}]))]))
@@ -132,6 +200,7 @@
keys
(filter #(not= uuid/zero %))
vec)]
(update reparented-objects uuid/zero assoc :shapes reparented-shapes)))
;; --- Layers Toolbox
@@ -277,9 +346,11 @@
(swap! state* update :num-items + 100))))]
(mf/with-effect []
(let [keys [(events/listen globals/document EventType.KEYDOWN on-key-down)
(events/listen globals/document EventType.CLICK hide-menu)]]
(fn [] (doseq [key keys] (events/unlistenByKey key)))))
(let [key1 (events/listen globals/document "keydown" on-key-down)
key2 (events/listen globals/document "click" hide-menu)]
(fn []
(events/unlistenByKey key1)
(events/unlistenByKey key2))))
[filtered-objects
handle-show-more
@@ -464,6 +535,8 @@
{::mf/wrap [mf/memo]}
[{:keys [size-parent]}]
(let [page (mf/deref refs/workspace-page)
page-id (get page :id)
focus (mf/deref refs/workspace-focus-selected)
objects (hooks/with-focus-objects (:objects page) focus)
@@ -473,7 +546,8 @@
observer-var (mf/use-var nil)
lazy-load-ref (mf/use-ref nil)
[filtered-objects show-more filter-component] (use-search page objects)
[filtered-objects show-more filter-component]
(use-search page objects)
intersection-callback
(fn [entries]
@@ -519,25 +593,25 @@
[:div {:class (stl/css :tool-window-content)
:data-scroll-container true
:ref on-render-container}
[:& filters-tree {:objects filtered-objects
:key (dm/str (:id page))
:parent-size size-parent}]
[:> filters-tree* {:objects filtered-objects
:key (dm/str page-id)
:parent-size size-parent}]
[:div {:ref lazy-load-ref}]]
[:div {:on-scroll on-scroll
:class (stl/css :tool-window-content)
:data-scroll-container true
:style {:display (when (some? filtered-objects) "none")}}
[:& layers-tree {:objects filtered-objects
:key (dm/str (:id page))
:filtered? true
:parent-size size-parent}]]]
[:> layers-tree-wrapper* {:objects filtered-objects
:key (dm/str page-id)
:is-filtered true
:parent-size size-parent}]]]
[:div {:on-scroll on-scroll
:class (stl/css :tool-window-content)
:data-scroll-container true
:style {:display (when (some? filtered-objects) "none")}}
[:& layers-tree {:objects objects
:key (dm/str (:id page))
:filtered? false
:parent-size size-parent}]])]))
[:> layers-tree-wrapper* {:objects objects
:key (dm/str page-id)
:is-filtered false
:parent-size size-parent}]])]))

View File

@@ -92,6 +92,19 @@
(def ^:private xf:map-type (map :type))
(def ^:private xf:mapcat-type-to-options (mapcat type->options))
(defn fixed-decimal-value
"Fixes the amount of decimals that are kept"
([value]
(fixed-decimal-value value 2))
([value decimals]
(cond
(string? value)
(fixed-decimal-value (parse-double value) decimals)
(number? value)
(parse-double (.toFixed value decimals)))))
(mf/defc measures-menu*
[{:keys [ids values applied-tokens type shapes]}]
(let [token-numeric-inputs
@@ -300,7 +313,7 @@
(mf/deps ids)
(fn [value]
(if (or (string? value) (number? value))
(do
(let [value (fixed-decimal-value value)]
(st/emit! (udw/trigger-bounding-box-cloaking ids))
(st/emit! (udw/increase-rotation ids value)))
(st/emit! (udw/trigger-bounding-box-cloaking ids)

View File

@@ -75,7 +75,11 @@
is-type-unfolded (contains? (set unfolded-token-paths) (name type))
editing-ref (mf/deref refs/workspace-editor-state)
not-editing? (empty? editing-ref)
edition (mf/deref refs/selected-edition)
objects (mf/deref refs/workspace-page-objects)
not-editing? (and (empty? editing-ref)
(not (and (some? edition)
(= :text (:type (get objects edition))))))
can-edit?
(mf/use-ctx ctx/can-edit?)

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))))
@@ -95,17 +95,7 @@
::dwsp/interrupt)
(when (and (not= edition id) (or text-editing? grid-editing?))
(st/emit! (dw/clear-edition-mode))
;; 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)))
(st/emit! (dw/clear-edition-mode)))
(when (and (not text-editing?)
(not blocked)
@@ -187,10 +177,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 +192,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 +204,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 +255,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 +310,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 +385,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 +396,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

@@ -55,7 +55,7 @@
(defn top?
[cur cand]
(let [closey? (mth/close? (:y cand) (:y cur))]
(let [closey? (mth/close? (:y cand) (:y cur) 0.01)]
(cond
(and closey? (< (:x cand) (:x cur))) cand
closey? cur
@@ -64,13 +64,19 @@
(defn right?
[cur cand]
(let [closex? (mth/close? (:x cand) (:x cur))]
(let [closex? (mth/close? (:x cand) (:x cur) 0.01)]
(cond
(and closex? (< (:y cand) (:y cur))) cand
closex? cur
(> (:x cand) (:x cur)) cand
:else cur)))
(defn title-transform-use-width?
[{:keys [rotation] :as shape}]
(let [side (mth/ceil (/ (- rotation 45) 90))
use-width? (even? side)]
use-width?))
(defn title-transform
[{:keys [points] :as shape} zoom grid-edition?]
(let [leftmost (->> points (reduce left?))

View File

@@ -129,13 +129,15 @@
(fn [_]
(on-frame-leave (:id frame))))
main-instance? (ctk/main-instance? frame)
is-variant? (:is-variant-container frame)
main-instance? (ctk/main-instance? frame)
is-variant? (:is-variant-container frame)
text-width (* (:width frame) zoom)
show-icon? (and (or (:use-for-thumbnail frame) is-grid-edition main-instance? is-variant?)
(not (<= text-width 15)))
text-pos-x (if show-icon? 15 0)
use-width? (vwu/title-transform-use-width? frame)
text-width (* (if use-width? (:width frame) (:height frame)) zoom)
show-icon? (and (or (:use-for-thumbnail frame) is-grid-edition main-instance? is-variant?)
(not (<= text-width 15)))
text-pos-x (if show-icon? 15 0)
edition* (mf/use-state false)
edition? (deref edition*)
@@ -178,7 +180,6 @@
(when (kbd/enter? event) (accept-edit))
(when (kbd/esc? event) (cancel-edit))))]
(when (not (:hidden frame))
[:g.frame-title {:id (dm/str "frame-title-" (:id frame))
:data-edit-grid is-grid-edition
@@ -242,7 +243,7 @@
[{:keys [objects zoom selected focus is-show-artboard-names
on-frame-enter on-frame-leave on-frame-select]}]
(let [selected (or selected #{})
shapes (ctt/get-frames objects {:skip-copies? true})
shapes (ctt/get-frames objects {:skip-copies? true :ignore-index? true})
shapes (if (dbg/enabled? :shape-titles)
(into (set shapes)
(map (d/getf objects))

View File

@@ -30,6 +30,7 @@
[app.main.ui.workspace.shapes.text.editor :as editor-v1]
[app.main.ui.workspace.shapes.text.text-edition-outline :refer [text-edition-outline]]
[app.main.ui.workspace.shapes.text.v2-editor :as editor-v2]
[app.main.ui.workspace.shapes.text.v3-editor :as editor-v3]
[app.main.ui.workspace.top-toolbar :refer [top-toolbar*]]
[app.main.ui.workspace.viewport.actions :as actions]
[app.main.ui.workspace.viewport.comments :as comments]
@@ -54,7 +55,6 @@
[app.main.ui.workspace.viewport.viewport-ref :refer [create-viewport-ref]]
[app.main.ui.workspace.viewport.widgets :as widgets]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.text-editor-input :refer [text-editor-input]]
[app.util.debug :as dbg]
[app.util.text-editor :as ted]
[beicon.v2.core :as rx]
@@ -328,10 +328,14 @@
(mf/with-effect [show-text-editor? workspace-editor-state edition]
(let [active-editor-state (get workspace-editor-state edition)]
(js/console.log "show-text-editor?" show-text-editor?)
(when (and show-text-editor? active-editor-state)
(let [content (-> active-editor-state
(ted/get-editor-current-content)
(ted/export-content))]
(when-not (wasm.api/text-editor-is-active? edition)
(prn "hola")
(wasm.api/text-editor-start edition))
(wasm.api/use-shape edition)
(wasm.api/set-shape-text-content edition content)
(let [dimension (wasm.api/get-text-dimensions)]
@@ -417,14 +421,7 @@
(when picking-color?
[:> pixel-overlay/pixel-overlay-wasm* {:viewport-ref viewport-ref
:canvas-ref canvas-ref}])
;; WASM text editor contenteditable (must be outside SVG to work)
(when (and show-text-editor?
(features/active-feature? @st/state "text-editor-wasm/v1"))
[:& text-editor-input {:shape editing-shape
:zoom zoom
:vbox vbox}])]
:canvas-ref canvas-ref}])]
[:canvas {:id "render"
:data-testid "canvas-wasm-shapes"
@@ -471,14 +468,20 @@
[:g {:style {:pointer-events (if disable-events? "none" "auto")}}
;; Text editor handling:
;; - When text-editor-wasm/v1 is active, contenteditable is rendered in viewport-overlays (HTML DOM)
(when (and show-text-editor?
(not (features/active-feature? @st/state "text-editor-wasm/v1")))
(if (features/active-feature? @st/state "text-editor/v2")
(when show-text-editor?
(cond
(features/active-feature? @st/state "text-editor-wasm/v1")
[:& editor-v3/text-editor {:shape editing-shape
:canvas-ref canvas-ref
:ref text-editor-ref}]
(features/active-feature? @st/state "text-editor/v2")
[:& editor-v2/text-editor {:shape editing-shape
:canvas-ref canvas-ref
:ref text-editor-ref}]
[:& editor-v1/text-editor-svg {:shape editing-shape
:ref text-editor-ref}]))
:else [:& editor-v1/text-editor-svg {:shape editing-shape
:ref text-editor-ref}]))
(when show-frame-outline?
(let [outlined-frame-id

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

@@ -86,8 +86,13 @@
;; Re-export public text editor functions
(def text-editor-start text-editor/text-editor-start)
(def text-editor-stop text-editor/text-editor-stop)
(def text-editor-set-cursor-from-offset text-editor/text-editor-set-cursor-from-offset)
(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 +268,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 +985,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

@@ -22,11 +22,33 @@
(aget buffer 2)
(aget buffer 3)))))
(defn text-editor-set-cursor-from-offset
"Sets caret position from shape relative coordinates"
[x y]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_set_cursor_from_offset" x y)))
(defn text-editor-set-cursor-from-point
"Sets caret position from screen (canvas) coordinates"
[x y]
(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 +105,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

@@ -19,6 +19,8 @@
[rumext.v2 :as mf])
(:import goog.events.EventType))
(def caret-blink-interval-ms 250)
(defn- sync-wasm-text-editor-content!
"Sync WASM text editor content back to the shape via the standard
commit pipeline. Called after every text-modifying input."
@@ -54,18 +56,17 @@
(.focus node))
js/undefined))
;; Animation loop for cursor blink
(mf/use-effect
(fn []
(let [raf-id (atom nil)
animate (fn animate []
(when (text-editor/text-editor-is-active?)
(wasm.api/request-render "cursor-blink")
(reset! raf-id (js/requestAnimationFrame animate))))]
(animate)
(let [timeout-id (atom nil)
schedule-blink (fn schedule-blink []
(when (text-editor/text-editor-is-active?)
(wasm.api/request-render "cursor-blink"))
(reset! timeout-id (js/setTimeout schedule-blink caret-blink-interval-ms)))]
(schedule-blink)
(fn []
(when @raf-id
(js/cancelAnimationFrame @raf-id))))))
(when @timeout-id
(js/clearTimeout @timeout-id))))))
;; Document-level keydown handler for control keys
(mf/use-effect
@@ -202,6 +203,7 @@
on-input
(mf/use-fn
(fn [^js event]
(js/console.log "event" event)
(let [native-event (.-nativeEvent event)
input-type (.-inputType native-event)
data (.-data native-event)]
@@ -213,7 +215,17 @@
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-input"))
(when-let [node (mf/ref-val contenteditable-ref)]
(set! (.-textContent node) ""))))))]
(set! (.-textContent node) ""))))))
on-focus
(mf/use-fn
(fn [^js event]
(js/console.log (.-type event) event)))
on-blur
(mf/use-fn
(fn [^js event]
(js/console.log (.-type event) event)))]
[:div
{:ref contenteditable-ref
@@ -224,6 +236,8 @@
:on-input on-input
:on-paste on-paste
:on-copy on-copy
:on-focus on-focus
:on-blur on-blur
;; FIXME on-click
;; :on-click on-click
:id "text-editor-wasm-input"

View File

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

View File

@@ -1958,6 +1958,8 @@ export class SelectionController extends EventTarget {
this.startOffset === this.endOffset &&
this.endOffset === endNode.nodeValue?.length
) {
const paragraph = this.startParagraph;
setParagraphStyles(paragraph, newStyles);
const newTextSpan = createVoidTextSpan(newStyles);
this.endTextSpan.after(newTextSpan);
this.setSelection(newTextSpan.firstChild, 0, newTextSpan.firstChild, 0);

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",

86
render-wasm/Cargo.lock generated
View File

@@ -202,9 +202,9 @@ checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b"
[[package]]
name = "hashbrown"
version = "0.15.0"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "heck"
@@ -214,9 +214,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "indexmap"
version = "2.7.1"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown",
@@ -253,12 +253,6 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.161"
@@ -468,18 +462,27 @@ checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
[[package]]
name = "serde"
version = "1.0.210"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.210"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
@@ -500,11 +503,11 @@ dependencies = [
[[package]]
name = "serde_spanned"
version = "0.6.8"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1"
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
dependencies = [
"serde",
"serde_core",
]
[[package]]
@@ -515,9 +518,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "skia-bindings"
version = "0.87.0"
version = "0.93.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "704242769235d2ffe66a2a0a3002661262fc4af08d32807c362d7b0160ee703c"
checksum = "2359f7e30c9da3f322f8ca3d4ec0abbc12a40035ce758309db0cdab07b5d4476"
dependencies = [
"bindgen",
"cc",
@@ -532,13 +535,12 @@ dependencies = [
[[package]]
name = "skia-safe"
version = "0.87.0"
version = "0.93.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f7d94f3e7537c71ad4cf132eb26e3be8c8a886ed3649c4525c089041fc312b2"
checksum = "7f9e837ea9d531c9efee8f980bfcdb7226b21db0285b0c3171d8be745829f940"
dependencies = [
"base64",
"bitflags",
"lazy_static",
"percent-encoding",
"skia-bindings",
"skia-svg-macros",
@@ -579,38 +581,43 @@ dependencies = [
[[package]]
name = "toml"
version = "0.8.19"
version = "1.0.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e"
checksum = "c7614eaf19ad818347db24addfa201729cf2a9b6fdfd9eb0ab870fcacc606c0c"
dependencies = [
"serde",
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_edit",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "0.6.8"
version = "1.0.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41"
checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.22.22"
name = "toml_parser"
version = "1.0.9+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5"
checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
[[package]]
name = "unicode-ident"
version = "1.0.13"
@@ -775,12 +782,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.6.20"
version = "0.7.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b"
dependencies = [
"memchr",
]
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
[[package]]
name = "xattr"

View File

@@ -25,7 +25,7 @@ gl = "0.14.0"
glam = "0.24.2"
indexmap = "2.7.1"
macros = { path = "macros" }
skia-safe = { version = "0.87.0", default-features = false, features = [
skia-safe = { version = "0.93.1", default-features = false, features = [
"gl",
"svg",
"textlayout",

View File

@@ -10,7 +10,7 @@ fi
export BUILD_NAME="${BUILD_NAME:-render-wasm}"
export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"wasm32-unknown-emscripten"};
export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.87.0/skia-binaries-e551f334ad5cbdf43abf-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp.tar.gz"}
export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp.tar.gz"}
# 256 MB of initial heap to perform less
# initial calls to memory grow.

View File

@@ -11,7 +11,7 @@ fi
. ./_build_env
export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"wasm32-unknown-emscripten"};
export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.87.0/skia-binaries-e551f334ad5cbdf43abf-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp.tar.gz"}
export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp.tar.gz"}
ALLOWED_RULES="-D static_mut_refs"

View File

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

View File

@@ -356,7 +356,7 @@ impl Bounds {
}
pub fn from_rect(r: &Rect) -> Self {
let [nw, ne, se, sw] = r.to_quad();
let [nw, ne, se, sw] = r.to_quad(None);
Self::new(nw, ne, se, sw)
}

View File

@@ -477,30 +477,32 @@ pub fn debug_render_bool_paths(
paint.set_alpha_f(1.0);
paint.set_style(skia::PaintStyle::Stroke);
let mut path = skia::Path::default();
path.move_to((b.1.start.x as f32, b.1.start.y as f32));
match b.1.handles {
BezierHandles::Linear => {
path.line_to((b.1.end.x as f32, b.1.end.y as f32));
let path = {
let mut pb = skia::PathBuilder::new();
pb.move_to((b.1.start.x as f32, b.1.start.y as f32));
match b.1.handles {
BezierHandles::Linear => {
pb.line_to((b.1.end.x as f32, b.1.end.y as f32));
}
BezierHandles::Quadratic { handle } => {
pb.quad_to(
(handle.x as f32, handle.y as f32),
(b.1.end.x as f32, b.1.end.y as f32),
);
}
BezierHandles::Cubic {
handle_start,
handle_end,
} => {
pb.cubic_to(
(handle_start.x as f32, handle_start.y as f32),
(handle_end.x as f32, handle_end.y as f32),
(b.1.end.x as f32, b.1.end.y as f32),
);
}
}
BezierHandles::Quadratic { handle } => {
path.quad_to(
(handle.x as f32, handle.y as f32),
(b.1.end.x as f32, b.1.end.y as f32),
);
}
BezierHandles::Cubic {
handle_start,
handle_end,
} => {
path.cubic_to(
(handle_start.x as f32, handle_start.y as f32),
(handle_end.x as f32, handle_end.y as f32),
(b.1.end.x as f32, b.1.end.y as f32),
);
}
}
pb.detach()
};
canvas.draw_path(&path, &paint);
let mut v1 = b.1.normal(TValue::Parametric(1.0));

View File

@@ -701,7 +701,14 @@ impl RenderState {
canvas.translate(translation);
});
fills::render(self, shape, &shape.fills, antialias, SurfaceId::Current, outset);
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();
@@ -853,6 +860,8 @@ impl RenderState {
let text_content = text_content.new_bounds(shape.selrect());
let count_inner_strokes = shape.count_visible_inner_strokes();
// Erode the main text fill by 1px when there are inner strokes, to avoid a visible seam at the glyph edge.
let text_fill_inset = (count_inner_strokes > 0).then(|| 1.0 / self.get_scale());
let text_stroke_blur_outset =
Stroke::max_bounds_width(shape.visible_strokes(), false);
let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None);
@@ -879,6 +888,7 @@ impl RenderState {
Some(fills_surface_id),
None,
None,
text_fill_inset,
);
for stroke_paragraphs in stroke_paragraphs_list.iter_mut() {
@@ -891,6 +901,7 @@ impl RenderState {
None,
None,
text_stroke_blur_outset,
None,
);
}
} else {
@@ -929,6 +940,7 @@ impl RenderState {
text_drop_shadows_surface_id.into(),
Some(&shadow),
blur_filter.as_ref(),
None,
);
}
} else {
@@ -954,6 +966,7 @@ impl RenderState {
text_drop_shadows_surface_id.into(),
Some(shadow),
blur_filter.as_ref(),
None,
);
}
}
@@ -967,6 +980,7 @@ impl RenderState {
Some(fills_surface_id),
None,
blur_filter.as_ref(),
text_fill_inset,
);
// 3. Stroke drop shadows
@@ -991,6 +1005,7 @@ impl RenderState {
None,
blur_filter.as_ref(),
text_stroke_blur_outset,
None,
);
}
@@ -1016,6 +1031,7 @@ impl RenderState {
Some(innershadows_surface_id),
Some(shadow),
blur_filter.as_ref(),
None,
);
}
}
@@ -1644,14 +1660,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);
@@ -1666,7 +1678,53 @@ impl RenderState {
false,
Some(shadow.offset),
None,
Some(shadow.spread),
);
});
self.surfaces.canvas(SurfaceId::DropShadows).restore();
return;
}
// 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();
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);
// 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),
);
});
@@ -1676,7 +1734,7 @@ impl RenderState {
// 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. 8×): beyond that the
// 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 {
@@ -1685,23 +1743,18 @@ impl RenderState {
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);
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);
canvas.save_layer(&layer_rec);
}
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,
@@ -1710,16 +1763,13 @@ impl RenderState {
temp_surface,
temp_surface,
false,
Some(shadow.offset),
None,
Some(shadow.offset), // Offset is geometric
None,
Some(shadow.spread),
);
});
{
let canvas = state.surfaces.canvas(temp_surface);
canvas.restore();
}
state.surfaces.canvas(temp_surface).restore();
},
);

View File

@@ -59,15 +59,18 @@ fn draw_image_fill(
canvas.clip_rect(container, skia::ClipOp::Intersect, antialias);
}
Type::Circle => {
let mut oval_path = skia::Path::new();
oval_path.add_oval(container, None);
let oval_path = {
let mut pb = skia::PathBuilder::new();
pb.add_oval(container, None, None);
pb.detach()
};
canvas.clip_path(&oval_path, skia::ClipOp::Intersect, antialias);
}
shape_type @ (Type::Path(_) | Type::Bool(_)) => {
if let Some(path) = shape_type.path() {
if let Some(path_transform) = path_transform {
canvas.clip_path(
path.to_skia_path().transform(&path_transform),
&path.to_skia_path().make_transform(&path_transform),
skia::ClipOp::Intersect,
antialias,
);
@@ -111,25 +114,6 @@ pub fn render(
return;
}
// Image fills use draw_image_fill which needs render_state for GPU images
// and sampling options that get_fill_shader (used by merge_fills) lacks.
let has_image_fills = fills.iter().any(|f| matches!(f, Fill::Image(_)));
if has_image_fills {
let scale = render_state.get_scale().max(1e-6);
let inset = if has_inner_stroke(shape) {
Some(1.0 / scale)
} else {
None
};
for fill in fills.iter().rev() {
render_single_fill(render_state, shape, fill, antialias, surface_id, outset, inset);
}
return;
}
let mut paint = merge_fills(fills, shape.selrect);
paint.set_anti_alias(antialias);
let scale = render_state.get_scale().max(1e-6);
let inset = if has_inner_stroke(shape) {
Some(1.0 / scale)
@@ -137,6 +121,27 @@ pub fn render(
None
};
// Image fills use draw_image_fill which needs render_state for GPU images
// and sampling options that get_fill_shader (used by merge_fills) lacks.
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,
outset,
inset,
);
}
return;
}
let mut paint = merge_fills(fills, shape.selrect);
paint.set_anti_alias(antialias);
if let Some(image_filter) = shape.image_filter(1.) {
let bounds = image_filter.compute_fast_bounds(shape.selrect);
if filters::render_with_filter_surface(
@@ -239,6 +244,7 @@ fn render_single_fill(
);
}
#[allow(clippy::too_many_arguments)]
fn draw_single_fill_to_surface(
render_state: &mut RenderState,
shape: &Shape,

View File

@@ -24,7 +24,11 @@ pub fn render_overlay(zoom: f32, canvas: &skia::Canvas, shape: &Shape, shapes: S
cell.anchor + hv + vv,
cell.anchor + vv,
];
let polygon = skia::Path::polygon(&points, true, None, None);
let polygon = {
let mut pb = skia::PathBuilder::new();
pb.add_polygon(&points, true);
pb.detach()
};
canvas.draw_path(&polygon, &paint);
}
}

View File

@@ -47,7 +47,7 @@ pub fn render_stroke_inner_shadows(
Some(surface_id),
filter.as_ref(),
antialias,
None,
None, // Inner shadows don't use spread
)
}
}
@@ -154,6 +154,7 @@ pub fn render_text_shadows(
surface_id,
None,
blur_filter.as_ref(),
None,
);
for stroke_paragraphs in stroke_paragraphs_group.iter_mut() {
@@ -165,6 +166,7 @@ pub fn render_text_shadows(
surface_id,
None,
blur_filter.as_ref(),
None,
);
}

View File

@@ -41,8 +41,13 @@ fn draw_stroke_on_rect(
}
};
// By default just draw the rect. Only dotted inner/outer strokes need
// clipping to prevent the dotted pattern from appearing in wrong areas.
if let Some(clip_op) = stroke.clip_op() {
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
// Use a neutral layer (no extra paint) so opacity and filters
// come solely from the stroke paint. This avoids applying
// stroke alpha twice for dotted inner/outer strokes.
let layer_rec = skia::canvas::SaveLayerRec::default();
canvas.save_layer(&layer_rec);
match corners {
Some(radii) => {
@@ -81,10 +86,16 @@ fn draw_stroke_on_circle(
// By default just draw the circle. Only dotted inner/outer strokes need
// clipping to prevent the dotted pattern from appearing in wrong areas.
if let Some(clip_op) = stroke.clip_op() {
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
// Use a neutral layer (no extra paint) so opacity and filters
// come solely from the stroke paint. This avoids applying
// stroke alpha twice for dotted inner/outer strokes.
let layer_rec = skia::canvas::SaveLayerRec::default();
canvas.save_layer(&layer_rec);
let mut clip_path = skia::Path::new();
clip_path.add_oval(rect, None);
let clip_path = {
let mut pb = skia::PathBuilder::new();
pb.add_oval(rect, None, None);
pb.detach()
};
canvas.clip_path(&clip_path, clip_op, antialias);
canvas.draw_oval(stroke_rect, &paint);
canvas.restore();
@@ -153,8 +164,9 @@ fn draw_stroke_on_path(
blur: Option<&ImageFilter>,
antialias: bool,
) {
let mut skia_path = path.to_skia_path();
skia_path.transform(path_transform.unwrap_or(&Matrix::default()));
let skia_path = path
.to_skia_path()
.make_transform(path_transform.unwrap_or(&Matrix::default()));
let is_open = path.is_open();
@@ -174,15 +186,7 @@ fn draw_stroke_on_path(
}
}
handle_stroke_caps(
&mut skia_path,
stroke,
canvas,
is_open,
paint,
blur,
antialias,
);
handle_stroke_caps(&skia_path, stroke, canvas, is_open, paint, blur, antialias);
}
fn handle_stroke_cap(
@@ -224,7 +228,7 @@ fn handle_stroke_cap(
#[allow(clippy::too_many_arguments)]
fn handle_stroke_caps(
path: &mut skia::Path,
path: &skia::Path,
stroke: &Stroke,
canvas: &skia::Canvas,
is_open: bool,
@@ -232,8 +236,7 @@ fn handle_stroke_caps(
blur: Option<&ImageFilter>,
_antialias: bool,
) {
let mut points = vec![Point::default(); path.count_points()];
path.get_points(&mut points);
let mut points = path.points().to_vec();
// Curves can have duplicated points, so let's remove consecutive duplicated points
points.dedup();
let c_points = points.len();
@@ -304,13 +307,16 @@ fn draw_square_cap(
let mut transformed_points = points;
matrix.map_points(&mut transformed_points, &points);
let mut path = skia::Path::new();
path.move_to(Point::new(center.x, center.y));
path.move_to(transformed_points[0]);
path.line_to(transformed_points[1]);
path.line_to(transformed_points[2]);
path.line_to(transformed_points[3]);
path.close();
let path = {
let mut pb = skia::PathBuilder::new();
pb.move_to(Point::new(center.x, center.y));
pb.move_to(transformed_points[0]);
pb.line_to(transformed_points[1]);
pb.line_to(transformed_points[2]);
pb.line_to(transformed_points[3]);
pb.close();
pb.detach()
};
canvas.draw_path(&path, paint);
}
@@ -338,13 +344,15 @@ fn draw_arrow_cap(
let mut transformed_points = points;
matrix.map_points(&mut transformed_points, &points);
let mut path = skia::Path::new();
path.move_to(transformed_points[1]);
path.line_to(transformed_points[0]);
path.line_to(transformed_points[2]);
path.move_to(Point::new(center.x, center.y));
path.line_to(transformed_points[0]);
let path = {
let mut pb = skia::PathBuilder::new();
pb.move_to(transformed_points[1]);
pb.line_to(transformed_points[0]);
pb.line_to(transformed_points[2]);
pb.move_to(Point::new(center.x, center.y));
pb.line_to(transformed_points[0]);
pb.detach()
};
canvas.draw_path(&path, paint);
}
@@ -372,12 +380,14 @@ fn draw_triangle_cap(
let mut transformed_points = points;
matrix.map_points(&mut transformed_points, &points);
let mut path = skia::Path::new();
path.move_to(transformed_points[0]);
path.line_to(transformed_points[1]);
path.line_to(transformed_points[2]);
path.close();
let path = {
let mut pb = skia::PathBuilder::new();
pb.move_to(transformed_points[0]);
pb.line_to(transformed_points[1]);
pb.line_to(transformed_points[2]);
pb.close();
pb.detach()
};
canvas.draw_path(&path, paint);
}
@@ -441,8 +451,7 @@ fn draw_image_stroke_in_container(
shape_type @ (Type::Path(_) | Type::Bool(_)) => {
if let Some(p) = shape_type.path() {
canvas.save();
let mut path = p.to_skia_path();
path.transform(&path_transform.unwrap());
let path = p.to_skia_path().make_transform(&path_transform.unwrap());
let stroke_kind = stroke.render_kind(p.is_open());
match stroke_kind {
StrokeKind::Inner => {
@@ -464,7 +473,7 @@ fn draw_image_stroke_in_container(
canvas.draw_path(&path, &thin_paint);
}
handle_stroke_caps(
&mut path,
&path,
stroke,
canvas,
is_open,
@@ -504,8 +513,7 @@ fn draw_image_stroke_in_container(
// Clear outer stroke for paths if necessary. When adding an outer stroke we need to empty the stroke added too in the inner area.
if let Type::Path(p) = &shape.shape_type {
if stroke.render_kind(p.is_open()) == StrokeKind::Outer {
let mut path = p.to_skia_path();
path.transform(&path_transform.unwrap());
let path = p.to_skia_path().make_transform(&path_transform.unwrap());
let mut clear_paint = skia::Paint::default();
clear_paint.set_blend_mode(skia::BlendMode::Clear);
clear_paint.set_anti_alias(antialias);
@@ -724,7 +732,6 @@ fn render_merged(
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();

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 =
@@ -425,7 +425,6 @@ impl Surfaces {
if let Some(s) = outset.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_style(skia::PaintStyle::Stroke);
stroke_paint.set_stroke_width(s * 2.0);
canvas.draw_path(&path, &stroke_paint);
} else {
@@ -485,11 +484,7 @@ impl Surfaces {
self.current.height() - TILE_SIZE_MULTIPLIER * self.margins.height,
);
let snapshot = self.current.image_snapshot();
let mut direct_context = self.current.direct_context();
let tile_image_opt = snapshot
.make_subset(direct_context.as_mut(), rect)
.or_else(|| self.current.image_snapshot_with_bounds(rect));
let tile_image_opt = self.current.image_snapshot_with_bounds(rect);
if let Some(tile_image) = tile_image_opt {
// Draw to cache first (takes reference), then move to tile cache

View File

@@ -166,6 +166,7 @@ pub fn render_with_bounds_outset(
shadow: Option<&Paint>,
blur: Option<&ImageFilter>,
stroke_bounds_outset: f32,
fill_inset: Option<f32>,
) {
if let Some(render_state) = render_state {
let target_surface = surface_id.unwrap_or(SurfaceId::Fills);
@@ -193,6 +194,7 @@ pub fn render_with_bounds_outset(
paragraph_builders,
shadow,
Some(&blur_filter_clone),
fill_inset,
);
},
) {
@@ -202,15 +204,16 @@ pub fn render_with_bounds_outset(
}
let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface);
render_text_on_canvas(canvas, shape, paragraph_builders, shadow, blur);
render_text_on_canvas(canvas, shape, paragraph_builders, shadow, blur, fill_inset);
return;
}
if let Some(canvas) = canvas {
render_text_on_canvas(canvas, shape, paragraph_builders, shadow, blur);
render_text_on_canvas(canvas, shape, paragraph_builders, shadow, blur, fill_inset);
}
}
#[allow(clippy::too_many_arguments)]
pub fn render(
render_state: Option<&mut RenderState>,
canvas: Option<&Canvas>,
@@ -219,6 +222,7 @@ pub fn render(
surface_id: Option<SurfaceId>,
shadow: Option<&Paint>,
blur: Option<&ImageFilter>,
fill_inset: Option<f32>,
) {
render_with_bounds_outset(
render_state,
@@ -229,6 +233,7 @@ pub fn render(
shadow,
blur,
0.0,
fill_inset,
);
}
@@ -238,6 +243,7 @@ fn render_text_on_canvas(
paragraph_builders: &mut [Vec<ParagraphBuilder>],
shadow: Option<&Paint>,
blur: Option<&ImageFilter>,
fill_inset: Option<f32>,
) {
if let Some(blur_filter) = blur {
let mut blur_paint = Paint::default();
@@ -251,6 +257,17 @@ fn render_text_on_canvas(
canvas.save_layer(&layer_rec);
draw_text(canvas, shape, paragraph_builders);
canvas.restore();
} else if let Some(eps) = fill_inset.filter(|&e| e > 0.0) {
if let Some(erode) = skia_safe::image_filters::erode((eps, eps), None, None) {
let mut layer_paint = Paint::default();
layer_paint.set_image_filter(erode);
let layer_rec = SaveLayerRec::default().paint(&layer_paint);
canvas.save_layer(&layer_rec);
draw_text(canvas, shape, paragraph_builders);
canvas.restore();
} else {
draw_text(canvas, shape, paragraph_builders);
}
} else {
draw_text(canvas, shape, paragraph_builders);
}

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,
@@ -1336,7 +1348,7 @@ impl Shape {
if let Some(path) = self.shape_type.path() {
let mut skia_path = path.to_skia_path();
if let Some(path_transform) = self.to_path_transform() {
skia_path.transform(&path_transform);
skia_path = skia_path.make_transform(&path_transform);
}
if let Some(svg_attrs) = &self.svg_attrs {
if svg_attrs.fill_rule == FillRule::Evenodd {

View File

@@ -51,10 +51,10 @@ impl Gradient {
rect.left + self.end.0 * rect.width(),
rect.top + self.end.1 * rect.height(),
);
skia::shader::Shader::linear_gradient(
skia::gradient_shader::linear(
(start, end),
self.colors.as_slice(),
self.offsets.as_slice(),
Some(self.offsets.as_slice()),
skia::TileMode::Clamp,
None,
None,
@@ -83,11 +83,11 @@ impl Gradient {
transform.pre_scale((self.width * rect.width() / rect.height(), 1.), None);
transform.pre_translate((-center.x, -center.y));
skia::shader::Shader::radial_gradient(
skia::gradient_shader::radial(
center,
distance,
self.colors.as_slice(),
self.offsets.as_slice(),
Some(self.offsets.as_slice()),
skia::TileMode::Clamp,
None,
Some(&transform),

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

@@ -29,40 +29,28 @@ impl Default for Path {
}
}
fn to_verb(v: u8) -> skia::path::Verb {
match v {
0 => skia::path::Verb::Move,
1 => skia::path::Verb::Line,
2 => skia::path::Verb::Quad,
3 => skia::path::Verb::Conic,
4 => skia::path::Verb::Cubic,
5 => skia::path::Verb::Close,
_ => skia::path::Verb::Done,
}
}
impl Path {
pub fn new(segments: Vec<Segment>) -> Self {
let mut skia_path = skia::Path::new();
let mut pb = skia::PathBuilder::new();
let mut start = None;
for segment in segments.iter() {
let destination = match *segment {
Segment::MoveTo(xy) => {
start = Some(xy);
skia_path.move_to(xy);
pb.move_to(xy);
None
}
Segment::LineTo(xy) => {
skia_path.line_to(xy);
pb.line_to(xy);
Some(xy)
}
Segment::CurveTo((c1, c2, xy)) => {
skia_path.cubic_to(c1, c2, xy);
pb.cubic_to(c1, c2, xy);
Some(xy)
}
Segment::Close => {
skia_path.close();
pb.close();
None
}
};
@@ -71,11 +59,12 @@ impl Path {
if math::is_close_to(destination.0, start.0)
&& math::is_close_to(destination.1, start.1)
{
skia_path.close();
pb.close();
}
}
}
let skia_path = pb.detach();
let open = subpaths::is_open_path(&segments);
Self {
@@ -86,38 +75,31 @@ impl Path {
}
pub fn from_skia_path(path: skia::Path) -> Self {
let nv = path.count_verbs();
let mut verbs = vec![0; nv];
path.get_verbs(&mut verbs);
let np = path.count_points();
let mut points = Vec::with_capacity(np);
points.resize(np, skia::Point::default());
path.get_points(&mut points);
let verbs = path.verbs();
let points = path.points();
let mut segments = Vec::new();
let mut current_point = 0;
for verb in verbs {
let verb = to_verb(verb);
match verb {
skia::path::Verb::Move => {
skia::PathVerb::Move => {
let p = points[current_point];
segments.push(Segment::MoveTo((p.x, p.y)));
current_point += 1;
}
skia::path::Verb::Line => {
skia::PathVerb::Line => {
let p = points[current_point];
segments.push(Segment::LineTo((p.x, p.y)));
current_point += 1;
}
skia::path::Verb::Quad => {
skia::PathVerb::Quad => {
let p1 = points[current_point];
let p2 = points[current_point + 1];
segments.push(Segment::CurveTo(((p1.x, p1.y), (p1.x, p1.y), (p2.x, p2.y))));
current_point += 2;
}
skia::path::Verb::Conic => {
skia::PathVerb::Conic => {
// TODO: There is no way currently to access the conic weight
// to transform this correctly
let p1 = points[current_point];
@@ -125,17 +107,14 @@ impl Path {
segments.push(Segment::CurveTo(((p1.x, p1.y), (p1.x, p1.y), (p2.x, p2.y))));
current_point += 2;
}
skia::path::Verb::Cubic => {
skia::PathVerb::Cubic => {
let p1 = points[current_point];
let p2 = points[current_point + 1];
let p3 = points[current_point + 2];
segments.push(Segment::CurveTo(((p1.x, p1.y), (p2.x, p2.y), (p3.x, p3.y))));
current_point += 3;
}
skia::path::Verb::Close => {
segments.push(Segment::Close);
}
skia::path::Verb::Done => {
skia::PathVerb::Close => {
segments.push(Segment::Close);
}
}
@@ -184,7 +163,7 @@ impl Path {
_ => {}
});
self.skia_path.transform(mtx);
self.skia_path = self.skia_path.make_transform(mtx);
}
pub fn segments(&self) -> &Vec<Segment> {

View File

@@ -225,13 +225,16 @@ impl Stroke {
if self.style != StrokeStyle::Solid {
let path_effect = match self.style {
StrokeStyle::Dotted => {
let mut circle_path = skia::Path::new();
let width = match self.kind {
StrokeKind::Inner => self.width,
StrokeKind::Center => self.width / 2.0,
StrokeKind::Outer => self.width,
};
circle_path.add_circle((0.0, 0.0), width, None);
let circle_path = {
let mut pb = skia::PathBuilder::new();
pb.add_circle((0.0, 0.0), width, None);
pb.detach()
};
let advance = self.width + 5.0;
skia::PathEffect::path_1d(
&circle_path,

View File

@@ -11,6 +11,7 @@ use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
use skia_safe::{
self as skia,
paint::{self, Paint},
textlayout::Affinity,
textlayout::ParagraphBuilder,
textlayout::ParagraphStyle,
textlayout::PositionWithAffinity,
@@ -112,26 +113,51 @@ 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,
}
}
@@ -421,14 +447,18 @@ 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;
for layout_paragraph in layout_paragraphs {
paragraph_index += 1;
// 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 (paragraph_index, layout_paragraph) in layout_paragraphs.enumerate() {
let start_y = offset_y;
let end_y = offset_y + layout_paragraph.height();
@@ -445,20 +475,22 @@ impl TextContent {
if matches {
let position_with_affinity =
layout_paragraph.get_glyph_position_at_coordinate(*point);
if let Some(paragraph) = self.paragraphs().get(paragraph_index as usize) {
if let Some(paragraph) = self.paragraphs().get(paragraph_index) {
// 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;
// This could be useful in the future as part of the TextPositionWithAffinity.
#[allow(dead_code)]
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_offset = 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;
@@ -467,26 +499,26 @@ impl TextContent {
// Handle empty spans: if the span is empty and current position
// matches the start, this is the right span
if length == 0 && current_position == start_position {
span_offset = 0;
_span_offset = 0;
break;
}
if start_position <= current_position
&& end_position >= current_position
{
span_offset =
position_with_affinity.position - start_position as i32;
_span_offset =
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 as usize,
));
}
}
@@ -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

@@ -101,7 +101,6 @@ impl TextPaths {
if let Some((text_blob_path, text_blob_bounds)) =
Self::get_text_blob_path(span_text, font, blob_offset_x, blob_offset_y)
{
let mut text_path = text_blob_path.clone();
let text_width = font.measure_text(span_text, None).0;
let decoration = style_metric.text_style.decoration();
@@ -111,16 +110,20 @@ impl TextPaths {
let blob_top = blob_offset_y;
let blob_height = text_blob_bounds.height();
if let Some(decoration_rect) = self.calculate_text_decoration_rect(
decoration.ty,
font_metrics,
blob_left,
blob_top,
text_width,
blob_height,
) {
text_path.add_rect(decoration_rect, None);
}
let text_path = {
let mut pb = skia::PathBuilder::new_path(&text_blob_path);
if let Some(decoration_rect) = self.calculate_text_decoration_rect(
decoration.ty,
font_metrics,
blob_left,
blob_top,
text_width,
blob_height,
) {
pb.add_rect(decoration_rect, None, None);
}
pb.detach()
};
let mut paint = style_metric.text_style.foreground();
paint.set_anti_alias(antialias);

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,65 @@ impl TextEditorState {
self.is_active = false;
self.active_shape_id = None;
self.cursor_visible = false;
self.last_blink_time = 0.0;
self.is_pointer_selection_active = false;
self.pending_events.clear();
}
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::empty());
let num_paragraphs = content.paragraphs().len() - 1;
let Some(last_paragraph) = content.paragraphs().last() else {
return false;
};
#[allow(dead_code)]
let _num_spans = last_paragraph.children().len() - 1;
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,
offset,
));
self.reset_blink();
self.push_event(EditorEvent::SelectionChanged);
self.push_event(crate::state::TextEditorEvent::SelectionChanged);
true
}
pub fn set_caret_from_position(&mut self, position: &TextPositionWithAffinity) {
self.selection.set_caret(*position);
self.push_event(TextEditorEvent::SelectionChanged);
}
pub fn extend_selection_from_position(&mut self, position: &TextPositionWithAffinity) {
self.selection.extend_to(*position);
self.push_event(TextEditorEvent::SelectionChanged);
}
pub fn update_blink(&mut self, timestamp_ms: f64) {
@@ -198,41 +226,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]
@@ -120,6 +110,116 @@ pub extern "C" fn text_editor_poll_event() -> u8 {
// SELECTION MANAGEMENT
// ============================================================================
#[no_mangle]
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 Some(shape) = state.shapes.get(&shape_id) else {
return;
};
let Type::Text(text_content) = &shape.shape_type else {
return;
};
let point = Point::new(x, y);
state.text_editor_state.start_pointer_selection();
if let Some(position) =
text_content.get_caret_position_from_shape_coords(&point)
{
state.text_editor_state.set_caret_from_position(&position);
}
});
}
#[no_mangle]
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 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;
};
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_shape_coords(&point)
{
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 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;
};
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_shape_coords(&point)
{
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_offset(x: f32, y: f32) {
with_state_mut!(state, {
if !state.text_editor_state.is_active {
return;
}
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 Type::Text(text_content) = &shape.shape_type else {
return;
};
if let Some(position) =
text_content.get_caret_position_from_shape_coords(&point)
{
state.text_editor_state.set_caret_from_position(&position);
}
});
}
#[no_mangle]
pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) {
with_state_mut!(state, {
@@ -127,140 +227,22 @@ pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) {
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
// 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) {
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) {
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 {
return;
};
let Some(inv_shape_matrix) = shape_matrix.invert() 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 +287,8 @@ 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 +298,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 +348,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 +396,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 +435,8 @@ 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 +446,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 +507,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 +724,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 +777,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 +789,11 @@ 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 +811,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 +886,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 +931,49 @@ 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 +981,58 @@ 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 +1060,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 +1080,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 +1095,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.
@@ -1106,20 +1109,16 @@ 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,
);
delete_range_in_paragraph(&mut paragraphs[start.paragraph], 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 +1217,19 @@ 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 +1245,17 @@ 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 +1263,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 +1278,10 @@ 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 +1289,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

@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -x
export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.87.0/skia-binaries-e551f334ad5cbdf43abf-x86_64-unknown-linux-gnu-gl-svg-textlayout-binary-cache-webp.tar.gz"}
export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-x86_64-unknown-linux-gnu-gl-svg-textlayout-binary-cache-webp.tar.gz"}
export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"x86_64-unknown-linux-gnu"};
_SCRIPT_DIR=$(dirname $0);

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash
_SCRIPT_DIR=$(dirname $0);
export SKIA_BINARIES_URL="https://github.com/penpot/skia-binaries/releases/download/0.87.0/skia-binaries-e551f334ad5cbdf43abf-x86_64-unknown-linux-gnu-gl-svg-textlayout-binary-cache-webp.tar.gz"
export SKIA_BINARIES_URL="https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-x86_64-unknown-linux-gnu-gl-svg-textlayout-binary-cache-webp.tar.gz"
pushd $_SCRIPT_DIR;
cargo watch -x "test --bin render_wasm -- --show-output"