Compare commits

...

72 Commits

Author SHA1 Message Date
Andrey Antukh
12e5d8d8c4 Merge remote-tracking branch 'origin/staging-render' into develop 2026-02-12 11:00:56 +01:00
Andrey Antukh
04a3126856 Merge remote-tracking branch 'origin/main' into staging-render 2026-02-12 11:00:38 +01:00
Elena Torró
2f71663470 Merge pull request #8245 from penpot/elenatorro-13047-setup-embedded-text-editor
🔧 Set up embedded editor
2026-02-12 10:05:39 +01:00
Andrey Antukh
43cb313cd7 Merge pull request #8310 from oraios/mcp-tokens
 MCP improvements to enable UC2, design token handling
2026-02-12 09:47:32 +01:00
Elena Torró
0b199c606a Merge pull request #8331 from penpot/ladybenko-add-wasm-config-playwright-helper
🔧 Add helper utils to mock config flags for WasmWorkspacePage (e2e)
2026-02-12 09:45:03 +01:00
Aitor Moreno
54f63c5dc5 ♻️ Refactor minor things 2026-02-12 09:34:21 +01:00
Elena Torro
a14c36e996 📚 Add embedded text editor MVP documentation 2026-02-12 09:34:20 +01:00
Elena Torro
2b525f0f48 🔧 Set up embedded editor 2026-02-12 09:34:20 +01:00
Belén Albeza
fd6ff04e90 🔧 Add helper utils to mock config flags for WasmWorkspacePage (e2e) 2026-02-12 09:25:08 +01:00
eps-epsiloneridani
dbb0aa8ce2 📚 Update recommended-settings.md (#8330)
Got rid of a stray quotation mark

Signed-off-by: eps-epsiloneridani <162043859+eps-epsiloneridani@users.noreply.github.com>
2026-02-12 09:19:10 +01:00
Andrey Antukh
12822833f6 Merge pull request #8301 from eureka928/fix/4513-shift-arrow-color-inputs
🐛 Add Shift/Alt arrow key stepping to color picker inputs
2026-02-12 08:26:05 +01:00
eureka928
307ae374fe ♻️ Unify color picker input handlers by treating alpha as a property
Eliminate duplicated on-change-opacity and on-key-down-opacity handlers
by routing alpha through apply-property-change, and extract shared
stepping logic into on-key-down-step.

Signed-off-by: eureka928 <meobius123@gmail.com>
2026-02-12 08:25:37 +01:00
eureka928
7d7dbd4662 🐛 Add Shift/Alt arrow key stepping to color picker inputs (#4513)
Color picker numeric inputs (R, G, B, H, S, V, Alpha) now support
Shift+Arrow for ×10 steps and Alt+Arrow for ×0.1 steps, matching
the behavior of numeric inputs elsewhere in the application.

Signed-off-by: eureka928 <meobius123@gmail.com>
2026-02-12 08:25:37 +01:00
Alejandro Alonso
139d4ba13c Merge pull request #8328 from penpot/elenatorro-13311-fix-multiple-strokes-blending
🐛 Fix stroke color aliasing when a shape has multiple strokes
2026-02-12 07:05:44 +01:00
Elena Torro
0cb5c16823 🐛 Fix fallback font 2026-02-12 06:43:52 +01:00
Elena Torro
4ed1a544f8 🐛 Fix stroke color aliasing when a shape has multiple strokes 2026-02-12 06:43:52 +01:00
Elena Torró
566ac67fc9 Merge pull request #8324 from penpot/azazeln28-fix-editor-fills
🐛 Fix text editor issues
2026-02-11 16:37:20 +01:00
Juanfran
394d597736 Add enhacements to plugins build mechanism (#8326)
* 🐛 Fix plugin code build

* 🔧 Update editor.defaultFormatter to new Prettier

* 🐛 Fix lint issues in create-palette-plugin

* 🐛 Add missing run in pnpm init script for plugins
2026-02-11 15:28:33 +01:00
Aitor Moreno
b2231e520c 📚 Add best practices to text editor README.md 2026-02-11 13:09:56 +01:00
Aitor Moreno
e722e17b10 🐛 Fix paragraph styles not being applied 2026-02-11 12:49:20 +01:00
Aitor Moreno
755d720b34 🐛 Fix text editor fills not being updated 2026-02-11 12:29:03 +01:00
Alejandro Alonso
d991d59852 Merge pull request #8318 from penpot/elenatorro-13311-fix-multiple-fills-blending
🐛 Fix fill aliasing when a shape has multiple fills
2026-02-11 11:37:43 +01:00
Dominik Jain
7eb9a207f5 Change PenpotUtils.findShapes to search on all pages by default
This matches the behaviour of findShape, more closely aligning with
the LLM's expectations (given the lack of concrete information in
the instructions)
2026-02-11 11:35:10 +01:00
Dominik Jain
8ac17604fd Improve information on component instances
* Add information on detachment
* Add information on remove behaviour in component instances
2026-02-11 11:35:10 +01:00
Elena Torro
eede023d6b 🐛 Fix fill aliasing when a shape has multiple fills 2026-02-11 11:21:08 +01:00
Belén Albeza
ccd42852b7 🐛 Fix token not being highlighted (wasm) 2026-02-11 11:17:27 +01:00
Alejandro Alonso
a2f7ae549e Merge pull request #8312 from penpot/elenatorro-13256-sync-text-selection
🔧 Hide text color from selected text
2026-02-11 11:02:35 +01:00
Alejandro Alonso
6f74d458a8 🐛 Adding lost file for render e2e testing get-file-stroke-styles.json 2026-02-11 10:47:50 +01:00
Alejandro Alonso
8d033de145 Merge pull request #8299 from penpot/elenatorro-13242-review-performance
🔧 Improve layout performance
2026-02-11 10:45:40 +01:00
Dominik Jain
f5afcde0de Improve shapeStructure
* Add information on component instance (component id, name; main instance id)
* Improve JSON result order (children should come last)
2026-02-11 10:45:22 +01:00
Dominik Jain
b6dfdc23cd Update information on TokenProperty 2026-02-11 10:45:22 +01:00
Dominik Jain
a5a084cf0f Update API type information based on current repo state 2026-02-11 10:45:22 +01:00
Dominik Jain
1546025814 Avoid certain <ul> elements with single <li> generating bullets 2026-02-11 10:45:22 +01:00
Dominik Jain
8de510d1c6 🐛 Fix PenpotAPIDocsProcessor not requiring the url parameter
* Add additional constant for the PROD url
* Adapt the debug function to use a URL
* Improve logging
2026-02-11 10:45:10 +01:00
Elena Torró
2e77c09ca5 Merge pull request #8309 from penpot/superalex-fix-stroke-dot-dash-mix
🐛 Fix dot strokes
2026-02-11 10:37:46 +01:00
Elena Torró
47346e478e Merge pull request #8303 from penpot/superalex-fix-stroke-opacity-for-boards
🐛 Fix stroke opacity for boards
2026-02-11 10:05:47 +01:00
Andrey Antukh
89cd4d820c 🔥 Remove mcp from compose 2026-02-11 09:13:24 +01:00
Alejandro Alonso
f32c377f17 🐛 Fix stroke opacity for boards 2026-02-11 09:08:03 +01:00
Andrey Antukh
8693623b13 📎 Update SECURITY.md file 2026-02-11 08:11:04 +01:00
Alejandro Alonso
97f01c646d 🎉 Improve multiple emoji E2E test 2026-02-11 07:36:22 +01:00
Alejandro Alonso
eea1d3c0a5 🎉 Improve updating canvas background E2E test 2026-02-11 07:19:22 +01:00
Alejandro Alonso
9eef4de87d 🐛 Fix dot/dahs/mixed strokes 2026-02-11 07:08:28 +01:00
Dominik Jain
76289df32c Establish compatibility with new member anchors (h3 instead of a tag) 2026-02-10 14:03:40 +01:00
Elena Torro
187d1118c0 🔧 Hide text color from selected text 2026-02-10 13:15:55 +01:00
Dominik Jain
a674b5f914 📚 Add instructions on running only the docs server 2026-02-10 12:53:20 +01:00
Dominik Jain
71507fb9b7 ♻️ Adjust ConfigurationLoader to use markdown file instead of yml 2026-02-10 12:35:44 +01:00
Dominik Jain
024aedc3ca ♻️ Convert prompt content to markdown format 2026-02-10 12:35:44 +01:00
Dominik Jain
44657c95df ♻️ Rename prompts.yml -> initial_instructions.md 2026-02-10 12:35:44 +01:00
Dominik Jain
d4d5009a3d Improve prompts on token application 2026-02-10 12:35:44 +01:00
Dominik Jain
bb4d0322d8 🚧 Temporarily add ts-ignore statements
This shall be reverted once the new API types are published
2026-02-10 12:35:44 +01:00
Dominik Jain
56e369a1c0 Add helper functions for token exploration
Extend PenpotUtils with helper functions for token exploration/discovery
and describe them in the system prompt
2026-02-10 12:35:44 +01:00
Dominik Jain
6b277956b9 Add information on clone() method 2026-02-10 12:35:44 +01:00
Dominik Jain
e9a56c9d9f Shorten design token instructions 2026-02-10 12:35:44 +01:00
Dominik Jain
8d90edcc2f Add instructions on design tokens 2026-02-10 12:35:44 +01:00
Dominik Jain
8186f3c87c 📚 Remove misleading information from README
The types build is not part of the bootstrap, and it is not
relevant to regular users (only to developers).

Information on how to apply it is now in types-generator/README.md
2026-02-10 12:35:44 +01:00
Dominik Jain
d7282518c4 📚 Improve usage documentation of API type generator script 2026-02-10 12:35:44 +01:00
Dominik Jain
467eb3c333 Update API docs to include token-related types 2026-02-10 12:35:44 +01:00
Dominik Jain
d2299f83ec Apply bash in build scripts explicitly (Win compatibility) 2026-02-10 12:35:44 +01:00
Andrey Antukh
11a283916d Merge remote-tracking branch 'origin/staging' into staging-render 2026-02-10 11:58:27 +01:00
Aitor Moreno
e9b2e9e818 🚑 Hot fix for text editor internal error 2026-02-10 11:10:16 +01:00
Belén Albeza
c4aa51bc01 🐛 Fix permanent blur when switching pages 2026-02-10 10:59:47 +01:00
Belén Albeza
1c270ac9c6 Remove leftover println in render code 2026-02-10 10:59:47 +01:00
Elena Torro
969666b39b 🔧 Simplify view interaction log message
Remove zoom_changed from log output as it's no longer needed
for debugging after the tile optimization changes.
2026-02-09 11:44:50 +01:00
Elena Torro
a8322215dd 🔧 Optimize pan/zoom tile handling
- Add incremental tile update that preserves cache during pan
- Only invalidate tile cache when zoom changes
- Force visible tiles to render synchronously (no yielding)
- Increase interest area threshold from 2 to 3 tiles
2026-02-09 09:38:01 +01:00
Elena Torro
e1ce97a2b4 🔧 Prioritize visible tiles over interest-area tiles
Partition pending tiles into 4 groups by visibility and cache status.
Visible tiles are processed first to eliminate empty squares during
pan/zoom. Cached tiles within each group are processed before uncached.
2026-02-09 09:38:01 +01:00
Elena Torro
2ccd2a6679 🔧 Use HashSet for grid layout children lookup
HashSet provides O(1) contains() vs Vec's O(n), improving
child lookup performance in grid cell data creation.
2026-02-09 09:38:01 +01:00
Elena Torro
2d9a2e0d50 🔧 Use swap_remove in flex layout distribution
swap_remove is O(1) vs remove's O(n) when order doesn't matter.
These loops iterate backwards, so swap_remove is safe.
2026-02-09 09:38:01 +01:00
Elena Torro
216d400262 🔧 Prevent duplicate layout calculations
Use HashSet for layout_reflows to avoid processing the same
layout multiple times. Also use std::mem::take instead of
creating a new Vec on each iteration.
2026-02-09 09:37:58 +01:00
Elena Torro
c87ffdcd30 🔧 Add forward children iterator for flex layout
Avoid Vec allocation + reverse for reversed flex layouts.
The new children_ids_iter_forward returns children in original order,
eliminating the need to collect and reverse.
2026-02-09 09:35:04 +01:00
Elena Torro
8ef6600cdc 🔧 Return HashSet from update_shape_tiles
Avoid final collect() allocation by returning HashSet directly.
Callers already use extend() which works with both types.
2026-02-09 09:35:04 +01:00
Elena Torro
a3764b9713 🔧 Avoid clone in rebuild_touched_tiles
Use std::mem::take instead of clone to avoid HashSet allocation.
The set was cleared anyway by clean_touched(), so take() is safe.
2026-02-09 09:35:03 +01:00
andrés gonzález
79e5d2f4cd 📚 Change link to post at SH guide (#8247) 2026-02-03 08:27:17 +01:00
80 changed files with 23788 additions and 7779 deletions

View File

@@ -2,4 +2,30 @@
## Reporting a Vulnerability ## Reporting a Vulnerability
Please report security issues to `support@penpot.app` We take the security of this project seriously. If you have discovered
a security vulnerability, please do **not** open a public issue.
Please report vulnerabilities via email to: **[support@penpot.app]**
### What to include:
* A brief description of the vulnerability.
* Steps to reproduce the issue.
* Potential impact if exploited.
We appreciate your patience and your commitment to **responsible disclosure**.
---
## Security Contributors
We are incredibly grateful to the following individuals and
organizations for their help in keeping this project safe.
* **Ali Maharramli** for identifying critical path traversal vulnerability
> **Note:** This list is a work in progress. If you have contributed
> to the security of this project and would like to be recognized (or
> prefer to remain anonymous), please let us know.

View File

@@ -55,6 +55,7 @@
"design-tokens/v1" "design-tokens/v1"
"text-editor/v2-html-paste" "text-editor/v2-html-paste"
"text-editor/v2" "text-editor/v2"
"text-editor-wasm/v1"
"render-wasm/v1" "render-wasm/v1"
"variants/v1"}) "variants/v1"})
@@ -78,6 +79,7 @@
"plugins/runtime" "plugins/runtime"
"text-editor/v2-html-paste" "text-editor/v2-html-paste"
"text-editor/v2" "text-editor/v2"
"text-editor-wasm/v1"
"tokens/numeric-input" "tokens/numeric-input"
"render-wasm/v1"}) "render-wasm/v1"})
@@ -127,6 +129,7 @@
:feature-design-tokens "design-tokens/v1" :feature-design-tokens "design-tokens/v1"
:feature-text-editor-v2 "text-editor/v2" :feature-text-editor-v2 "text-editor/v2"
:feature-text-editor-v2-html-paste "text-editor/v2-html-paste" :feature-text-editor-v2-html-paste "text-editor/v2-html-paste"
:feature-text-editor-wasm "text-editor-wasm/v1"
:feature-render-wasm "render-wasm/v1" :feature-render-wasm "render-wasm/v1"
:feature-variants "variants/v1" :feature-variants "variants/v1"
:feature-token-input "tokens/numeric-input" :feature-token-input "tokens/numeric-input"

View File

@@ -198,13 +198,6 @@ services:
## Valkey (or previously Redis) is used for the websockets notifications. ## Valkey (or previously Redis) is used for the websockets notifications.
PENPOT_REDIS_URI: redis://penpot-valkey/0 PENPOT_REDIS_URI: redis://penpot-valkey/0
penpot-mcp:
image: penpotapp/mcp:${PENPOT_VERSION:-latest}
restart: always
networks:
- penpot
penpot-postgres: penpot-postgres:
image: "postgres:15" image: "postgres:15"
restart: always restart: always

View File

@@ -8,9 +8,7 @@ desc: Customize your Penpot instance today. Learn how to install with Elestio, D
This guide explains how to get your own Penpot instance, running on a machine you control, This guide explains how to get your own Penpot instance, running on a machine you control,
to test it, use it by you or your team, or even customize and extend it any way you like. to test it, use it by you or your team, or even customize and extend it any way you like.
If you need more context you can look at the <a For additional context, see the post <a href="https://penpot.app/blog/how-to-self-host-penpot/" target="_blank">How to self-host Penpot: A technical implementation guide</a> on the Penpot blog.
href="https://community.penpot.app/t/self-hosting-penpot-i/2336" target="_blank">post
about self-hosting</a> in Penpot community.
<strong>The experience stays the same, whether you use <strong>The experience stays the same, whether you use
Penpot <a href="https://design.penpot.app" target="_blank">in the cloud</a> Penpot <a href="https://design.penpot.app" target="_blank">in the cloud</a>

View File

@@ -14,7 +14,7 @@ Keep in mind that database size doesn't grow strictly proportionally with user c
# About Valkey / Redis requirements # About Valkey / Redis requirements
"Valkey is mainly used for coordinating websocket notifications and, since Penpot 2.11, as a cache. Therefore, disk storage will not be necessary as it will use the instance's RAM. Valkey is mainly used for coordinating websocket notifications and, since Penpot 2.11, as a cache. Therefore, disk storage will not be necessary as it will use the instance's RAM.
To prevent the cache from hogging all the system's RAM usage, it is recommended to use two configuration parameters which, both in the docker-compose.yaml provided by Penpot and in the official Helm Chart, come with default parameters that should be sufficient for most deployments: To prevent the cache from hogging all the system's RAM usage, it is recommended to use two configuration parameters which, both in the docker-compose.yaml provided by Penpot and in the official Helm Chart, come with default parameters that should be sufficient for most deployments:

View File

@@ -0,0 +1,147 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"plugins/runtime",
"design-tokens/v1",
"variants/v1",
"layout/grid",
"styles/v2",
"fdata/objects-map",
"text-editor/v2",
"render-wasm/v1",
"components/v2",
"fdata/shape-data-type"
]
},
"~:team-id": "~ud7430f09-4f59-8049-8007-6277bb7586f6",
"~: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_color_blending",
"~:revn": 78,
"~:modified-at": "~m1770820738388",
"~:vern": 0,
"~:id": "~ub15901d7-d46d-8056-8007-8d5e34fc1f0c",
"~: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": "~ud7430f09-4f59-8049-8007-6277bb765abd",
"~:created-at": "~m1770741329904",
"~:backend": "legacy-db",
"~:data": {
"~:pages": [
"~ub15901d7-d46d-8056-8007-8d5e34fc1f0d"
],
"~:pages-index": {
"~ub15901d7-d46d-8056-8007-8d5e34fc1f0d": {
"~: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]],\"~: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,\"^H\",0.01,\"~:flip-y\",null,\"~:shapes\",[\"~u3b7d4c1f-3b79-80e5-8007-8d5e38c5a297\",\"~udb80df91-a3a3-803b-8007-8e379b5fd50f\",\"~udb80df91-a3a3-803b-8007-8e38034ff7c8\",\"~udb80df91-a3a3-803b-8007-8e37a71c9d28\",\"~udb80df91-a3a3-803b-8007-8e384d8c53b9\",\"~udb80df91-a3a3-803b-8007-8e37c09b4084\",\"~u18522c44-655d-8050-8007-8e89f4bdc0c4\",\"~u097859f1-ca3b-80ba-8007-8e8beb99a3f5\",\"~u18522c44-655d-8050-8007-8e89f4bdc0c5\",\"~u097859f1-ca3b-80ba-8007-8e8bfca43303\",\"~ufb1f50bf-1bff-8030-8007-8e8c3bd8fcd7\",\"~u18522c44-655d-8050-8007-8e89f4bdc0c6\"]]]",
"~u097859f1-ca3b-80ba-8007-8e8bfca43303": "[\"~#shape\",[\"^ \",\"~:y\",-637.0000057220459,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",336.9999895095825,\"~:y\",-637.000005722046]],[\"^<\",[\"^ \",\"~:x\",636.9999995231628,\"~:y\",-637.000005722046]],[\"^<\",[\"^ \",\"~:x\",636.9999995231628,\"~:y\",-337.00000858306885]],[\"^<\",[\"^ \",\"~:x\",336.9999895095825,\"~:y\",-337.00000858306885]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e380b12ac2a\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~u097859f1-ca3b-80ba-8007-8e8bfca43303\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",10,\"~:stroke-color\",\"#4bff00\",\"~:stroke-opacity\",1],[\"^ \",\"^J\",\"^K\",\"^L\",\"~:outer\",\"^N\",10,\"^O\",\"#333fbd\",\"^P\",1],[\"^ \",\"^J\",\"^K\",\"^L\",\"~:inner\",\"^N\",10,\"^O\",\"#ff0000\",\"^P\",1]],\"~:x\",336.9999895095825,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",336.9999895095825,\"~:y\",-637.0000057220459,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",336.9999895095825,\"~:y1\",-637.0000057220459,\"~:x2\",636.9999995231628,\"~:y2\",-337.00000858306885]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^11\",\"#ff0000\",\"^12\",1]],\"~:flip-x\",null,\"^W\",299.99999713897705,\"~:flip-y\",null]]",
"~udb80df91-a3a3-803b-8007-8e384d8c53b9": "[\"~#shape\",[\"^ \",\"~:y\",450.99999806284904,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Ellipse\",\"~:width\",300.0000065565109,\"~:type\",\"~:circle\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1021.0000203847885,\"~:y\",450.99999806284904]],[\"^<\",[\"^ \",\"~:x\",1321.0000269412994,\"~:y\",450.99999806284904]],[\"^<\",[\"^ \",\"~:x\",1321.0000269412994,\"~:y\",751.0000142753124]],[\"^<\",[\"^ \",\"~:x\",1021.0000203847885,\"~:y\",751.0000142753124]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e37b7ddd15c\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"^@\",\"~udb80df91-a3a3-803b-8007-8e384d8c53b9\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:inner\",\"~:stroke-width\",20,\"~:stroke-color\",\"#333fbd\",\"~:stroke-opacity\",1]],\"~:x\",1021.0000203847885,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1021.0000203847885,\"~:y\",450.99999806284904,\"^8\",300.0000065565109,\"~:height\",300.0000162124634,\"~:x1\",1021.0000203847885,\"~:y1\",450.99999806284904,\"~:x2\",1321.0000269412994,\"~:y2\",751.0000142753124]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^W\",\"#ff0000\",\"^X\",1]],\"~:flip-x\",null,\"^Q\",300.0000162124634,\"~:flip-y\",null]]",
"~udb80df91-a3a3-803b-8007-8e379b5fd50f": "[\"~#shape\",[\"^ \",\"~:y\",82.00000368146124,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",686.7500124588994,\"~:y\",82.00000368146122]],[\"^<\",[\"^ \",\"~:x\",986.7500224724797,\"~:y\",82.00000368146122]],[\"^<\",[\"^ \",\"~:x\",986.7500224724797,\"~:y\",382.0000008204383]],[\"^<\",[\"^ \",\"~:x\",686.7500124588994,\"~:y\",382.0000008204383]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~udb80df91-a3a3-803b-8007-8e379b5fd50f\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",686.7500124588994,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",686.7500124588994,\"~:y\",82.00000368146124,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",686.7500124588994,\"~:y1\",82.00000368146124,\"~:x2\",986.7500224724797,\"~:y2\",382.0000008204383]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^P\",\"#ff0000\",\"^Q\",1]],\"~:flip-x\",null,\"^J\",299.99999713897705,\"~:flip-y\",null]]",
"~u3b7d4c1f-3b79-80e5-8007-8d5e38c5a297": "[\"~#shape\",[\"^ \",\"~:y\",81.9999960520667,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",337.0000200882939,\"~:y\",81.99999605206669]],[\"^<\",[\"^ \",\"~:x\",637.0000301018742,\"~:y\",81.99999605206669]],[\"^<\",[\"^ \",\"~:x\",637.0000301018742,\"~:y\",381.99999319104376]],[\"^<\",[\"^ \",\"~:x\",337.0000200882939,\"~:y\",381.99999319104376]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:blur\",[\"^ \",\"~:id\",\"~u432cbb09-2ee7-80bf-8007-8d660b2f52ad\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~u3b7d4c1f-3b79-80e5-8007-8d5e38c5a297\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",337.0000200882939,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",337.0000200882939,\"~:y\",81.9999960520667,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",337.0000200882939,\"~:y1\",81.9999960520667,\"~:x2\",637.0000301018742,\"~:y2\",381.99999319104376]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^T\",\"#ff0000\",\"^U\",1]],\"~:flip-x\",null,\"^N\",299.99999713897705,\"~:flip-y\",null]]",
"~ufb1f50bf-1bff-8030-8007-8e8c3bd8fcd7": "[\"~#shape\",[\"^ \",\"~:y\",-629.9999999999998,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1037,\"~:y\",-630]],[\"^<\",[\"^ \",\"~:x\",1337.0000100135803,\"~:y\",-630]],[\"^<\",[\"^ \",\"~:x\",1337.0000100135803,\"~:y\",-330.0000028610228]],[\"^<\",[\"^ \",\"~:x\",1037,\"~:y\",-330.0000028610228]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e380b12ac2a\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~ufb1f50bf-1bff-8030-8007-8e8c3bd8fcd7\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:outer\",\"~:stroke-width\",10,\"~:stroke-color\",\"#333fbd\",\"~:stroke-opacity\",1],[\"^ \",\"^J\",\"^K\",\"^L\",\"~:inner\",\"^N\",10,\"^O\",\"#ff0000\",\"^P\",1],[\"^ \",\"^J\",\"^K\",\"^L\",\"~:center\",\"^N\",10,\"^O\",\"#4bff00\",\"^P\",1]],\"~:x\",1037,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1037,\"~:y\",-629.9999999999998,\"^8\",300.0000100135803,\"~:height\",299.999997138977,\"~:x1\",1037,\"~:y1\",-629.9999999999998,\"~:x2\",1337.0000100135803,\"~:y2\",-330.0000028610228]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^11\",\"#ff0000\",\"^12\",1]],\"~:flip-x\",null,\"^W\",299.999997138977,\"~:flip-y\",null]]",
"~u097859f1-ca3b-80ba-8007-8e8beb99a3f5": "[\"~#shape\",[\"^ \",\"~:y\",-626.0000057220459,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",687.0000123977661,\"~:y\",-626.000005722046]],[\"^<\",[\"^ \",\"~:x\",987.0000224113464,\"~:y\",-626.000005722046]],[\"^<\",[\"^ \",\"~:x\",987.0000224113464,\"~:y\",-326.00000858306885]],[\"^<\",[\"^ \",\"~:x\",687.0000123977661,\"~:y\",-326.00000858306885]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e380b12ac2a\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~u097859f1-ca3b-80ba-8007-8e8beb99a3f5\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:inner\",\"~:stroke-width\",10,\"~:stroke-color\",\"#333fbd\",\"~:stroke-opacity\",1],[\"^ \",\"^J\",\"^K\",\"^L\",\"^M\",\"^N\",10,\"^O\",\"#ff0000\",\"^P\",1]],\"~:x\",687.0000123977661,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",687.0000123977661,\"~:y\",-626.0000057220459,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",687.0000123977661,\"~:y1\",-626.0000057220459,\"~:x2\",987.0000224113464,\"~:y2\",-326.00000858306885]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^[\",\"#ff0000\",\"^10\",1]],\"~:flip-x\",null,\"^U\",299.99999713897705,\"~:flip-y\",null]]",
"~udb80df91-a3a3-803b-8007-8e37a71c9d28": "[\"~#shape\",[\"^ \",\"~:y\",450.99999806284904,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Ellipse\",\"~:width\",300.0000065565109,\"~:type\",\"~:circle\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",337.0000203847885,\"~:y\",450.99999806284904]],[\"^<\",[\"^ \",\"~:x\",637.0000269412994,\"~:y\",450.99999806284904]],[\"^<\",[\"^ \",\"~:x\",637.0000269412994,\"~:y\",751.0000142753124]],[\"^<\",[\"^ \",\"~:x\",337.0000203847885,\"~:y\",751.0000142753124]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e37b7ddd15c\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"^@\",\"~udb80df91-a3a3-803b-8007-8e37a71c9d28\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",337.0000203847885,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",337.0000203847885,\"~:y\",450.99999806284904,\"^8\",300.0000065565109,\"~:height\",300.0000162124634,\"~:x1\",337.0000203847885,\"~:y1\",450.99999806284904,\"~:x2\",637.0000269412994,\"~:y2\",751.0000142753124]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^P\",\"#ff0000\",\"^Q\",1]],\"~:flip-x\",null,\"^J\",300.0000162124634,\"~:flip-y\",null]]",
"~u18522c44-655d-8050-8007-8e89f4bdc0c5": "[\"~#shape\",[\"^ \",\"~:y\",-287.0000057220459,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",337.00002002716064,\"~:y\",-287.000005722046]],[\"^<\",[\"^ \",\"~:x\",637.000030040741,\"~:y\",-287.000005722046]],[\"^<\",[\"^ \",\"~:x\",637.000030040741,\"~:y\",12.999991416931152]],[\"^<\",[\"^ \",\"~:x\",337.00002002716064,\"~:y\",12.999991416931152]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e380b12ac2a\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~u18522c44-655d-8050-8007-8e89f4bdc0c5\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:outer\",\"~:stroke-width\",10,\"~:stroke-color\",\"#333fbd\",\"~:stroke-opacity\",1],[\"^ \",\"^J\",\"^K\",\"^L\",\"~:inner\",\"^N\",10,\"^O\",\"#ff0000\",\"^P\",1]],\"~:x\",337.00002002716064,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",337.00002002716064,\"~:y\",-287.0000057220459,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",337.00002002716064,\"~:y1\",-287.0000057220459,\"~:x2\",637.000030040741,\"~:y2\",12.999991416931152]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^10\",\"#ff0000\",\"^11\",1]],\"~:flip-x\",null,\"^V\",299.99999713897705,\"~:flip-y\",null]]",
"~udb80df91-a3a3-803b-8007-8e37c09b4084": "[\"~#shape\",[\"^ \",\"~:y\",450.99999806284904,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Ellipse\",\"~:width\",300.0000065565109,\"~:type\",\"~:circle\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",679.0000203847885,\"~:y\",450.99999806284904]],[\"^<\",[\"^ \",\"~:x\",979.0000269412994,\"~:y\",450.99999806284904]],[\"^<\",[\"^ \",\"~:x\",979.0000269412994,\"~:y\",751.0000142753124]],[\"^<\",[\"^ \",\"~:x\",679.0000203847885,\"~:y\",751.0000142753124]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~udb80df91-a3a3-803b-8007-8e37c09b4084\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",679.0000203847885,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",679.0000203847885,\"~:y\",450.99999806284904,\"^8\",300.0000065565109,\"~:height\",300.0000162124634,\"~:x1\",679.0000203847885,\"~:y1\",450.99999806284904,\"~:x2\",979.0000269412994,\"~:y2\",751.0000142753124]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^L\",\"#ff0000\",\"^M\",1]],\"~:flip-x\",null,\"^F\",300.0000162124634,\"~:flip-y\",null]]",
"~u18522c44-655d-8050-8007-8e89f4bdc0c4": "[\"~#shape\",[\"^ \",\"~:y\",-287.0000057220459,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",686.7500123977661,\"~:y\",-287.000005722046]],[\"^<\",[\"^ \",\"~:x\",986.7500224113464,\"~:y\",-287.000005722046]],[\"^<\",[\"^ \",\"~:x\",986.7500224113464,\"~:y\",12.999991416931152]],[\"^<\",[\"^ \",\"~:x\",686.7500123977661,\"~:y\",12.999991416931152]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e380b12ac2a\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~u18522c44-655d-8050-8007-8e89f4bdc0c4\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:inner\",\"~:stroke-width\",10,\"~:stroke-color\",\"#333fbd\",\"~:stroke-opacity\",0.5],[\"^ \",\"^J\",\"^K\",\"^L\",\"^M\",\"^N\",10,\"^O\",\"#ff0000\",\"^P\",1]],\"~:x\",686.7500123977661,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",686.7500123977661,\"~:y\",-287.0000057220459,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",686.7500123977661,\"~:y1\",-287.0000057220459,\"~:x2\",986.7500224113464,\"~:y2\",12.999991416931152]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^[\",\"#ff0000\",\"^10\",1]],\"~:flip-x\",null,\"^U\",299.99999713897705,\"~:flip-y\",null]]",
"~udb80df91-a3a3-803b-8007-8e38034ff7c8": "[\"~#shape\",[\"^ \",\"~:y\",82.00000368146124,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1036.5000048295049,\"~:y\",82.00000368146122]],[\"^<\",[\"^ \",\"~:x\",1336.5000148430852,\"~:y\",82.00000368146122]],[\"^<\",[\"^ \",\"~:x\",1336.5000148430852,\"~:y\",382.0000008204383]],[\"^<\",[\"^ \",\"~:x\",1036.5000048295049,\"~:y\",382.0000008204383]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e380b12ac2a\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~udb80df91-a3a3-803b-8007-8e38034ff7c8\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:inner\",\"~:stroke-width\",10,\"~:stroke-color\",\"#333fbd\",\"~:stroke-opacity\",1]],\"~:x\",1036.5000048295049,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1036.5000048295049,\"~:y\",82.00000368146124,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",1036.5000048295049,\"~:y1\",82.00000368146124,\"~:x2\",1336.5000148430852,\"~:y2\",382.0000008204383]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^[\",\"#ff0000\",\"^10\",1]],\"~:flip-x\",null,\"^U\",299.99999713897705,\"~:flip-y\",null]]",
"~u18522c44-655d-8050-8007-8e89f4bdc0c6": "[\"~#shape\",[\"^ \",\"~:y\",-287.0000057220459,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1036.5000047683716,\"~:y\",-287.000005722046]],[\"^<\",[\"^ \",\"~:x\",1336.500014781952,\"~:y\",-287.000005722046]],[\"^<\",[\"^ \",\"~:x\",1336.500014781952,\"~:y\",12.999991416931152]],[\"^<\",[\"^ \",\"~:x\",1036.5000047683716,\"~:y\",12.999991416931152]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e380b12ac2a\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~u18522c44-655d-8050-8007-8e89f4bdc0c6\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:inner\",\"~:stroke-width\",10,\"~:stroke-color\",\"#333fbd\",\"~:stroke-opacity\",1],[\"^ \",\"^J\",\"^K\",\"^L\",\"~:outer\",\"^N\",10,\"^O\",\"#ff0000\",\"^P\",1]],\"~:x\",1036.5000047683716,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1036.5000047683716,\"~:y\",-287.0000057220459,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",1036.5000047683716,\"~:y1\",-287.0000057220459,\"~:x2\",1336.500014781952,\"~:y2\",12.999991416931152]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^10\",\"#ff0000\",\"^11\",1]],\"~:flip-x\",null,\"^V\",299.99999713897705,\"~:flip-y\",null]]"
}
},
"~:id": "~ub15901d7-d46d-8056-8007-8d5e34fc1f0d",
"~:name": "Page 1"
}
},
"~:id": "~ub15901d7-d46d-8056-8007-8d5e34fc1f0c",
"~: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

@@ -10,7 +10,7 @@ export const WASM_FLAGS = [
export class WasmWorkspacePage extends WorkspacePage { export class WasmWorkspacePage extends WorkspacePage {
static async init(page) { static async init(page) {
await super.init(page); await super.init(page);
await WorkspacePage.mockConfigFlags(page, WASM_FLAGS); await WasmWorkspacePage.mockConfigFlags(page, WASM_FLAGS);
await page.addInitScript(() => { await page.addInitScript(() => {
document.addEventListener("penpot:wasm:loaded", () => { document.addEventListener("penpot:wasm:loaded", () => {
@@ -27,6 +27,14 @@ export class WasmWorkspacePage extends WorkspacePage {
}); });
} }
static async mockConfigFlags(page, flags) {
await super.mockConfigFlags(page, [...WASM_FLAGS, ...flags]);
}
async mockConfigFlags(flags) {
return WasmWorkspacePage.mockConfigFlags(this.page, flags);
}
constructor(page) { constructor(page) {
super(page); super(page);
this.canvas = page.getByTestId("canvas-wasm-shapes"); this.canvas = page.getByTestId("canvas-wasm-shapes");

View File

@@ -165,6 +165,7 @@ test("Updates canvas background", async ({ page }) => {
}); });
await canvasBackgroundInput.fill("FABADA"); await canvasBackgroundInput.fill("FABADA");
await workspace.page.keyboard.press("Enter"); await workspace.page.keyboard.press("Enter");
await workspace.waitForFirstRenderWithoutUI();
await expect(workspace.canvas).toHaveScreenshot(); await expect(workspace.canvas).toHaveScreenshot();
}); });
@@ -290,6 +291,24 @@ test("Renders a file with nested clipping frames", async ({ page }) => {
await expect(workspace.canvas).toHaveScreenshot(); await expect(workspace.canvas).toHaveScreenshot();
}); });
test("Renders clipped frames with strokes correctly (no double painting)", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile(
"render-wasm/get-file-frame-strokes-opacity.json",
);
await workspace.goToWorkspace({
id: "3144ac7c-a5cc-80e8-8007-8bbb29a4e56e",
pageId: "3144ac7c-a5cc-80e8-8007-8bbb29a510ac",
});
await workspace.waitForFirstRenderWithoutUI();
await expect(workspace.canvas).toHaveScreenshot();
});
test("Renders a clipped frame with a large blur drop shadow", async ({ test("Renders a clipped frame with a large blur drop shadow", async ({
page, page,
}) => { }) => {
@@ -305,3 +324,35 @@ test("Renders a clipped frame with a large blur drop shadow", async ({
await expect(workspace.canvas).toHaveScreenshot(); await expect(workspace.canvas).toHaveScreenshot();
}); });
test("Renders a file with solid, dotted, dashed and mixed stroke styles", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("render-wasm/get-file-stroke-styles.json");
await workspace.goToWorkspace({
id: "b888b894-3697-80d3-8006-51cc8a55c200",
pageId: "b888b894-3697-80d3-8006-51cc8a55c210",
});
await workspace.waitForFirstRenderWithoutUI();
await expect(workspace.canvas).toHaveScreenshot();
});
test("Renders shapes with multiple fills and blur", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("render-wasm/get-file-fill-blend-blurs.json");
await workspace.goToWorkspace({
id: "b15901d7-d46d-8056-8007-8d5e34fc1f0c",
pageId: "b15901d7-d46d-8056-8007-8d5e34fc1f0d",
});
await workspace.waitForFirstRenderWithoutUI();
await expect(workspace.canvas).toHaveScreenshot();
});

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 360 KiB

After

Width:  |  Height:  |  Size: 348 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 220 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 126 KiB

View File

@@ -32,6 +32,7 @@
[app.main.features :as features] [app.main.features :as features]
[app.main.fonts :as fonts] [app.main.fonts :as fonts]
[app.main.router :as rt] [app.main.router :as rt]
[app.render-wasm.api :as wasm.api]
[app.util.text-editor :as ted] [app.util.text-editor :as ted]
[app.util.text.content.styles :as styles] [app.util.text.content.styles :as styles]
[app.util.timers :as ts] [app.util.timers :as ts]
@@ -508,12 +509,12 @@
ptk/EffectEvent ptk/EffectEvent
(effect [_ state _] (effect [_ state _]
(when (features/active-feature? state "text-editor/v2") (when (features/active-feature? state "text-editor/v2")
(let [instance (:workspace-editor state) (when-let [instance (:workspace-editor state)]
styles (some-> (editor.v2/getCurrentStyle instance) (let [styles (some-> (editor.v2/getCurrentStyle instance)
(styles/get-styles-from-style-declaration :removed-mixed true) (styles/get-styles-from-style-declaration :removed-mixed true)
((comp update-node-fn migrate-node)) ((comp update-node-fn migrate-node))
(styles/attrs->styles))] (styles/attrs->styles))]
(editor.v2/applyStylesToSelection instance styles))))))) (editor.v2/applyStylesToSelection instance styles))))))))
;; --- RESIZE UTILS ;; --- RESIZE UTILS
@@ -777,17 +778,30 @@
(rx/of (v2-update-text-editor-styles id attrs))) (rx/of (v2-update-text-editor-styles id attrs)))
(when (features/active-feature? state "render-wasm/v1") (when (features/active-feature? state "render-wasm/v1")
(rx/of (dwwt/resize-wasm-text-debounce id))))))) (rx/concat
;; Apply style to selected spans and sync content
(when (wasm.api/text-editor-is-active?)
(let [span-attrs (select-keys attrs txt/text-node-attrs)]
(when (not (empty? span-attrs))
(let [result (wasm.api/apply-style-to-selection span-attrs)]
(when result
(rx/of (v2-update-text-shape-content
(:shape-id result) (:content result)
:update-name? true)))))))
;; Resize (with delay for font-id changes)
(cond->> (rx/of (dwwt/resize-wasm-text id))
(contains? attrs :font-id)
(rx/delay 200))))))))
ptk/EffectEvent ptk/EffectEvent
(effect [_ state _] (effect [_ state _]
(when (features/active-feature? state "text-editor/v2") (when (features/active-feature? state "text-editor/v2")
(let [instance (:workspace-editor state) (when-let [instance (:workspace-editor state)]
attrs-to-override (some-> (editor.v2/getCurrentStyle instance) (let [attrs-to-override (some-> (editor.v2/getCurrentStyle instance)
(styles/get-styles-from-style-declaration)) (styles/get-styles-from-style-declaration))
overriden-attrs (merge attrs-to-override attrs) overriden-attrs (merge attrs-to-override attrs)
styles (styles/attrs->styles overriden-attrs)] styles (styles/attrs->styles overriden-attrs)]
(editor.v2/applyStylesToSelection instance styles)))))) (editor.v2/applyStylesToSelection instance styles)))))))
(defn update-all-attrs (defn update-all-attrs
[ids attrs] [ids attrs]

View File

@@ -409,7 +409,7 @@
modif-tree (dwm/build-modif-tree ids objects get-modifier)] modif-tree (dwm/build-modif-tree ids objects get-modifier)]
(if (features/active-feature? state "render-wasm/v1") (if (features/active-feature? state "render-wasm/v1")
(rx/of (dwm/apply-wasm-modifiers modif-tree {:ignore-snap-pixel true})) (rx/of (dwm/apply-wasm-modifiers modif-tree (assoc options :ignore-snap-pixel true)))
(let [modif-tree (gm/set-objects-modifiers modif-tree objects)] (let [modif-tree (gm/set-objects-modifiers modif-tree objects)]
(rx/of (dwm/apply-modifiers* objects modif-tree nil options))))))))) (rx/of (dwm/apply-modifiers* objects modif-tree nil options)))))))))

View File

@@ -11,6 +11,7 @@
[app.common.math :as mth] [app.common.math :as mth]
[app.common.types.color :as cc] [app.common.types.color :as cc]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.keyboard :as kbd]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
(defn parse-hex (defn parse-hex
@@ -67,34 +68,60 @@
(when (some? val) (when (some? val)
(setup-hex-color val)))) (setup-hex-color val))))
apply-property-change
(fn [property val]
(let [val (case property
:s (/ val 100)
:v (value->hsv-value val)
:alpha (/ val 100)
val)]
(cond
(= property :alpha)
(on-change {:alpha val})
(#{:r :g :b} property)
(let [{:keys [r g b]} (merge color (hash-map property val))
hex (cc/rgb->hex [r g b])
[h s v] (cc/hex->hsv hex)]
(on-change {:hex hex
:h h :s s :v v
:r r :g g :b b}))
:else
(let [{:keys [h s v]} (merge color (hash-map property val))
hex (cc/hsv->hex [h s v])
[r g b] (cc/hex->rgb hex)]
(on-change {:hex hex
:h h :s s :v v
:r r :g g :b b})))))
on-change-property on-change-property
(fn [property max-value] (fn [property max-value]
(fn [e] (fn [e]
(let [val (-> e dom/get-target-val d/parse-double (mth/clamp 0 max-value)) (let [val (-> e dom/get-target-val d/parse-double (mth/clamp 0 max-value))]
val (case property (when (some? val)
:s (/ val 100) (apply-property-change property val)))))
:v (value->hsv-value val)
val)]
(when (not (nil? val))
(if (#{:r :g :b} property)
(let [{:keys [r g b]} (merge color (hash-map property val))
hex (cc/rgb->hex [r g b])
[h s v] (cc/hex->hsv hex)]
(on-change {:hex hex
:h h :s s :v v
:r r :g g :b b}))
(let [{:keys [h s v]} (merge color (hash-map property val)) on-key-down-step
hex (cc/hsv->hex [h s v]) (fn [max-value on-step]
[r g b] (cc/hex->rgb hex)] (fn [e]
(on-change {:hex hex (let [up? (kbd/up-arrow? e)
:h h :s s :v v down? (kbd/down-arrow? e)]
:r r :g g :b b}))))))) (when (and (or up? down?)
(or (kbd/shift? e) (kbd/alt? e)))
(dom/prevent-default e)
(when-let [current-value (-> e dom/get-target-val d/parse-double)]
(let [step (cond
(kbd/shift? e) (if up? 10 -10)
(kbd/alt? e) (if up? 0.1 -0.1))
new-value (mth/clamp (+ current-value step) 0 max-value)
node (dom/get-target e)]
(dom/set-value! node new-value)
(on-step new-value)))))))
on-change-opacity on-key-down-property
(fn [e] (fn [property max-value]
(when-let [new-alpha (-> e dom/get-target-val (mth/clamp 0 100) (/ 100))] (on-key-down-step max-value #(apply-property-change property %)))]
(on-change {:alpha new-alpha})))]
;; Updates the inputs values when a property is changed in the parent ;; Updates the inputs values when a property is changed in the parent
@@ -127,7 +154,8 @@
:min 0 :min 0
:max 255 :max 255
:default-value red :default-value red
:on-change (on-change-property :r 255)}]] :on-change (on-change-property :r 255)
:on-key-down (on-key-down-property :r 255)}]]
[:div {:class (stl/css :input-wrapper)} [:div {:class (stl/css :input-wrapper)}
[:label {:for "green-value" :class (stl/css :input-label)} "G"] [:label {:for "green-value" :class (stl/css :input-label)} "G"]
[:input {:id "green-value" [:input {:id "green-value"
@@ -136,7 +164,8 @@
:min 0 :min 0
:max 255 :max 255
:default-value green :default-value green
:on-change (on-change-property :g 255)}]] :on-change (on-change-property :g 255)
:on-key-down (on-key-down-property :g 255)}]]
[:div {:class (stl/css :input-wrapper)} [:div {:class (stl/css :input-wrapper)}
[:label {:for "blue-value" :class (stl/css :input-label)} "B"] [:label {:for "blue-value" :class (stl/css :input-label)} "B"]
[:input {:id "blue-value" [:input {:id "blue-value"
@@ -145,7 +174,8 @@
:min 0 :min 0
:max 255 :max 255
:default-value blue :default-value blue
:on-change (on-change-property :b 255)}]]] :on-change (on-change-property :b 255)
:on-key-down (on-key-down-property :b 255)}]]]
[:* [:*
[:div {:class (stl/css :input-wrapper)} [:div {:class (stl/css :input-wrapper)}
@@ -156,7 +186,8 @@
:min 0 :min 0
:max 360 :max 360
:default-value hue :default-value hue
:on-change (on-change-property :h 360)}]] :on-change (on-change-property :h 360)
:on-key-down (on-key-down-property :h 360)}]]
[:div {:class (stl/css :input-wrapper)} [:div {:class (stl/css :input-wrapper)}
[:label {:for "saturation-value" :class (stl/css :input-label)} "S"] [:label {:for "saturation-value" :class (stl/css :input-label)} "S"]
[:input {:id "saturation-value" [:input {:id "saturation-value"
@@ -166,7 +197,8 @@
:max 100 :max 100
:step 1 :step 1
:default-value saturation :default-value saturation
:on-change (on-change-property :s 100)}]] :on-change (on-change-property :s 100)
:on-key-down (on-key-down-property :s 100)}]]
[:div {:class (stl/css :input-wrapper)} [:div {:class (stl/css :input-wrapper)}
[:label {:for "value-value" :class (stl/css :input-label)} "V"] [:label {:for "value-value" :class (stl/css :input-label)} "V"]
[:input {:id "value-value" [:input {:id "value-value"
@@ -175,7 +207,8 @@
:min 0 :min 0
:max 100 :max 100
:default-value value :default-value value
:on-change (on-change-property :v 100)}]]])] :on-change (on-change-property :v 100)
:on-key-down (on-key-down-property :v 100)}]]])]
[:div {:class (stl/css :hex-alpha-wrapper)} [:div {:class (stl/css :hex-alpha-wrapper)}
[:div {:class (stl/css-case :input-wrapper true [:div {:class (stl/css-case :input-wrapper true
:hex true)} :hex true)}
@@ -195,4 +228,5 @@
:step 1 :step 1
:max 100 :max 100
:default-value (if (= alpha :multiple) "" alpha) :default-value (if (= alpha :multiple) "" alpha)
:on-change on-change-opacity}]])]])) :on-change (on-change-property :alpha 100)
:on-key-down (on-key-down-property :alpha 100)}]])]]))

View File

@@ -352,11 +352,9 @@
max-height (max height selrect-height) max-height (max height selrect-height)
valign (-> shape :content :vertical-align) valign (-> shape :content :vertical-align)
y (:y selrect) y (:y selrect)
y (if (and valign (> height selrect-height)) y (case valign
(case valign "bottom" (+ y (- selrect-height height))
"bottom" (- y (- height selrect-height)) "center" (+ y (/ (- selrect-height height) 2))
"center" (- y (/ (- height selrect-height) 2))
y)
y)] y)]
[(assoc selrect :y y :width max-width :height max-height) transform]) [(assoc selrect :y y :width max-width :height max-height) transform])

View File

@@ -29,6 +29,23 @@
color: transparent; color: transparent;
// Match Skia's text layout precision: prevent browser text-size
// adjustments and ensure consistent kerning across browsers.
text-size-adjust: none;
-webkit-text-size-adjust: none;
font-kerning: normal;
&::selection,
*::selection {
color: transparent;
-webkit-text-fill-color: transparent; // WebKit/Safari
}
&::-moz-selection,
*::-moz-selection {
color: transparent;
}
[data-itype="paragraph"] { [data-itype="paragraph"] {
line-height: inherit; line-height: inherit;
user-select: text; user-select: text;

View File

@@ -70,7 +70,7 @@
on-click on-click
(mf/use-fn (mf/use-fn
(mf/deps id) (mf/deps id current-page-id)
(fn [] (fn []
;; For the wasm renderer, apply a blur effect to the viewport canvas ;; For the wasm renderer, apply a blur effect to the viewport canvas
;; when we navigate to a different page. ;; when we navigate to a different page.

View File

@@ -19,10 +19,14 @@
[app.main.data.workspace.media :as dwm] [app.main.data.workspace.media :as dwm]
[app.main.data.workspace.path :as dwdp] [app.main.data.workspace.path :as dwdp]
[app.main.data.workspace.specialized-panel :as-alias dwsp] [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.refs :as refs]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.workspace.sidebar.assets.components :as wsac] [app.main.ui.workspace.sidebar.assets.components :as wsac]
[app.main.ui.workspace.viewport.viewport-ref :as uwvv] [app.main.ui.workspace.viewport.viewport-ref :as uwvv]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.wasm :as wasm.wasm]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.dom.dnd :as dnd] [app.util.dom.dnd :as dnd]
[app.util.dom.normalize-wheel :as nw] [app.util.dom.normalize-wheel :as nw]
@@ -91,7 +95,17 @@
::dwsp/interrupt) ::dwsp/interrupt)
(when (and (not= edition id) (or text-editing? grid-editing?)) (when (and (not= edition id) (or text-editing? grid-editing?))
(st/emit! (dw/clear-edition-mode))) (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)))
(when (and (not text-editing?) (when (and (not text-editing?)
(not blocked) (not blocked)
@@ -184,6 +198,20 @@
(not drawing-tool)) (not drawing-tool))
(st/emit! (dw/select-shape (:id @hover) shift?))) (st/emit! (dw/select-shape (:id @hover) shift?)))
;; If clicking on a text shape and wasm render is enabled, forward cursor position
(when (and hovering?
(not @space?)
edition ;; Only when already in edit mode
(not drawing-path?)
(not drawing-tool))
(let [hover-shape @hover]
(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))))))
(when (and @z? (when (and @z?
(not @space?) (not @space?)
(not edition) (not edition)
@@ -223,8 +251,15 @@
(when (and (not drawing-path?) shape) (when (and (not drawing-path?) shape)
(cond (cond
(and editable? (not= id edition) (not read-only?)) (and editable? (not= id edition) (not read-only?))
(st/emit! (dw/select-shape id) (do
(dw/start-editing-selected)) (st/emit! (dw/select-shape id)
(dw/start-editing-selected))
;; If using wasm text-editor, notify WASM to start editing this shape
;; and set cursor position from the double-click location
(when (and (= type :text)
(features/active-feature? @st/state "text-editor-wasm/v1")
wasm.wasm/context-initialized?)
(wasm.api/text-editor-start id)))
(some? selected-shape) (some? selected-shape)
(do (do

View File

@@ -164,7 +164,6 @@
;; for the release of the z key ;; for the release of the z key
(when-not ^boolean value (when-not ^boolean value
(reset! z* false)))) (reset! z* false))))
(hooks/use-stream kbd-zoom-s (hooks/use-stream kbd-zoom-s
(fn [kevent] (fn [kevent]
(dom/prevent-default kevent) (dom/prevent-default kevent)
@@ -316,7 +315,7 @@
(and (cfh/group-shape? objects %) (and (cfh/group-shape? objects %)
(not (contains? child-parent? %))) (not (contains? child-parent? %)))
(and (features/active-feature? @st/state "render-wasm/v1") (and (features/active-feature? @st/state "render-wasm/v1")
(cfh/text-shape? objects %) (cfh/text-shape? (get objects %))
(not (wasm.api/intersect-position-in-shape % @last-point-ref))))))) (not (wasm.api/intersect-position-in-shape % @last-point-ref)))))))
remove-measure-xf remove-measure-xf

View File

@@ -66,6 +66,14 @@
(gpt/divide zoom) (gpt/divide zoom)
(gpt/add box)))))) (gpt/add box))))))
(defn point->viewport-relative
"Convert client coordinates to viewport-relative coordinates.
Unlike point->viewport, this does NOT convert to canvas coordinates -
it just subtracts the viewport's bounding rect offset."
[pt]
(when (some? @viewport-brect)
(gpt/subtract pt @viewport-brect)))
(defn inside-viewport? (defn inside-viewport?
[target] [target]
(dom/is-child? @viewport-ref target)) (dom/is-child? @viewport-ref target))

View File

@@ -54,6 +54,7 @@
[app.main.ui.workspace.viewport.viewport-ref :refer [create-viewport-ref]] [app.main.ui.workspace.viewport.viewport-ref :refer [create-viewport-ref]]
[app.main.ui.workspace.viewport.widgets :as widgets] [app.main.ui.workspace.viewport.widgets :as widgets]
[app.render-wasm.api :as wasm.api] [app.render-wasm.api :as wasm.api]
[app.render-wasm.text-editor-input :refer [text-editor-input]]
[app.util.debug :as dbg] [app.util.debug :as dbg]
[app.util.text-editor :as ted] [app.util.text-editor :as ted]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
@@ -407,7 +408,14 @@
(when picking-color? (when picking-color?
[:> pixel-overlay/pixel-overlay-wasm* {:viewport-ref viewport-ref [:> pixel-overlay/pixel-overlay-wasm* {:viewport-ref viewport-ref
:canvas-ref canvas-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 {:id "render" [:canvas {:id "render"
:data-testid "canvas-wasm-shapes" :data-testid "canvas-wasm-shapes"
@@ -452,7 +460,10 @@
:height (max 0 (- (:height vbox) rule-area-size))}]]] :height (max 0 (- (:height vbox) rule-area-size))}]]]
[:g {:style {:pointer-events (if disable-events? "none" "auto")}} [:g {:style {:pointer-events (if disable-events? "none" "auto")}}
(when show-text-editor? ;; 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") (if (features/active-feature? @st/state "text-editor/v2")
[:& editor-v2/text-editor {:shape editing-shape [:& editor-v2/text-editor {:shape editing-shape
:canvas-ref canvas-ref :canvas-ref canvas-ref

View File

@@ -39,6 +39,7 @@
[app.render-wasm.serializers :as sr] [app.render-wasm.serializers :as sr]
[app.render-wasm.serializers.color :as sr-clr] [app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.svg-filters :as svg-filters] [app.render-wasm.svg-filters :as svg-filters]
[app.render-wasm.text-editor :as text-editor]
[app.render-wasm.wasm :as wasm] [app.render-wasm.wasm :as wasm]
[app.util.debug :as dbg] [app.util.debug :as dbg]
[app.util.dom :as dom] [app.util.dom :as dom]
@@ -74,6 +75,18 @@
;; Threshold below which we use synchronous processing (no chunking overhead) ;; Threshold below which we use synchronous processing (no chunking overhead)
(def ^:const ASYNC_THRESHOLD 100) (def ^:const ASYNC_THRESHOLD 100)
;; Re-export public WebGL functions
(def capture-canvas-pixels webgl/capture-canvas-pixels)
(def restore-previous-canvas-pixels webgl/restore-previous-canvas-pixels)
(def clear-canvas-pixels webgl/clear-canvas-pixels)
;; 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-point text-editor/text-editor-set-cursor-from-point)
(def text-editor-is-active? text-editor/text-editor-is-active?)
(def text-editor-sync-content text-editor/text-editor-sync-content)
(def dpr (def dpr
(if use-dpr? (if (exists? js/window) js/window.devicePixelRatio 1.0) 1.0)) (if use-dpr? (if (exists? js/window) js/window.devicePixelRatio 1.0) 1.0))
@@ -109,11 +122,36 @@
(mf/element object-svg #js {:shape shape}) (mf/element object-svg #js {:shape shape})
(rds/renderToStaticMarkup))) (rds/renderToStaticMarkup)))
;; forward declare helpers so render can call them
(declare request-render)
(declare set-shape-vertical-align fonts-from-text-content)
;; This should never be called from the outside. ;; This should never be called from the outside.
(defn- render (defn- render
[timestamp] [timestamp]
(when (and wasm/context-initialized? (not @wasm/context-lost?)) (when (and wasm/context-initialized? (not @wasm/context-lost?))
(h/call wasm/internal-module "_render" timestamp) (h/call wasm/internal-module "_render" timestamp)
;; Update text editor blink (so cursor toggles) using the same timestamp
(try
(when wasm/context-initialized?
(text-editor/text-editor-update-blink timestamp)
;; Render text editor overlay on top of main canvas (only if feature enabled)
;; Determine if text-editor-wasm feature is active without requiring
;; app.main.features to avoid circular dependency: check runtime and
;; persisted feature sets in the store state.
(let [runtime-features (get @st/state :features-runtime)
enabled-features (get @st/state :features)]
(when (or (contains? runtime-features "text-editor-wasm/v1")
(contains? enabled-features "text-editor-wasm/v1"))
(text-editor/text-editor-render-overlay)))
;; Poll for editor events; if any event occurs, trigger a re-render
(let [ev (text-editor/text-editor-poll-event)]
(when (and ev (not= ev 0))
(request-render "text-editor-event"))))
(catch :default e
(js/console.error "text-editor overlay/update failed:" e)))
(set! wasm/internal-frame-id nil) (set! wasm/internal-frame-id nil)
(ug/dispatch! (ug/event "penpot:wasm:render")))) (ug/dispatch! (ug/event "penpot:wasm:render"))))
@@ -187,25 +225,6 @@
(declare get-text-dimensions) (declare get-text-dimensions)
(defn update-text-rect!
[id]
(when wasm/context-initialized?
(let [dimensions (get-text-dimensions id)
page-id (:current-page-id @st/state)]
(mw/emit!
{:cmd :index/update-text-rect
:page-id page-id
:shape-id id
:dimensions dimensions}))))
(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 use-shape (defn use-shape
[id] [id]
(when wasm/context-initialized? (when wasm/context-initialized?
@@ -216,6 +235,47 @@
(aget buffer 2) (aget buffer 2)
(aget buffer 3))))) (aget buffer 3)))))
(defn set-shape-text-content
"This function sets shape text content and returns a stream that loads the needed fonts asynchronously"
[shape-id content]
;; Cache content for text editor sync
(text-editor/cache-shape-text-content! shape-id content)
(h/call wasm/internal-module "_clear_shape_text")
(set-shape-vertical-align (get content :vertical-align))
(let [fonts (f/get-content-fonts content)
fallback-fonts (fonts-from-text-content content true)
all-fonts (concat fonts fallback-fonts)
result (f/store-fonts all-fonts)]
(f/load-fallback-fonts-for-editor! fallback-fonts)
(h/call wasm/internal-module "_update_shape_text_layout")
result))
(defn apply-style-to-selection
"Apply style attrs to the currently selected text spans.
Updates the cached content, pushes to WASM, and returns {:shape-id :content} for saving."
[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 (defn set-parent-id
[id] [id]
(let [buffer (uuid/get-u32 id)] (let [buffer (uuid/get-u32 id)]
@@ -859,22 +919,6 @@
(if fallback-fonts-only? updated-fonts fallback-fonts)))))) (if fallback-fonts-only? updated-fonts fallback-fonts))))))
(defn set-shape-text-content
"This function sets shape text content and returns a stream that loads the needed fonts asynchronously"
[shape-id content]
(h/call wasm/internal-module "_clear_shape_text")
(set-shape-vertical-align (get content :vertical-align))
(let [fonts (f/get-content-fonts content)
fallback-fonts (fonts-from-text-content content true)
all-fonts (concat fonts fallback-fonts)
result (f/store-fonts all-fonts)]
(f/load-fallback-fonts-for-editor! fallback-fonts)
(f/update-text-layout shape-id)
result))
(defn set-shape-grow-type (defn set-shape-grow-type
[grow-type] [grow-type]
(h/call wasm/internal-module "_set_shape_grow_type" (sr/translate-grow-type grow-type))) (h/call wasm/internal-module "_set_shape_grow_type" (sr/translate-grow-type grow-type)))
@@ -1557,33 +1601,41 @@
(persistent! result))) (persistent! result)))
result result
(->> result (into []
(mapv (keep
(fn [{:keys [paragraph span start-pos end-pos direction x y width height]}] (fn [{:keys [paragraph span start-pos end-pos direction x y width height]}]
(let [content (:content shape) (let [content (:content shape)
element (-> content :children element (-> content :children
(get 0) :children ;; paragraph-set (get 0) :children ;; paragraph-set
(get paragraph) :children ;; paragraph (get paragraph) :children ;; paragraph
(get span)) (get span))
text (subs (:text element) start-pos end-pos)] element-text (:text element)]
(d/patch-object ;; Add comprehensive nil-safety checks
txt/default-text-attrs (when (and element
(d/without-nils element-text
{:x x (>= start-pos 0)
:y (+ y height) (<= end-pos (count element-text))
:width width (<= start-pos end-pos))
:height height (let [text (subs element-text start-pos end-pos)]
:direction (dr/translate-direction direction) (d/patch-object
:font-family (get element :font-family) txt/default-text-attrs
:font-size (get element :font-size) (d/without-nils
:font-weight (get element :font-weight) {:x x
:text-transform (get element :text-transform) :y (+ y height)
:text-decoration (get element :text-decoration) :width width
:letter-spacing (get element :letter-spacing) :height height
:font-style (get element :font-style) :direction (dr/translate-direction direction)
:fills (d/nilv (get element :fills) [{:fill-color "#000000"}]) :font-family (get element :font-family)
:text text}))))))] :font-size (get element :font-size)
:font-weight (get element :font-weight)
:text-transform (get element :text-transform)
:text-decoration (get element :text-decoration)
:letter-spacing (get element :letter-spacing)
:font-style (get element :font-style)
:fills (get element :fills)
:text text})))))))
result)]
(mem/free) (mem/free)
result))) result)))
@@ -1617,7 +1669,4 @@
(p/resolved false))))) (p/resolved false)))))
(p/resolved false)))) (p/resolved false))))
;; Re-export public WebGL functions
(def capture-canvas-pixels webgl/capture-canvas-pixels)
(def restore-previous-canvas-pixels webgl/restore-previous-canvas-pixels)
(def clear-canvas-pixels webgl/clear-canvas-pixels)

View File

@@ -320,7 +320,7 @@
:style-name style :style-name style
:weight weight :weight weight
:emoji? emoji? :emoji? emoji?
:fallbck? fallback? :fallback? fallback?
:asset-id asset-id})) :asset-id asset-id}))
(defn store-font (defn store-font

View File

@@ -240,3 +240,12 @@ export const RawGrowType = {
"auto-height": 2, "auto-height": 2,
}; };
export const CursorDirection = {
"backward": 0,
"forward": 1,
"line-before": 2,
"line-after": 3,
"line-start": 4,
"line-end": 5,
};

View File

@@ -61,6 +61,29 @@
[] []
(h/call wasm/internal-module "_free_bytes")) (h/call wasm/internal-module "_free_bytes"))
(defn read-string
"Read a UTF-8 string from WASM memory given a byte pointer/offset.
Uses Emscripten's UTF8ToString to decode the string."
[ptr]
(h/call wasm/internal-module "UTF8ToString" ptr))
(defn read-null-terminated-string
"Read a null-terminated UTF-8 string from WASM memory.
Manually reads bytes until null terminator and decodes using TextDecoder."
[ptr]
(when (and ptr (not (zero? ptr)))
(let [heap (get-heap-u8)
;; Find the null terminator
end-idx (loop [idx ptr]
(if (zero? (aget heap idx))
idx
(recur (inc idx))))
;; Extract the bytes (excluding null terminator)
bytes (.slice heap ptr end-idx)
;; Decode using TextDecoder
decoder (js/TextDecoder. "utf-8")]
(.decode decoder bytes))))
(defn slice (defn slice
"Returns a copy of a portion of a typed array into a new typed array "Returns a copy of a portion of a typed array into a new typed array
object selected from start to end." object selected from start to end."

View File

@@ -0,0 +1,300 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.render-wasm.text-editor
"Text editor WASM bindings"
(:require
[app.common.uuid :as uuid]
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
[app.render-wasm.wasm :as wasm]))
(defn text-editor-start
[id]
(when wasm/context-initialized?
(let [buffer (uuid/get-u32 id)]
(h/call wasm/internal-module "_text_editor_start"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3)))))
(defn text-editor-set-cursor-from-point
[x y]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y)))
(defn text-editor-update-blink
[timestamp-ms]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_update_blink" timestamp-ms)))
(defn text-editor-render-overlay
[]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_render_overlay")))
(defn text-editor-poll-event
[]
(when wasm/context-initialized?
(let [res (h/call wasm/internal-module "_text_editor_poll_event")]
res)))
(defn text-editor-insert-text
[text]
(when wasm/context-initialized?
(let [encoder (js/TextEncoder.)
buf (.encode encoder text)
heapu8 (mem/get-heap-u8)
size (mem/size buf)
offset (mem/alloc size)]
(mem/write-buffer offset heapu8 buf)
(h/call wasm/internal-module "_text_editor_insert_text")
(mem/free))))
(defn text-editor-delete-backward []
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_delete_backward")))
(defn text-editor-delete-forward []
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_delete_forward")))
(defn text-editor-insert-paragraph []
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_insert_paragraph")))
(defn text-editor-move-cursor
[direction extend-selection]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_move_cursor" direction (if extend-selection 1 0))))
(defn text-editor-select-all
[]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_select_all")))
(defn text-editor-stop
[]
(when wasm/context-initialized?
(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")))))
(defn text-editor-export-content
[]
(when wasm/context-initialized?
(let [ptr (h/call wasm/internal-module "_text_editor_export_content")]
(when (and ptr (not (zero? ptr)))
(let [json-str (mem/read-null-terminated-string ptr)]
(mem/free)
(js/JSON.parse json-str))))))
(defn text-editor-export-selection
"Export only the currently selected text as plain text from the WASM editor. Requires WASM support (_text_editor_export_selection)."
[]
(when wasm/context-initialized?
(let [ptr (h/call wasm/internal-module "_text_editor_export_selection")]
(when (and ptr (not (zero? ptr)))
(let [text (mem/read-null-terminated-string ptr)]
(mem/free)
text)))))
(defn text-editor-get-active-shape-id
[]
(when wasm/context-initialized?
(try
(let [byte-offset (mem/alloc 16)
u32-offset (mem/->offset-32 byte-offset)
heap (mem/get-heap-u32)]
(h/call wasm/internal-module "_text_editor_get_active_shape_id" byte-offset)
(let [a (aget heap u32-offset)
b (aget heap (+ u32-offset 1))
c (aget heap (+ u32-offset 2))
d (aget heap (+ u32-offset 3))
result (when (or (not= a 0) (not= b 0) (not= c 0) (not= d 0))
(uuid/from-unsigned-parts a b c d))]
(mem/free)
result))
(catch js/Error e
(js/console.error "[text-editor-get-active-shape-id] Error:" e)
nil))))
(defn text-editor-get-selection
[]
(when wasm/context-initialized?
(let [byte-offset (mem/alloc 16)
u32-offset (mem/->offset-32 byte-offset)
heap (mem/get-heap-u32)
active? (h/call wasm/internal-module "_text_editor_get_selection" byte-offset)]
(try
(when (= active? 1)
{:anchor-para (aget heap u32-offset)
:anchor-offset (aget heap (+ u32-offset 1))
:focus-para (aget heap (+ u32-offset 2))
:focus-offset (aget heap (+ u32-offset 3))})
(finally
(mem/free))))))
(def ^:private shape-text-contents (atom {}))
(defn- merge-exported-texts-into-content
"Merge exported span texts back into the existing content tree.
The WASM editor may split or merge paragraphs (Enter / Backspace at
paragraph boundary), so the exported structure can differ from the
original. When extra paragraphs or spans appear we clone styling from
the nearest existing sibling; when fewer appear we truncate.
exported-texts vector of vectors [[\"span1\" \"span2\"] [\"p2s1\"]]
content existing Penpot content map (root -> paragraph-set -> …)"
[content exported-texts]
(let [para-set (first (get content :children))
orig-paras (get para-set :children)
num-orig (count orig-paras)
last-orig-para (when (seq orig-paras) (last orig-paras))
template-span (when last-orig-para
(-> last-orig-para :children last))
new-paras
(mapv (fn [para-idx exported-span-texts]
(let [orig-para (if (< para-idx num-orig)
(nth orig-paras para-idx)
(dissoc last-orig-para :children))
orig-spans (get orig-para :children)
num-orig-spans (count orig-spans)
last-orig-span (when (seq orig-spans) (last orig-spans))]
(assoc orig-para :children
(mapv (fn [span-idx new-text]
(let [orig-span (if (< span-idx num-orig-spans)
(nth orig-spans span-idx)
(or last-orig-span template-span))]
(assoc orig-span :text new-text)))
(range (count exported-span-texts))
exported-span-texts))))
(range (count exported-texts))
exported-texts)
new-para-set (assoc para-set :children new-paras)]
(assoc content :children [new-para-set])))
(defn text-editor-sync-content
"Sync text content from the WASM text editor back to the frontend shape.
Exports the current span texts from WASM, merges them into the shape's
cached content tree (preserving per-span styling), and returns the
shape-id and the fully merged content map ready for
v2-update-text-shape-content."
[]
(when (and wasm/context-initialized? (text-editor-is-active?))
(let [shape-id (text-editor-get-active-shape-id)
new-texts (text-editor-export-content)]
(when (and shape-id new-texts)
(let [texts-clj (js->clj new-texts)
content (get @shape-text-contents shape-id)]
(when content
(let [merged (merge-exported-texts-into-content content texts-clj)]
(swap! shape-text-contents assoc shape-id merged)
{:shape-id shape-id
:content merged})))))))
(defn cache-shape-text-content!
[shape-id content]
(when (some? content)
(swap! shape-text-contents assoc shape-id content)))
(defn get-cached-content
[shape-id]
(get @shape-text-contents shape-id))
(defn update-cached-content!
[shape-id content]
(swap! shape-text-contents assoc shape-id content))
(defn- normalize-selection
"Given anchor/focus para+offset, return {:start-para :start-offset :end-para :end-offset}
ordered so start <= end."
[{:keys [anchor-para anchor-offset focus-para focus-offset]}]
(if (or (< anchor-para focus-para)
(and (= anchor-para focus-para) (<= anchor-offset focus-offset)))
{:start-para anchor-para :start-offset anchor-offset
:end-para focus-para :end-offset focus-offset}
{:start-para focus-para :start-offset focus-offset
:end-para anchor-para :end-offset anchor-offset}))
(defn- apply-attrs-to-paragraph
"Apply attrs to spans within [sel-start, sel-end) char range of a single paragraph.
Splits spans at boundaries as needed."
[para sel-start sel-end attrs]
(let [spans (:children para)
result (loop [spans spans
pos 0
acc []]
(if (empty? spans)
acc
(let [span (first spans)
text (:text span)
span-len (count text)
span-end (+ pos span-len)
ol-start (max pos sel-start)
ol-end (min span-end sel-end)
has-overlap? (< ol-start ol-end)]
(if (not has-overlap?)
(recur (rest spans) span-end (conj acc span))
(let [before (when (> ol-start pos)
(assoc span :text (subs text 0 (- ol-start pos))))
selected (merge span attrs
{:text (subs text (- ol-start pos) (- ol-end pos))})
after (when (< ol-end span-end)
(assoc span :text (subs text (- ol-end pos))))]
(recur (rest spans) span-end
(-> acc
(into (keep identity [before selected after])))))))))]
(assoc para :children result)))
(defn- para-char-count
[para]
(apply + (map (fn [span] (count (:text span))) (:children para))))
(defn apply-style-to-selection
[attrs use-shape-fn set-shape-text-content-fn]
(when (and wasm/context-initialized? (text-editor-is-active?))
(let [shape-id (text-editor-get-active-shape-id)
sel (text-editor-get-selection)]
(when (and shape-id sel)
(let [content (get @shape-text-contents shape-id)]
(when content
(let [{:keys [start-para start-offset end-para end-offset]}
(normalize-selection sel)
collapsed? (and (= start-para end-para) (= start-offset end-offset))
para-set (first (:children content))
paras (:children para-set)
new-paras
(when (not collapsed?)
(mapv (fn [idx para]
(cond
(or (< idx start-para) (> idx end-para))
para
(= start-para end-para)
(apply-attrs-to-paragraph para start-offset end-offset attrs)
(= idx start-para)
(apply-attrs-to-paragraph para start-offset (para-char-count para) attrs)
(= idx end-para)
(apply-attrs-to-paragraph para 0 end-offset attrs)
:else
(apply-attrs-to-paragraph para 0 (para-char-count para) attrs)))
(range (count paras))
paras))
new-content (when new-paras
(assoc content :children
[(assoc para-set :children new-paras)]))]
(when new-content
(swap! shape-text-contents assoc shape-id new-content)
(use-shape-fn shape-id)
(set-shape-text-content-fn shape-id new-content)
{:shape-id shape-id
:content new-content}))))))))

View File

@@ -0,0 +1,240 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.render-wasm.text-editor-input
"Contenteditable DOM element for WASM text editor input"
(:require
[app.common.geom.shapes :as gsh]
[app.main.data.workspace.texts :as dwt]
[app.main.store :as st]
[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]
[goog.events :as events]
[rumext.v2 :as mf])
(:import goog.events.EventType))
(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?))))
(mf/defc text-editor-input
"Contenteditable element positioned over the text shape to capture input events."
{::mf/wrap-props false}
[props]
(let [shape (obj/get props "shape")
zoom (obj/get props "zoom")
vbox (obj/get props "vbox")
contenteditable-ref (mf/use-ref nil)
composing? (mf/use-state false)
;; Calculate screen position from shape bounds
shape-bounds (gsh/shape->rect shape)
screen-x (* (- (:x shape-bounds) (:x vbox)) zoom)
screen-y (* (- (:y shape-bounds) (:y vbox)) zoom)
screen-w (* (:width shape-bounds) zoom)
screen-h (* (:height shape-bounds) zoom)]
;; Focus contenteditable on mount
(mf/use-effect
(fn []
(when-let [node (mf/ref-val contenteditable-ref)]
(.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)
(fn []
(when @raf-id
(js/cancelAnimationFrame @raf-id))))))
;; Document-level keydown handler for control keys
(mf/use-effect
(fn []
(let [on-doc-keydown
(fn [e]
(when (and (text-editor/text-editor-is-active?)
(not @composing?))
(let [key (.-key e)
ctrl? (or (.-ctrlKey e) (.-metaKey e))
shift? (.-shiftKey e)]
(cond
;; Escape: finalize and stop
(= key "Escape")
(do
(dom/prevent-default e)
(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 e)
(text-editor/text-editor-select-all)
(wasm.api/request-render "text-select-all"))
;; Enter
(= key "Enter")
(do
(dom/prevent-default e)
(text-editor/text-editor-insert-paragraph)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-paragraph"))
;; Backspace
(= key "Backspace")
(do
(dom/prevent-default e)
(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 e)
(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 e)
(text-editor/text-editor-move-cursor 0 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "ArrowRight")
(do
(dom/prevent-default e)
(text-editor/text-editor-move-cursor 1 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "ArrowUp")
(do
(dom/prevent-default e)
(text-editor/text-editor-move-cursor 2 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "ArrowDown")
(do
(dom/prevent-default e)
(text-editor/text-editor-move-cursor 3 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "Home")
(do
(dom/prevent-default e)
(text-editor/text-editor-move-cursor 4 shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "End")
(do
(dom/prevent-default e)
(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))))]
(events/listen js/document EventType.KEYDOWN on-doc-keydown true)
(fn []
(events/unlisten js/document EventType.KEYDOWN on-doc-keydown true)))))
;; Composition and input events
(let [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-input
(mf/use-fn
(fn [^js 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) ""))))))]
[:div
{:ref contenteditable-ref
:contentEditable true
:suppressContentEditableWarning true
:on-composition-start on-composition-start
:on-composition-end on-composition-end
:on-input on-input
:on-paste on-paste
:on-copy on-copy
;; FIXME on-click
;; :on-click on-click
:id "text-editor-wasm-input"
;; FIXME
:style {:position "absolute"
:left (str screen-x "px")
:top (str screen-y "px")
:width (str screen-w "px")
:height (str screen-h "px")
:opacity 0
:overflow "hidden"
:white-space "pre"
:cursor "text"
:z-index 10}}])))

View File

@@ -82,6 +82,26 @@ The `TextEditor` contains a series of references to DOM elements, one of them is
`ChangeController` is called by the `TextEditor` instance everytime a change is performed on the content of the `contenteditable` element. `ChangeController` is called by the `TextEditor` instance everytime a change is performed on the content of the `contenteditable` element.
### Best practices
#### Use `isType` functions
Instead of handling elements by their properties like this:
```javascript
if (element.tagName === "SPAN") {
...
}
```
Use functions like `isParagraph`, `isTextSpan` or `isLineBreak`:
```javascript
if (isTextSpan(element)) {
...
}
```
### Events ### Events
- `change`: This event is dispatched every time a change is made in the editor. All changes are debounced to prevent dispatching too many change events. This event is also dispatched when there are pending change events and the user blurs the textarea element. - `change`: This event is dispatched every time a change is made in the editor. All changes are debounced to prevent dispatching too many change events. This event is also dispatched when there are pending change events and the user blurs the textarea element.

View File

@@ -326,9 +326,7 @@ export class TextEditor extends EventTarget {
* @param {FocusEvent} e * @param {FocusEvent} e
*/ */
#onBlur = (e) => { #onBlur = (e) => {
if (!this.isEmpty) { this.#changeController.notifyImmediately();
this.#changeController.notifyImmediately();
}
this.#selectionController.saveSelection(); this.#selectionController.saveSelection();
this.dispatchEvent(new FocusEvent(e.type, e)); this.dispatchEvent(new FocusEvent(e.type, e));
}; };
@@ -685,7 +683,7 @@ export function createRootFromString(string) {
* Returns true if the passed object is a TextEditor * Returns true if the passed object is a TextEditor
* instance. * instance.
* *
* @param {TextEditor} instance * @param {*} instance
* @returns {boolean} * @returns {boolean}
*/ */
export function isTextEditor(instance) { export function isTextEditor(instance) {
@@ -716,7 +714,7 @@ export function getRoot(instance) {
if (isTextEditor(instance)) { if (isTextEditor(instance)) {
return instance.root; return instance.root;
} }
throw new TypeError("Instance is not a TextEditor"); return null;
} }
/** /**
@@ -756,7 +754,7 @@ export function getCurrentStyle(instance) {
if (isTextEditor(instance)) { if (isTextEditor(instance)) {
return instance.currentStyle; return instance.currentStyle;
} }
throw new TypeError("Instance is not a TextEditor"); throw new TypeError('Instance is not a TextEditor');
} }
/** /**
@@ -771,7 +769,7 @@ export function applyStylesToSelection(instance, styles) {
if (isTextEditor(instance)) { if (isTextEditor(instance)) {
return instance.applyStylesToSelection(styles); return instance.applyStylesToSelection(styles);
} }
throw new TypeError("Instance is not a TextEditor"); throw new TypeError('Instance is not a TextEditor');
} }
/** /**
@@ -785,7 +783,7 @@ export function dispose(instance) {
if (isTextEditor(instance)) { if (isTextEditor(instance)) {
return instance.dispose(); return instance.dispose();
} }
throw new TypeError("Instance is not a TextEditor"); throw new TypeError('Instance is not a TextEditor');
} }
export default TextEditor; export default TextEditor;

View File

@@ -336,20 +336,22 @@ export function getStyle(element, styleName, styleUnit) {
* @returns {HTMLElement} * @returns {HTMLElement}
*/ */
export function setStylesFromObject(element, allowedStyles, styleObject) { export function setStylesFromObject(element, allowedStyles, styleObject) {
if (element.tagName === "SPAN") for (const [styleName, styleUnit] of allowedStyles) {
for (const [styleName, styleUnit] of allowedStyles) { if (!(styleName in styleObject)) {
if (!(styleName in styleObject)) { continue;
continue;
}
let styleValue = styleObject[styleName];
if (!styleValue) continue;
if (styleName === "font-family") {
styleValue = sanitizeFontFamily(styleValue);
}
setStyle(element, styleName, styleValue, styleUnit);
} }
let styleValue = styleObject[styleName];
if (!styleValue) {
continue;
}
if (styleName === "font-family") {
styleValue = sanitizeFontFamily(styleValue);
}
setStyle(element, styleName, styleValue, styleUnit);
}
return element; return element;
} }

View File

@@ -1961,7 +1961,8 @@ export class SelectionController extends EventTarget {
this.setSelection(newTextSpan.firstChild, 0, newTextSpan.firstChild, 0); this.setSelection(newTextSpan.firstChild, 0, newTextSpan.firstChild, 0);
} }
// The styles are applied to the paragraph // The styles are applied to the paragraph
else { else
{
const paragraph = this.startParagraph; const paragraph = this.startParagraph;
setParagraphStyles(paragraph, newStyles); setParagraphStyles(paragraph, newStyles);
// Apply styles to child text spans. // Apply styles to child text spans.
@@ -1969,11 +1970,9 @@ export class SelectionController extends EventTarget {
setTextSpanStyles(textSpan, newStyles); setTextSpanStyles(textSpan, newStyles);
} }
} }
return this.#notifyStyleChange(); // If the startContainer and endContainer are different
// then we need to iterate through those nodes to apply
// If the startContainer and endContainer are different // the styles.
// then we need to iterate through those nodes to apply
// the styles.
} else if (startNode !== endNode) { } else if (startNode !== endNode) {
const safeGuard = new SafeGuard("applyStylesTo"); const safeGuard = new SafeGuard("applyStylesTo");
safeGuard.start(); safeGuard.start();
@@ -2022,12 +2021,12 @@ export class SelectionController extends EventTarget {
} }
// We've reached the final node so we can return safely. // We've reached the final node so we can return safely.
if (this.#textNodeIterator.currentNode === expectedEndNode) return; if (this.#textNodeIterator.currentNode === expectedEndNode)
break;
this.#textNodeIterator.nextNode(); this.#textNodeIterator.nextNode();
} while (this.#textNodeIterator.currentNode); } while (this.#textNodeIterator.currentNode);
} }
return this.#notifyStyleChange(); return this.#notifyStyleChange();
} }

View File

@@ -90,32 +90,6 @@ This bootstrap command will:
* build all components (`pnpm -r run build`) * build all components (`pnpm -r run build`)
* start all components (`pnpm -r --parallel run start`) * start all components (`pnpm -r --parallel run start`)
If you want to have types scrapped from a remote repository, the best
approach is executing the following:
```shell
PENPOT_PLUGINS_API_DOC_URL=https://doc.plugins.penpot.app pnpm run build:types
pnpm run bootstrap
```
Or this, if you want skip build step bacause you have already have all
build artifacts ready (per example from previous `bootstrap` command):
```
PENPOT_PLUGINS_API_DOC_URL=https://doc.plugins.penpot.app pnpm run build:types
pnpm run start
```
If you want just to update the types definitions with the plugins api doc from the
current branch:
```shell
pnpm run build:types
```
(That command will build plugins doc locally and will generate the types yaml from
the locally build documentation)
### 2. Load the Plugin in Penpot and Establish the Connection ### 2. Load the Plugin in Penpot and Establish the Connection
> [!NOTE] > [!NOTE]

View File

@@ -5,7 +5,7 @@
"scripts": { "scripts": {
"build": "pnpm -r run build", "build": "pnpm -r run build",
"build:multi-user": "pnpm -r run build:multi-user", "build:multi-user": "pnpm -r run build:multi-user",
"build:types": "./scripts/build-types", "build:types": "bash ./scripts/build-types",
"start": "pnpm -r --parallel run start", "start": "pnpm -r --parallel run start",
"start:multi-user": "pnpm -r --parallel --filter \"./packages/*\" run start:multi-user", "start:multi-user": "pnpm -r --parallel --filter \"./packages/*\" run start:multi-user",
"bootstrap": "pnpm -r install && pnpm run build && pnpm run start", "bootstrap": "pnpm -r install && pnpm run build && pnpm run start",

View File

@@ -25,7 +25,6 @@ export class PenpotUtils {
id: shape.id, id: shape.id,
name: shape.name, name: shape.name,
type: shape.type, type: shape.type,
children: children,
}; };
// add layout information if present // add layout information if present
@@ -48,6 +47,23 @@ export class PenpotUtils {
}; };
} }
// add component instance information if present
if (shape.isComponentInstance()) {
result.componentInstance = {};
const component = shape.component();
if (component) {
result.componentInstance.componentId = component.id;
result.componentInstance.componentName = component.name;
const mainInstance = component.mainInstance();
if (mainInstance) {
result.componentInstance.mainInstanceId = mainInstance.id;
}
}
}
// finally, add children (last for more readable nesting order)
result.children = children;
return result; return result;
} }
@@ -55,9 +71,9 @@ export class PenpotUtils {
* Finds all shapes that matches the given predicate in the given shape tree. * Finds all shapes that matches the given predicate in the given shape tree.
* *
* @param predicate - A function that takes a shape and returns true if it matches the criteria * @param predicate - A function that takes a shape and returns true if it matches the criteria
* @param root - The root shape to start the search from (defaults to penpot.root) * @param root - The root shape to start the search from (if null, searches all pages)
*/ */
public static findShapes(predicate: (shape: Shape) => boolean, root: Shape | null = penpot.root): Shape[] { public static findShapes(predicate: (shape: Shape) => boolean, root: Shape | null = null): Shape[] {
let result = new Array<Shape>(); let result = new Array<Shape>();
let find = function (shape: Shape | null) { let find = function (shape: Shape | null) {
@@ -74,7 +90,16 @@ export class PenpotUtils {
} }
}; };
find(root); if (root === null) {
const pages = penpot.currentFile?.pages;
if (pages) {
for (let page of pages) {
find(page.root);
}
}
} else {
find(root);
}
return result; return result;
} }
@@ -422,4 +447,94 @@ export class PenpotUtils {
throw new Error(`Unsupported export mode: ${mode}`); throw new Error(`Unsupported export mode: ${mode}`);
} }
} }
/**
* Finds all tokens that match the given name across all token sets.
*
* @param name - The name of the token to search for (case-sensitive exact match)
* @returns An array of all matching tokens (may be empty)
*/
public static findTokensByName(name: string): any[] {
const tokens: any[] = [];
// @ts-ignore
const tokenCatalog = penpot.library.local.tokens;
for (const set of tokenCatalog.sets) {
for (const token of set.tokens) {
if (token.name === name) {
tokens.push(token);
}
}
}
return tokens;
}
/**
* Finds the first token that matches the given name across all token sets.
*
* @param name - The name of the token to search for (case-sensitive exact match)
* @returns The first matching token, or null if not found
*/
public static findTokenByName(name: string): any | null {
// @ts-ignore
const tokenCatalog = penpot.library.local.tokens;
for (const set of tokenCatalog.sets) {
for (const token of set.tokens) {
if (token.name === name) {
return token;
}
}
}
return null;
}
/**
* Gets the token set that contains the given token.
*
* @param token - The token whose set to find
* @returns The TokenSet containing this token, or null if not found
*/
public static getTokenSet(token: any): any | null {
// @ts-ignore
const tokenCatalog = penpot.library.local.tokens;
for (const set of tokenCatalog.sets) {
if (set.tokens.includes(token)) {
return set;
}
}
return null;
}
/**
* Generates an overview of all tokens organized by token set name, token type, and token name.
* The result is a nested object structure: {tokenSetName: {tokenType: [tokenName, ...]}}.
*
* @returns An object mapping token set names to objects that map token types to arrays of token names
*/
public static tokenOverview(): Record<string, Record<string, string[]>> {
const overview: Record<string, Record<string, string[]>> = {};
// @ts-ignore
const tokenCatalog = penpot.library.local.tokens;
for (const set of tokenCatalog.sets) {
const setOverview: Record<string, string[]> = {};
for (const token of set.tokens) {
const tokenType = token.type;
if (!setOverview[tokenType]) {
setOverview[tokenType] = [];
}
setOverview[tokenType].push(token.name);
}
overview[set.name] = setOverview;
}
return overview;
}
} }

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,333 @@
You have access to Penpot tools in order to interact with a Penpot design project directly.
As a precondition, the user must connect the Penpot design project to the MCP server using the Penpot MCP Plugin.
IMPORTANT: When transferring styles from a Penpot design to code, make sure that you strictly adhere to the design.
NEVER make assumptions about missing values and don't get overly creative (e.g. don't pick your own colours and stick to
non-creative defaults such as white/black if you are lacking information).
# Executing Code
One of your key tools is the `execute_code` tool, which allows you to run JavaScript code using the Penpot Plugin API
directly in the connected project.
VERY IMPORTANT: When writing code, NEVER LOG INFORMATION YOU ARE ALSO RETURNING. It would duplicate the information you receive!
To execute code correctly, you need to understand the Penpot Plugin API. You can retrieve API documentation via
the `penpot_api_info` tool.
This is the full list of types/interfaces in the Penpot API: $api_types
You use the `storage` object extensively to store data and utility functions you define across tool calls.
This allows you to inspect intermediate results while still being able to build on them in subsequent code executions.
# The Structure of Penpot Designs
A Penpot design ultimately consists of shapes.
The type `Shape` is a union type, which encompasses both containers and low-level shapes.
Shapes in a Penpot design are organized hierarchically.
At the top level, a design project contains one or more `Page` objects.
Each `Page` contains a tree of elements. For a given instance `page`, its root shape is `page.root`.
A Page is frequently structured into boards. A `Board` is a high-level grouping element.
A `Group` is a more low-level grouping element used to organize low-level shapes into a logical unit.
Actual low-level shape types are `Rectangle`, `Path`, `Text`, `Ellipse`, `Image`, `Boolean`, and `SvgRaw`.
`ShapeBase` is a base type most shapes build upon.
# Core Shape Properties and Methods
**Type**:
Any given shape contains information on the concrete type via its `type` field.
**Position and Dimensions**:
* The location properties `x` and `y` refer to the top left corner of a shape's bounding box in the absolute (Page) coordinate system.
These are writable - set them directly to position shapes.
* `parentX` and `parentY` (as well as `boardX` and `boardY`) are READ-ONLY computed properties showing position relative to parent/board.
To position relative to parent, use `penpotUtils.setParentXY(shape, parentX, parentY)` or manually set `shape.x = parent.x + parentX`.
* `width` and `height` are READ-ONLY. Use `resize(width, height)` method to change dimensions.
* `bounds` is a READ-ONLY property. Use `x`, `y` with `resize()` to modify shape bounds.
**Other Writable Properties**:
* `name` - Shape name
* `fills`, `strokes` - Styling properties
* `rotation`, `opacity`, `blocked`, `hidden`, `visible`
**Z-Order**:
* The z-order of shapes is determined by the order in the `children` array of the parent shape.
Therefore, when creating shapes that should be on top of each other, add them to the parent in the correct order
(i.e. add background shapes first, then foreground shapes later).
CRITICAL: NEVER use the broken function `appendChild` to achieve this, ALWAYS use `parent.insertChild(parent.children.length, shape)`
* To modify z-order after creation, use these methods: `bringToFront()`, `sendToBack()`, `bringForward()`, `sendBackward()`,
and, for precise control, `setParentIndex(index)` (0-based).
**Modification Methods**:
* `resize(width, height)` - Change dimensions (required for width/height since they're read-only)
* `rotate(angle, center?)` - Rotate shape
* `remove()` - Permanently destroy the shape (use only for deletion, NOT for reparenting).
Exception: When the shape is a descendant of a board that is a component (asset), the shape will not be removed but instead be made invisible.
**Hierarchical Structure**:
* `parent` - The parent shape (null for root shapes)
Note: Hierarchical nesting does not necessarily imply visual containment
* CRITICAL: To add children to a parent shape (e.g. a `Board`):
- ALWAYS use `parent.insertChild(index, shape)` to add a child, e.g. `parent.insertChild(parent.children.length, shape)` to append
- NEVER use `parent.appendChild(shape)` as it is BROKEN and will not insert in a predictable place (except in flex layout boards)
* Reparenting: `newParent.appendChild(shape)` or `newParent.insertChild(index, shape)` will move a shape to new parent
- Automatically removes the shape from its old parent
- Absolute x/y positions are preserved (use `penpotUtils.setParentXY` to adjust relative position)
Cloning: Use `shape.clone(): Shape` to create an exact duplicate (including all properties and children) of a shape; same position as original.
# Images
The `Image` type is a legacy type. Images are now typically embedded in a `Fill`, with `fillImage` set to an
`ImageData` object, i.e. the `fills` property of of a shape (e.g. a `Rectangle`) will contain a fill where `fillImage` is set.
Use the `export_shape` and `import_image` tools to export and import images.
# Layout Systems
Boards can have layout systems that automatically control the positioning and spacing of their children:
* If a board has a layout system, then child positions are controlled by the layout system.
For every child, key properties of the child within the layout are stored in `child.layoutChild: LayoutChildProperties`:
- `absolute: boolean` - if true, child position is not controlled by layout system. x/y will set *relative* position within parent!
- margins (`topMargin`, `rightMargin`, `bottomMargin`, `leftMargin` or combined `verticalMargin`, `horizontalMargin`)
- sizing (`verticalSizing`, `horizontalSizing`: "fill" | "auto" | "fix")
- min/max sizes (`minWidth`, `maxWidth`, `minHeight`, `maxHeight`)
- `zIndex: number` (higher numbers on top)
* **Flex Layout**: A flexbox-style layout system
- Properties: `dir`, `rowGap`, `columnGap`, `alignItems`, `justifyContent`;
- `dir`: "row" | "column" | "row-reverse" | "column-reverse"
- Padding: `topPadding`, `rightPadding`, `bottomPadding`, `leftPadding`, or combined `verticalPadding`, `horizontalPadding`
- To modify spacing: adjust `rowGap` and `columnGap` properties, not individual child positions.
Optionally, adjust indivudual child margins via `child.layoutChild`.
- When a board has flex layout,
- child positions are controlled by the layout system, not by individual x/y coordinates (unless `child.layoutChild.absolute` is true);
appending or inserting children automatically positions them according to the layout rules.
- CRITICAL: For for dir="column" or dir="row", the order of the `children` array is reversed relative to the visual order!
Therefore, the element that appears first in the array, appears visually at the end (bottom/right) and vice versa.
ALWAYS BEAR IN MIND THAT THE CHILDREN ARRAY ORDER IS REVERSED FOR dir="column" OR dir="row"!
- CRITICAL: The FlexLayout method `board.flex.appendChild` is BROKEN. To append children to a flex layout board such that
they appear visually at the end, ALWAYS use the Board's method `board.appendChild(shape)`; it will insert at the front
of the `children` array for dir="column" or dir="row", which is what you want. So call it in the order of visual appearance.
To insert at a specific index, use `board.insertChild(index, shape)`, bearing in mind the reversed order for dir="column"
or dir="row".
- Add to a board with `board.addFlexLayout(): FlexLayout`; instance then accessible via `board.flex`.
IMPORTANT: When adding a flex layout to a container that already has children,
use `penpotUtils.addFlexLayout(container, dir)` instead! This preserves the existing visual order of children.
Otherwise, children will be arbitrarily reordered when the children order suddenly determines the display order.
- Check with: `if (board.flex) { ... }`
* **Grid Layout**: A CSS grid-style layout system
- Add to a board with `board.addGridLayout(): GridLayout`; instance then accessibly via `board.grid`;
Check with: `if (board.grid) { ... }`
- Properties: `rows`, `columns`, `rowGap`, `columnGap`
- Children are positioned via 1-based row/column indices
- Add to grid via `board.flex.appendChild(shape, row, column)`
- Modify grid positioning after the fact via `shape.layoutCell: LayoutCellProperties`
* When working with boards:
- ALWAYS check if the board has a layout system before attempting to reposition children
- Modify layout properties (gaps, padding) instead of trying to set child x/y positions directly
- Layout systems override manual positioning of children
# Text Elements
The rendered content of `Text` element is given by the `characters` property.
To change the size of the text, change the `fontSize` property; applying `resize()` does NOT change the font size,
it only changes the formal bounding box; if the text does not fit it, it will overflow.
The bounding box is sized automatically as long as the `growType` property is set to "auto-width" or "auto-height".
`resize` always sets `growType` to "fixed", so ALWAYS set it back to "auto-*" if you want automatic sizing - otherwise the bounding box will be meaningless, with the text overflowing!
The auto-sizing is not immediate; sleep for a short time (100ms) if you want to read the updated bounding box.
# The `penpot` and `penpotUtils` Objects, Exploring Designs
A key object to use in your code is the `penpot` object (which is of type `Penpot`):
* `penpot.selection` provides the list of shapes the user has selected in the Penpot UI.
If it is unclear which elements to work on, you can ask the user to select them for you.
ALWAYS immediately copy the selected shape(s) into `storage`! Do not assume that the selection remains unchanged.
* `penpot.root` provides the root shape of the currently active page.
* Generation of CSS content for elements via `penpot.generateStyle`
* Generation of HTML/SVG content for elements via `penpot.generateMarkup`
For example, to generate CSS for the currently selected elements, you can execute this:
return penpot.generateStyle(penpot.selection, { type: "css", withChildren: true });
CRITICAL: The `penpotUtils` object provides essential utilities - USE THESE INSTEAD OF WRITING YOUR OWN:
* getPages(): { id: string; name: string }[]
* getPageById(id: string): Page | null
* getPageByName(name: string): Page | null
* shapeStructure(shape: Shape, maxDepth: number | undefined = undefined): { id, name, type, children?, layout? }
Generates an overview structure of the given shape.
- children: recursive, limited by maxDepth
- layout: present if shape has flex/grid layout, contains { type: "flex" | "grid", ... }
* findShapeById(id: string): Shape | null
* findShape(predicate: (shape: Shape) => boolean, root: Shape | null = null): Shape | null
If no root is provided, search globally (in all pages).
* findShapes(predicate: (shape: Shape) => boolean, root: Shape | null = null): Shape[]
* isContainedIn(shape: Shape, container: Shape): boolean
Returns true iff shape is fully within the container's geometric bounds.
Note that a shape's bounds may not always reflect its actual visual content - descendants can overflow; check using analyzeDescendants (see below).
* setParentXY(shape: Shape, parentX: number, parentY: number): void
Sets shape position relative to its parent (since parentX/parentY are read-only)
* analyzeDescendants<T>(root: Shape, evaluator: (root: Shape, descendant: Shape) => T | null | undefined, maxDepth?: number): Array<{ shape: Shape, result: T }>
General-purpose utility for analyzing/validating descendants
Calls evaluator on each descendant; collects non-null/undefined results
Powerful pattern: evaluator can return corrector functions or diagnostic data
* Further functions for specific tasks (described in the sections below)
General pointers for working with Penpot designs:
* Prefer `penpotUtils` helper functions — avoid reimplementing shape searching.
* To get an overview of a single page, use `penpotUtils.shapeStructure(page.root, 3)`.
Note that `penpot.root` refers to the current page only. When working across pages, first determine the relevant page(s).
* Use `penpotUtils.findShapes()` or `penpotUtils.findShape()` with predicates to locate elements efficiently.
Common tasks - Quick Reference (ALWAYS use penpotUtils for these):
* Find all images:
const images = penpotUtils.findShapes(
shape => shape.type === 'image' || shape.fills?.some(fill => fill.fillImage),
penpot.root
);
* Find text elements:
const texts = penpotUtils.findShapes(shape => shape.type === 'text', penpot.root);
* Find (the first) shape with a given name:
const shape = penpotUtils.findShape(shape => shape.name === 'MyShape');
* Get structure of current selection:
const structure = penpotUtils.shapeStructure(penpot.selection[0]);
* Find shapes in current selection/board:
const shapes = penpotUtils.findShapes(predicate, penpot.selection[0] || penpot.root);
* Validate/analyze descendants (returning corrector functions):
const fixes = penpotUtils.analyzeDescendants(board, (root, shape) => {
const xMod = shape.parentX % 4;
if (xMod !== 0) {
return () => penpotUtils.setParentXY(shape, Math.round(shape.parentX / 4) * 4, shape.parentY);
}
});
fixes.forEach(f => f.result()); // Apply all fixes
* Find containment violations:
const violations = penpotUtils.analyzeDescendants(board, (root, shape) => {
return !penpotUtils.isContainedIn(shape, root) ? 'outside-bounds' : null;
});
Always validate against the root container that is supposed to contain the shapes.
# Visual Inspection of Designs
For many tasks, it can be critical to visually inspect the design. Remember to use the `export_shape` tool for this purpose!
# Revising Designs
* Before applying design changes, ask: "Would a designer consider this appropriate?"
* When dealing with containment issues, ask: Is the parent too small OR is the child too large?
Container sizes are usually intentional, check content first.
* Check for reasonable font sizes and typefaces
* The use of flex layouts is encouraged for cases where elements are arranged in rows or columns with consistent spacing/positioning.
Consider converting boards to flex layout when appropriate.
# Asset Libraries
Libraries in Penpot are collections of reusable design assets (components, colors, and typographies) that can be shared across files.
They enable design systems and consistent styling across projects.
Each Penpot file has its own local library and can connect to external shared libraries.
Accessing libraries: via `penpot.library` (type: `LibraryContext`):
* `penpot.library.local` (type: `Library`) - The current file's own library
* `penpot.library.connected` (type: `Library[]`) - Array of already-connected external libraries
* `penpot.library.availableLibraries()` (returns: `Promise<LibrarySummary[]>`) - Libraries available to connect
* `penpot.library.connectLibrary(libraryId: string)` (returns: `Promise<Library>`) - Connect a new library
Each `Library` object has:
* `id: string`
* `name: string`
* `components: LibraryComponent[]` - Array of components
* `colors: LibraryColor[]` - Array of colors
* `typographies: LibraryTypography[]` - Array of typographies
Using library components:
* find a component in the library by name:
const component: LibraryComponent = library.components.find(comp => comp.name.includes('Button'));
* create a new instance of the component on the current page:
const instance: Shape = component.instance();
This returns a `Shape` (often a `Board` containing child elements).
After instantiation, modify the instance's properties as desired.
* get the reference to the main component shape:
const mainShape: Shape = component.mainInstance();
Adding assets to a library:
* const newColor: LibraryColor = penpot.library.local.createColor();
newColor.name = 'Brand Primary';
newColor.color = '#0066FF';
* const newTypo: LibraryTypography = penpot.library.local.createTypography();
newTypo.name = 'Heading Large';
// Set typography properties...
* const shapes: Shape[] = [shape1, shape2]; // shapes to include
const newComponent: LibraryComponent = penpot.library.local.createComponent(shapes);
newComponent.name = 'My Button';
Detaching:
* When creating new design elements based on a component instance/copy, use `shape.detach()` to break the link to the main component, allowing independent modification.
* Without detaching, some manipulations will have no effect; e.g. child/descendant removal will not work.
# Design Tokens
Design tokens are reusable design values (colors, dimensions, typography, etc.) for consistent styling.
The token library: `penpot.library.local.tokens` (type: `TokenCatalog`)
* `sets: TokenSet[]` - Token collections (order matters for precedence)
* `themes: TokenTheme[]` - Presets that activate specific sets
* `addSet(name: string): TokenSet` - Create new set
* `addTheme(group: string, name: string): TokenTheme` - Create new theme
`TokenSet` contains tokens with unique names:
* `active: boolean` - Only active sets affect shapes; use `set.toggleActive()` to change: `if (!set.active) set.toggleActive();`
* `tokens: Token[]` - All tokens in set
* `addToken(type: TokenType, name: string, value: TokenValueString): Token` - Creates a token, adding it to the set.
- `TokenType`: "color" | "dimension" | "spacing" | "typography" | "shadow" | "opacity" | "borderRadius" | "borderWidth" | "fontWeights" | "fontSizes" | "fontFamilies" | "letterSpacing" | "textDecoration" | "textCase"
- Examples:
const token = set.addToken("color", "color.primary", "#0066FF"); // direct value
const token2 = set.addToken("color", "color.accent", "{color.primary}"); // reference to another token
`Token`:
* `name: string` - Token name (may include group path like "color.base.white")
* `value: string | TokenValueString` - Raw value (may be direct value or reference to another token like "{color.primary}")
* `resolvedValue` - Computed final value (follows references)
* `type: TokenType`
Discovering tokens:
* `penpotUtils.tokenOverview()`: Maps from token set name to a mapping from token type to list of token names
* `penpotUtils.findTokenByName(name: string): Token | null`: Finds the first applicable token matching the given name
* `penpotUtils.findTokensByName(name: string): Token[]`: Finds all tokens that match the given name across all token sets
* `penpotUtils.getTokenSet(token: Token): TokenSet | null`: Gets the token set that contains the given token
Applying tokens:
* `shape.applyToken(token, properties: undefined | TokenProperty[])` - Apply a token to a shape for one or more properties
(if properties is undefined, use a default property based on the token type - not usually recommended).
`TokenProperty` is a union type; possible values are:
- "all": applies the token to all properties it can control
- TokenBorderRadiusProps: "r1", "r2", "r3", "r4"
- TokenShadowProps: "shadow"
- TokenColorProps: "fill", "stroke-color"
- TokenDimensionProps: "x", "y", "stroke-width"
- TokenFontFamiliesProps: "font-families"
- TokenFontSizesProps: "font-size"
- TokenFontWeightProps: "font-weight"
- TokenLetterSpacingProps: "letter-spacing"
- TokenNumberProps: "rotation", "line-height"
- TokenOpacityProps: "opacity"
- TokenSizingProps: "width", "height", "layout-item-min-w", "layout-item-max-w", "layout-item-min-h", "layout-item-max-h"
- TokenSpacingProps: "row-gap", "column-gap", "p1", "p2", "p3", "p4", "m1", "m2", "m3", "m4"
- TokenBorderWidthProps: "stroke-width"
- TokenTextCaseProps: "text-case"
- TokenTextDecorationProps: "text-decoration"
- TokenTypographyProps: "typography"
* `token.applyToShapes(shapes, properties)` - Apply from token
* Application is **asynchronous** (wait for ~100ms to see the effects)
* After application:
- `shape.tokens` returns a mapping `{ propertyName: "token.name" }` from `TokenProperty` to token name
- The actual shape properties that the tokens control will reflect the token's resolved value.
Removing tokens:
Simply set the respective property directly - token binding is automatically removed, e.g.
shape.fills = [{ fillColor: "#000000", fillOpacity: 1 }]; // Removes fill token
--
You have hereby read the 'Penpot High-Level Overview' and need not use a tool to read it again.

View File

@@ -1,267 +0,0 @@
# Prompts configuration for Penpot MCP Server
# This file contains various prompts and instructions that can be used by the server
initial_instructions: |
You have access to Penpot tools in order to interact with a Penpot design project directly.
As a precondition, the user must connect the Penpot design project to the MCP server using the Penpot MCP Plugin.
IMPORTANT: When transferring styles from a Penpot design to code, make sure that you strictly adhere to the design.
NEVER make assumptions about missing values and don't get overly creative (e.g. don't pick your own colours and stick to
non-creative defaults such as white/black if you are lacking information).
# Executing Code
One of your key tools is the `execute_code` tool, which allows you to run JavaScript code using the Penpot Plugin API
directly in the connected project.
VERY IMPORTANT: When writing code, NEVER LOG INFORMATION YOU ARE ALSO RETURNING. It would duplicate the information you receive!
To execute code correctly, you need to understand the Penpot Plugin API. You can retrieve API documentation via
the `penpot_api_info` tool.
This is the full list of types/interfaces in the Penpot API: $api_types
You use the `storage` object extensively to store data and utility functions you define across tool calls.
This allows you to inspect intermediate results while still being able to build on them in subsequent code executions.
# The Structure of Penpot Designs
A Penpot design ultimately consists of shapes.
The type `Shape` is a union type, which encompasses both containers and low-level shapes.
Shapes in a Penpot design are organized hierarchically.
At the top level, a design project contains one or more `Page` objects.
Each `Page` contains a tree of elements. For a given instance `page`, its root shape is `page.root`.
A Page is frequently structured into boards. A `Board` is a high-level grouping element.
A `Group` is a more low-level grouping element used to organize low-level shapes into a logical unit.
Actual low-level shape types are `Rectangle`, `Path`, `Text`, `Ellipse`, `Image`, `Boolean`, and `SvgRaw`.
`ShapeBase` is a base type most shapes build upon.
# Core Shape Properties and Methods
**Type**:
Any given shape contains information on the concrete type via its `type` field.
**Position and Dimensions**:
* The location properties `x` and `y` refer to the top left corner of a shape's bounding box in the absolute (Page) coordinate system.
These are writable - set them directly to position shapes.
* `parentX` and `parentY` (as well as `boardX` and `boardY`) are READ-ONLY computed properties showing position relative to parent/board.
To position relative to parent, use `penpotUtils.setParentXY(shape, parentX, parentY)` or manually set `shape.x = parent.x + parentX`.
* `width` and `height` are READ-ONLY. Use `resize(width, height)` method to change dimensions.
* `bounds` is a READ-ONLY property. Use `x`, `y` with `resize()` to modify shape bounds.
**Other Writable Properties**:
* `name` - Shape name
* `fills`, `strokes` - Styling properties
* `rotation`, `opacity`, `blocked`, `hidden`, `visible`
**Z-Order**:
* The z-order of shapes is determined by the order in the `children` array of the parent shape.
Therefore, when creating shapes that should be on top of each other, add them to the parent in the correct order
(i.e. add background shapes first, then foreground shapes later).
CRITICAL: NEVER use the broken function `appendChild` to achieve this, ALWAYS use `parent.insertChild(parent.children.length, shape)`
* To modify z-order after creation, use these methods: `bringToFront()`, `sendToBack()`, `bringForward()`, `sendBackward()`,
and, for precise control, `setParentIndex(index)` (0-based).
**Modification Methods**:
* `resize(width, height)` - Change dimensions (required for width/height since they're read-only)
* `rotate(angle, center?)` - Rotate shape
* `remove()` - Permanently destroy the shape (use only for deletion, NOT for reparenting)
**Hierarchical Structure**:
* `parent` - The parent shape (null for root shapes)
Note: Hierarchical nesting does not necessarily imply visual containment
* CRITICAL: To add children to a parent shape (e.g. a `Board`):
- ALWAYS use `parent.insertChild(index, shape)` to add a child, e.g. `parent.insertChild(parent.children.length, shape)` to append
- NEVER use `parent.appendChild(shape)` as it is BROKEN and will not insert in a predictable place (except in flex layout boards)
* Reparenting: `newParent.appendChild(shape)` or `newParent.insertChild(index, shape)` will move a shape to new parent
- Automatically removes the shape from its old parent
- Absolute x/y positions are preserved (use `penpotUtils.setParentXY` to adjust relative position)
# Images
The `Image` type is a legacy type. Images are now typically embedded in a `Fill`, with `fillImage` set to an
`ImageData` object, i.e. the `fills` property of of a shape (e.g. a `Rectangle`) will contain a fill where `fillImage` is set.
Use the `export_shape` and `import_image` tools to export and import images.
# Layout Systems
Boards can have layout systems that automatically control the positioning and spacing of their children:
* If a board has a layout system, then child positions are controlled by the layout system.
For every child, key properties of the child within the layout are stored in `child.layoutChild: LayoutChildProperties`:
- `absolute: boolean` - if true, child position is not controlled by layout system. x/y will set *relative* position within parent!
- margins (`topMargin`, `rightMargin`, `bottomMargin`, `leftMargin` or combined `verticalMargin`, `horizontalMargin`)
- sizing (`verticalSizing`, `horizontalSizing`: "fill" | "auto" | "fix")
- min/max sizes (`minWidth`, `maxWidth`, `minHeight`, `maxHeight`)
- `zIndex: number` (higher numbers on top)
* **Flex Layout**: A flexbox-style layout system
- Properties: `dir`, `rowGap`, `columnGap`, `alignItems`, `justifyContent`;
- `dir`: "row" | "column" | "row-reverse" | "column-reverse"
- Padding: `topPadding`, `rightPadding`, `bottomPadding`, `leftPadding`, or combined `verticalPadding`, `horizontalPadding`
- To modify spacing: adjust `rowGap` and `columnGap` properties, not individual child positions.
Optionally, adjust indivudual child margins via `child.layoutChild`.
- When a board has flex layout,
- child positions are controlled by the layout system, not by individual x/y coordinates (unless `child.layoutChild.absolute` is true);
appending or inserting children automatically positions them according to the layout rules.
- CRITICAL: For for dir="column" or dir="row", the order of the `children` array is reversed relative to the visual order!
Therefore, the element that appears first in the array, appears visually at the end (bottom/right) and vice versa.
ALWAYS BEAR IN MIND THAT THE CHILDREN ARRAY ORDER IS REVERSED FOR dir="column" OR dir="row"!
- CRITICAL: The FlexLayout method `board.flex.appendChild` is BROKEN. To append children to a flex layout board such that
they appear visually at the end, ALWAYS use the Board's method `board.appendChild(shape)`; it will insert at the front
of the `children` array for dir="column" or dir="row", which is what you want. So call it in the order of visual appearance.
To insert at a specific index, use `board.insertChild(index, shape)`, bearing in mind the reversed order for dir="column"
or dir="row".
- Add to a board with `board.addFlexLayout(): FlexLayout`; instance then accessible via `board.flex`.
IMPORTANT: When adding a flex layout to a container that already has children,
use `penpotUtils.addFlexLayout(container, dir)` instead! This preserves the existing visual order of children.
Otherwise, children will be arbitrarily reordered when the children order suddenly determines the display order.
- Check with: `if (board.flex) { ... }`
* **Grid Layout**: A CSS grid-style layout system
- Add to a board with `board.addGridLayout(): GridLayout`; instance then accessibly via `board.grid`;
Check with: `if (board.grid) { ... }`
- Properties: `rows`, `columns`, `rowGap`, `columnGap`
- Children are positioned via 1-based row/column indices
- Add to grid via `board.flex.appendChild(shape, row, column)`
- Modify grid positioning after the fact via `shape.layoutCell: LayoutCellProperties`
* When working with boards:
- ALWAYS check if the board has a layout system before attempting to reposition children
- Modify layout properties (gaps, padding) instead of trying to set child x/y positions directly
- Layout systems override manual positioning of children
# Text Elements
The rendered content of `Text` element is given by the `characters` property.
To change the size of the text, change the `fontSize` property; applying `resize()` does NOT change the font size,
it only changes the formal bounding box; if the text does not fit it, it will overflow.
The bounding box is sized automatically as long as the `growType` property is set to "auto-width" or "auto-height".
`resize` always sets `growType` to "fixed", so ALWAYS set it back to "auto-*" if you want automatic sizing - otherwise the bounding box will be meaningless, with the text overflowing!
The auto-sizing is not immediate; sleep for a short time (100ms) if you want to read the updated bounding box.
# The `penpot` and `penpotUtils` Objects, Exploring Designs
A key object to use in your code is the `penpot` object (which is of type `Penpot`):
* `penpot.selection` provides the list of shapes the user has selected in the Penpot UI.
If it is unclear which elements to work on, you can ask the user to select them for you.
ALWAYS immediately copy the selected shape(s) into `storage`! Do not assume that the selection remains unchanged.
* `penpot.root` provides the root shape of the currently active page.
* Generation of CSS content for elements via `penpot.generateStyle`
* Generation of HTML/SVG content for elements via `penpot.generateMarkup`
For example, to generate CSS for the currently selected elements, you can execute this:
return penpot.generateStyle(penpot.selection, { type: "css", withChildren: true });
CRITICAL: The `penpotUtils` object provides essential utilities - USE THESE INSTEAD OF WRITING YOUR OWN:
* getPages(): { id: string; name: string }[]
* getPageById(id: string): Page | null
* getPageByName(name: string): Page | null
* shapeStructure(shape: Shape, maxDepth: number | undefined = undefined): { id, name, type, children?, layout? }
Generates an overview structure of the given shape.
- children: recursive, limited by maxDepth
- layout: present if shape has flex/grid layout, contains { type: "flex" | "grid", ... }
* findShapeById(id: string): Shape | null
* findShape(predicate: (shape: Shape) => boolean, root: Shape | null = null): Shape | null
If no root is provided, search globally (in all pages).
* findShapes(predicate: (shape: Shape) => boolean, root: Shape | null = null): Shape[]
* isContainedIn(shape: Shape, container: Shape): boolean
Returns true iff shape is fully within the container's geometric bounds.
Note that a shape's bounds may not always reflect its actual visual content - descendants can overflow; check using analyzeDescendants (see below).
* setParentXY(shape: Shape, parentX: number, parentY: number): void
Sets shape position relative to its parent (since parentX/parentY are read-only)
* analyzeDescendants<T>(root: Shape, evaluator: (root: Shape, descendant: Shape) => T | null | undefined, maxDepth?: number): Array<{ shape: Shape, result: T }>
General-purpose utility for analyzing/validating descendants
Calls evaluator on each descendant; collects non-null/undefined results
Powerful pattern: evaluator can return corrector functions or diagnostic data
General pointers for working with Penpot designs:
* Prefer `penpotUtils` helper functions — avoid reimplementing shape searching.
* To get an overview of a single page, use `penpotUtils.shapeStructure(page.root, 3)`.
Note that `penpot.root` refers to the current page only. When working across pages, first determine the relevant page(s).
* Use `penpotUtils.findShapes()` or `penpotUtils.findShape()` with predicates to locate elements efficiently.
Common tasks - Quick Reference (ALWAYS use penpotUtils for these):
* Find all images:
const images = penpotUtils.findShapes(
shape => shape.type === 'image' || shape.fills?.some(fill => fill.fillImage),
penpot.root
);
* Find text elements:
const texts = penpotUtils.findShapes(shape => shape.type === 'text', penpot.root);
* Find (the first) shape with a given name:
const shape = penpotUtils.findShape(shape => shape.name === 'MyShape');
* Get structure of current selection:
const structure = penpotUtils.shapeStructure(penpot.selection[0]);
* Find shapes in current selection/board:
const shapes = penpotUtils.findShapes(predicate, penpot.selection[0] || penpot.root);
* Validate/analyze descendants (returning corrector functions):
const fixes = penpotUtils.analyzeDescendants(board, (root, shape) => {
const xMod = shape.parentX % 4;
if (xMod !== 0) {
return () => penpotUtils.setParentXY(shape, Math.round(shape.parentX / 4) * 4, shape.parentY);
}
});
fixes.forEach(f => f.result()); // Apply all fixes
* Find containment violations:
const violations = penpotUtils.analyzeDescendants(board, (root, shape) => {
return !penpotUtils.isContainedIn(shape, root) ? 'outside-bounds' : null;
});
Always validate against the root container that is supposed to contain the shapes.
# Visual Inspection of Designs
For many tasks, it can be critical to visually inspect the design. Remember to use the `export_shape` tool for this purpose!
# Revising Designs
* Before applying design changes, ask: "Would a designer consider this appropriate?"
* When dealing with containment issues, ask: Is the parent too small OR is the child too large?
Container sizes are usually intentional, check content first.
* Check for reasonable font sizes and typefaces
* The use of flex layouts is encouraged for cases where elements are arranged in rows or columns with consistent spacing/positioning.
Consider converting boards to flex layout when appropriate.
# Asset Libraries
Libraries in Penpot are collections of reusable design assets (components, colors, and typographies) that can be shared across files.
They enable design systems and consistent styling across projects.
Each Penpot file has its own local library and can connect to external shared libraries.
Accessing libraries: via `penpot.library` (type: `LibraryContext`):
* `penpot.library.local` (type: `Library`) - The current file's own library
* `penpot.library.connected` (type: `Library[]`) - Array of already-connected external libraries
* `penpot.library.availableLibraries()` (returns: `Promise<LibrarySummary[]>`) - Libraries available to connect
* `penpot.library.connectLibrary(libraryId: string)` (returns: `Promise<Library>`) - Connect a new library
Each `Library` object has:
* `id: string`
* `name: string`
* `components: LibraryComponent[]` - Array of components
* `colors: LibraryColor[]` - Array of colors
* `typographies: LibraryTypography[]` - Array of typographies
Using library components:
* find a component in the library by name:
const component: LibraryComponent = library.components.find(comp => comp.name.includes('Button'));
* create a new instance of the component on the current page:
const instance: Shape = component.instance();
This returns a `Shape` (often a `Board` containing child elements).
After instantiation, modify the instance's properties as desired.
* get the reference to the main component shape:
const mainShape: Shape = component.mainInstance();
Adding assets to a library:
* const newColor: LibraryColor = penpot.library.local.createColor();
newColor.name = 'Brand Primary';
newColor.color = '#0066FF';
* const newTypo: LibraryTypography = penpot.library.local.createTypography();
newTypo.name = 'Heading Large';
// Set typography properties...
* const shapes: Shape[] = [shape1, shape2]; // shapes to include
const newComponent: LibraryComponent = penpot.library.local.createComponent(shapes);
newComponent.name = 'My Button';
--
You have hereby read the 'Penpot High-Level Overview' and need not use a tool to read it again.

View File

@@ -1,18 +1,7 @@
import { readFileSync, existsSync } from "fs"; import { existsSync, readFileSync } from "fs";
import { join, dirname } from "path"; import { join } from "path";
import { fileURLToPath } from "url";
import yaml from "js-yaml";
import { createLogger } from "./logger.js"; import { createLogger } from "./logger.js";
/**
* Interface defining the structure of the prompts configuration file.
*/
export interface PromptsConfig {
/** Initial instructions displayed when the server starts or connects to a client */
initial_instructions: string;
[key: string]: any; // Allow for future extension with additional prompt types
}
/** /**
* Configuration loader for prompts and server settings. * Configuration loader for prompts and server settings.
* *
@@ -23,7 +12,7 @@ export interface PromptsConfig {
export class ConfigurationLoader { export class ConfigurationLoader {
private readonly logger = createLogger("ConfigurationLoader"); private readonly logger = createLogger("ConfigurationLoader");
private readonly baseDir: string; private readonly baseDir: string;
private promptsConfig: PromptsConfig | null = null; private initialInstructions: string;
/** /**
* Creates a new configuration loader instance. * Creates a new configuration loader instance.
@@ -32,34 +21,14 @@ export class ConfigurationLoader {
*/ */
constructor(baseDir: string) { constructor(baseDir: string) {
this.baseDir = baseDir; this.baseDir = baseDir;
this.initialInstructions = this.loadFileContent(join(this.baseDir, "data", "initial_instructions.md"));
} }
/** private loadFileContent(filePath: string): string {
* Loads the prompts configuration from the YAML file. if (!existsSync(filePath)) {
* throw new Error(`Configuration file not found at ${filePath}`);
* Reads and parses the prompts.yml file, providing cached access
* to configuration values on subsequent calls.
*
* @returns The parsed prompts configuration object
*/
public getPromptsConfig(): PromptsConfig {
if (this.promptsConfig !== null) {
return this.promptsConfig;
} }
return readFileSync(filePath, "utf8");
const promptsPath = join(this.baseDir, "data", "prompts.yml");
if (!existsSync(promptsPath)) {
throw new Error(`Prompts configuration file not found at ${promptsPath}, using defaults`);
}
const fileContent = readFileSync(promptsPath, "utf8");
const parsedConfig = yaml.load(fileContent) as PromptsConfig;
this.promptsConfig = parsedConfig || {};
this.logger.info(`Loaded prompts configuration from ${promptsPath}`);
return this.promptsConfig;
} }
/** /**
@@ -68,18 +37,6 @@ export class ConfigurationLoader {
* @returns The initial instructions string, or undefined if not configured * @returns The initial instructions string, or undefined if not configured
*/ */
public getInitialInstructions(): string { public getInitialInstructions(): string {
const config = this.getPromptsConfig(); return this.initialInstructions;
return config.initial_instructions;
}
/**
* Reloads the configuration from disk.
*
* Forces a fresh read of the configuration file on the next access,
* useful for development or when configuration files are updated at runtime.
*/
public reloadConfiguration(): void {
this.promptsConfig = null;
this.logger.info("Configuration cache cleared, will reload on next access");
} }
} }

View File

@@ -15,9 +15,9 @@ fi
if [[ "$URL" = "http://localhost:9090" ]]; then if [[ "$URL" = "http://localhost:9090" ]]; then
pnpx concurrently --kill-others-on-fail -s last -k \ pnpx concurrently --kill-others-on-fail -s last -k \
"caddy file-server --root ../../plugins/dist/doc/ --listen :9090" \ "caddy file-server --root ../../plugins/dist/doc/ --listen :9090" \
"../types-generator/build $URL"; "bash ../types-generator/build $URL";
else else
../types-generator/build $URL; bash ../types-generator/build $URL;
fi fi
popd popd

View File

@@ -1,7 +1,8 @@
# Types Generator # Types Generator
This subproject contains helper scripts used in the development of the This subproject contains helper scripts used in the development of the
Penpot MCP server for generate the types yaml. Penpot MCP server, specifically for the generation of a YAML file containing
Penpot plugin API types and their documentation.
## Setup ## Setup
@@ -12,15 +13,41 @@ Install the environment via (optional, already handled by `build` script)
pixi install pixi install
## Running the API Documentation Preparation Script
### Buld API types The script `prepare_api_docs.py` reads API documentation from a Web URL
and collects it in a single YAML file, which is then used by an MCP
The script `prepare_api_docs.py` reads API documentation from the Web
and collects it in a single yaml file, which is then used by an MCP
tool to provide API documentation to an LLM on demand. tool to provide API documentation to an LLM on demand.
Successful execution will generate the output file `../packages/server/data/api_types.yml`.
### Generating the YAML File for a Given URL
Running the script: Running the script:
./build <optional-url> pixi run python prepare_api_docs.py <url>
You can alternatively run `./build <url>`, which additionally performs pixi environment installation.
For example, to generate the API documentation based on the current PROD Penpot API documentation,
use the URL
https://doc.plugins.penpot.app
### Generating the YAML File Based on the Current Documentation in the Repository
Requirement: [Caddy](https://caddyserver.com/download) must be installed and available in the system path.
To generate the API documentation based on the current documentation in the repository,
run the `build:types` script in the parent directory, i.e.
cd ..
pnpm run build:types
This will spawn a local HTTP server on port 9090 and run the `prepare_api_docs.py` script with the
URL `http://localhost:9090`.
To run only the server without executing the script, run
cd ..
caddy file-server --root ../plugins/dist/doc/ --listen 127.0.0.1:9090
This will generate `../packages/server/data/api_types.yml`.

View File

@@ -80,6 +80,25 @@ class PenpotAPIContentMarkdownConverter(MarkdownConverter):
# return as code block # return as code block
return f"\n```\n{soup.get_text()}\n```\n\n" return f"\n```\n{soup.get_text()}\n```\n\n"
# check for <ul> tag with a single <li>: move the <li> content a <div> and process it as normal,
# to avoid single list items with superfluous bullet points and indentations.
# This happens frequently, especially in new versions of the docs generator, e.g. for methods:
# <ul class="tsd-signatures tsd-is-inherited">
# <li class="tsd-is-inherited">
# <div class="tsd-signature tsd-anchor-link" id="remove-1">...</div>
# </li>
# </ul>
if node.name == "ul" and "class" in node.attrs and "tsd-signatures" in node.attrs["class"]:
soup_ul = soup.find("ul")
if soup_ul is not None:
li_children = soup_ul.find_all("li", recursive=False)
if len(li_children) == 1:
# create a new div with the content of the single li
new_div = soup.new_tag("div")
for child in list(li_children[0].contents):
new_div.append(child)
return self.process_tag(new_div, parent_tags=parent_tags)
# other cases: use the default processing # other cases: use the default processing
return super().process_tag(node, parent_tags=parent_tags) return super().process_tag(node, parent_tags=parent_tags)
@@ -135,7 +154,7 @@ class YamlConverter:
class PenpotAPIDocsProcessor: class PenpotAPIDocsProcessor:
def __init__(self, url=None): def __init__(self, url: str):
self.md_converter = PenpotAPIContentMarkdownConverter() self.md_converter = PenpotAPIContentMarkdownConverter()
self.base_url = url self.base_url = url
self.types: dict[str, TypeInfo] = {} self.types: dict[str, TypeInfo] = {}
@@ -157,7 +176,7 @@ class PenpotAPIDocsProcessor:
type_name = href.split("/")[-1].replace(".html", "") type_name = href.split("/")[-1].replace(".html", "")
log.info("Processing page: %s", type_name) log.info("Processing page: %s", type_name)
type_info = self.process_page(href, type_name) type_info = self.process_page(href, type_name)
print(f"Adding '{type_name}' with {type_info}") log.info(f"Adding '{type_name}' with {type_info}")
self.types[type_name] = type_info self.types[type_name] = type_info
# add type reference information # add type reference information
@@ -201,11 +220,21 @@ class PenpotAPIDocsProcessor:
members_in_group = {} members_in_group = {}
members[members_type] = members_in_group members[members_type] = members_in_group
for member_tag in el.find_all(attrs={"class": "tsd-member"}): for member_tag in el.find_all(attrs={"class": "tsd-member"}):
# determine member name
member_name = None
member_anchor = member_tag.find("a", attrs={"class": "tsd-anchor"}, recursive=False) member_anchor = member_tag.find("a", attrs={"class": "tsd-anchor"}, recursive=False)
member_name = member_anchor.attrs["id"] if member_anchor is not None:
member_heading = member_tag.find("h3") member_name = member_anchor.attrs["id"]
else:
member_h3 = member_tag.find("h3", recursive=False)
if member_h3 is not None:
h3_span = member_h3.find("span", recursive=False)
if h3_span is not None:
member_name = h3_span.get_text().strip()
assert member_name is not None, f"Could not determine member name for\n{member_tag}"
# extract tsd-tag info (e.g., "Readonly") from the heading and reinsert it into the signature, # extract tsd-tag info (e.g., "Readonly") from the heading and reinsert it into the signature,
# where we want to see it. The heading is removed, as it is redundant. # where we want to see it. The heading is removed, as it is redundant.
member_heading = member_tag.find("h3")
if member_heading: if member_heading:
tags_in_heading = member_heading.find_all(attrs={"class": "tsd-tag"}) tags_in_heading = member_heading.find_all(attrs={"class": "tsd-tag"})
if tags_in_heading: if tags_in_heading:
@@ -237,25 +266,29 @@ class PenpotAPIDocsProcessor:
) )
DEFAULT_API_DOCS_URL = "http://localhost:9090" LOCAL_API_DOCS_URL = "http://localhost:9090"
PROD_API_DOCS_URL = "https://doc.plugins.penpot.app"
DEFAULT_API_DOCS_URL = LOCAL_API_DOCS_URL
def main(): def main():
target_dir = Path(__file__).parent.parent / "packages" / "server" / "data" target_dir = Path(__file__).parent.parent / "packages" / "server" / "data"
url = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_API_DOCS_URL url = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_API_DOCS_URL
print("Fetching plugin data from: {}".format(url)) log.info("Fetching plugin data from: {}".format(url))
PenpotAPIDocsProcessor(url).run(target_dir=str(target_dir)) PenpotAPIDocsProcessor(url).run(target_dir=str(target_dir))
def debug_type_conversion(rel_url: str): def debug_type_conversion(rel_url: str, base_url: str):
""" """
This function is for debugging purposes only. This function is for debugging purposes only.
It processes a single type page and prints the converted markdown to the console. It processes a single type page and prints the converted markdown to the console.
:param base_url: base URL of the API docs (e.g., "http://localhost:9090")
:param rel_url: relative URL of the type page (e.g., "interfaces/ShapeBase") :param rel_url: relative URL of the type page (e.g., "interfaces/ShapeBase")
""" """
type_name = rel_url.split("/")[-1] type_name = rel_url.split("/")[-1].replace(".html", "")
processor = PenpotAPIDocsProcessor() processor = PenpotAPIDocsProcessor(url=base_url)
type_info = processor.process_page(rel_url, type_name) type_info = processor.process_page(rel_url, type_name)
print(f"--- overview ---\n{type_info.overview}\n") print(f"--- overview ---\n{type_info.overview}\n")
for member_type, members in type_info.members.items(): for member_type, members in type_info.members.items():
@@ -265,5 +298,5 @@ def debug_type_conversion(rel_url: str):
if __name__ == '__main__': if __name__ == '__main__':
# debug_type_conversion("interfaces/LayoutChildProperties") # debug_type_conversion("interfaces/Path.html", LOCAL_API_DOCS_URL)
logging.run_main(main) logging.run_main(main)

View File

@@ -1,5 +1,5 @@
{ {
"prettier.singleQuote": true, "prettier.singleQuote": true,
"editor.defaultFormatter": "prettier.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true "editor.formatOnSave": true
} }

View File

@@ -4,9 +4,12 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "ng build colors-to-tokens-plugin", "build": "ng build colors-to-tokens-plugin && pnpm run build:plugin",
"build:dev": "ng build colors-to-tokens-plugin --configuration development", "build:dev": "ng build colors-to-tokens-plugin --configuration development",
"build:plugin": "node ../../tools/scripts/build-plugin.mjs --plugin=colors-to-tokens-plugin",
"build:plugin:watch": "node ../../tools/scripts/build-plugin.mjs --plugin=colors-to-tokens-plugin --watch",
"serve": "ng serve colors-to-tokens-plugin", "serve": "ng serve colors-to-tokens-plugin",
"init": "concurrently --kill-others --names plugin,serve \"pnpm run build:plugin:watch\" \"pnpm run serve\"",
"lint": "eslint .", "lint": "eslint .",
"test": "vitest" "test": "vitest"
} }

View File

@@ -4,9 +4,12 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "ng build contrast-plugin", "build": "ng build contrast-plugin && pnpm run build:plugin",
"build:dev": "ng build contrast-plugin --configuration development", "build:dev": "ng build contrast-plugin --configuration development",
"build:plugin": "node ../../tools/scripts/build-plugin.mjs --plugin=contrast-plugin",
"build:plugin:watch": "node ../../tools/scripts/build-plugin.mjs --plugin=contrast-plugin --watch",
"serve": "ng serve contrast-plugin", "serve": "ng serve contrast-plugin",
"init": "concurrently --kill-others --names plugin,serve \"pnpm run build:plugin:watch\" \"pnpm run serve\"",
"lint": "eslint .", "lint": "eslint .",
"test": "vitest" "test": "vitest"
} }

View File

@@ -3,6 +3,7 @@ import baseConfig from '../../eslint.config.js';
export default [ export default [
...baseConfig, ...baseConfig,
{ {
files: ['**/*.ts', '**/*.tsx'],
languageOptions: { languageOptions: {
parserOptions: { parserOptions: {
project: './tsconfig.*?.json', project: './tsconfig.*?.json',
@@ -22,5 +23,5 @@ export default [
files: ['**/*.js', '**/*.jsx'], files: ['**/*.js', '**/*.jsx'],
rules: {}, rules: {},
}, },
{ ignores: ['vite.config.ts'] }, { ignores: ['**/assets/*.js', 'vite.config.ts'] },
]; ];

View File

@@ -8,6 +8,7 @@
"build": "vite build", "build": "vite build",
"build:watch": "vite build --watch --mode development", "build:watch": "vite build --watch --mode development",
"preview": "vite preview", "preview": "vite preview",
"init": "concurrently --kill-others --names build,serve \"pnpm run build:watch\" \"pnpm run preview\"",
"lint": "eslint .", "lint": "eslint .",
"test": "vitest" "test": "vitest"
} }

View File

@@ -4,9 +4,12 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "ng build icons-plugin", "build": "ng build icons-plugin && pnpm run build:plugin",
"build:dev": "ng build icons-plugin --configuration development", "build:dev": "ng build icons-plugin --configuration development",
"build:plugin": "node ../../tools/scripts/build-plugin.mjs --plugin=icons-plugin",
"build:plugin:watch": "node ../../tools/scripts/build-plugin.mjs --plugin=icons-plugin --watch",
"serve": "ng serve icons-plugin", "serve": "ng serve icons-plugin",
"init": "concurrently --kill-others --names plugin,serve \"pnpm run build:plugin:watch\" \"pnpm run serve\"",
"lint": "eslint .", "lint": "eslint .",
"test": "vitest" "test": "vitest"
} }

View File

@@ -4,9 +4,12 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "ng build lorem-ipsum-plugin", "build": "ng build lorem-ipsum-plugin && pnpm run build:plugin",
"build:dev": "ng build lorem-ipsum-plugin --configuration development", "build:dev": "ng build lorem-ipsum-plugin --configuration development",
"build:plugin": "node ../../tools/scripts/build-plugin.mjs --plugin=lorem-ipsum-plugin",
"build:plugin:watch": "node ../../tools/scripts/build-plugin.mjs --plugin=lorem-ipsum-plugin --watch",
"serve": "ng serve lorem-ipsum-plugin", "serve": "ng serve lorem-ipsum-plugin",
"init": "concurrently --kill-others --names plugin,serve \"pnpm run build:plugin:watch\" \"pnpm run serve\"",
"lint": "eslint .", "lint": "eslint .",
"test": "vitest" "test": "vitest"
} }

View File

@@ -4,9 +4,12 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "ng build poc-state-plugin", "build": "ng build poc-state-plugin && pnpm run build:plugin",
"build:dev": "ng build poc-state-plugin --configuration development", "build:dev": "ng build poc-state-plugin --configuration development",
"build:plugin": "node ../../tools/scripts/build-plugin.mjs --plugin=poc-state-plugin",
"build:plugin:watch": "node ../../tools/scripts/build-plugin.mjs --plugin=poc-state-plugin --watch",
"serve": "ng serve poc-state-plugin", "serve": "ng serve poc-state-plugin",
"init": "concurrently --kill-others --names plugin,serve \"pnpm run build:plugin:watch\" \"pnpm run serve\"",
"lint": "eslint .", "lint": "eslint .",
"test": "vitest" "test": "vitest"
} }

View File

@@ -4,9 +4,12 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "ng build poc-tokens-plugin", "build": "ng build poc-tokens-plugin && pnpm run build:plugin",
"build:dev": "ng build poc-tokens-plugin --configuration development", "build:dev": "ng build poc-tokens-plugin --configuration development",
"build:plugin": "node ../../tools/scripts/build-plugin.mjs --plugin=poc-tokens-plugin",
"build:plugin:watch": "node ../../tools/scripts/build-plugin.mjs --plugin=poc-tokens-plugin --watch",
"serve": "ng serve poc-tokens-plugin", "serve": "ng serve poc-tokens-plugin",
"init": "concurrently --kill-others --names plugin,serve \"pnpm run build:plugin:watch\" \"pnpm run serve\"",
"lint": "eslint .", "lint": "eslint .",
"test": "exit 0" "test": "exit 0"
} }

View File

@@ -4,9 +4,12 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "ng build rename-layers-plugin", "build": "ng build rename-layers-plugin && pnpm run build:plugin",
"build:dev": "ng build rename-layers-plugin --configuration development", "build:dev": "ng build rename-layers-plugin --configuration development",
"build:plugin": "node ../../tools/scripts/build-plugin.mjs --plugin=rename-layers-plugin",
"build:plugin:watch": "node ../../tools/scripts/build-plugin.mjs --plugin=rename-layers-plugin --watch",
"serve": "ng serve rename-layers-plugin", "serve": "ng serve rename-layers-plugin",
"init": "concurrently --kill-others --names plugin,serve \"pnpm run build:plugin:watch\" \"pnpm run serve\"",
"lint": "eslint .", "lint": "eslint .",
"test": "vitest" "test": "vitest"
} }

View File

@@ -4,9 +4,12 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "ng build table-plugin", "build": "ng build table-plugin && pnpm run build:plugin",
"build:dev": "ng build table-plugin --configuration development", "build:dev": "ng build table-plugin --configuration development",
"build:plugin": "node ../../tools/scripts/build-plugin.mjs --plugin=table-plugin",
"build:plugin:watch": "node ../../tools/scripts/build-plugin.mjs --plugin=table-plugin --watch",
"serve": "ng serve table-plugin", "serve": "ng serve table-plugin",
"init": "concurrently --kill-others --names plugin,serve \"pnpm run build:plugin:watch\" \"pnpm run serve\"",
"lint": "eslint .", "lint": "eslint .",
"test": "vitest" "test": "vitest"
} }

View File

@@ -27,13 +27,21 @@ export default [
sourceType: 'module', sourceType: 'module',
}, },
}, },
rules: {
'no-multiple-empty-lines': ['error', { max: 1 }],
quotes: ['error', 'single', { avoidEscape: true }],
},
},
{
files: ['**/*.ts', '**/*.tsx'],
plugins: {
'@typescript-eslint': tseslint.plugin,
},
rules: { rules: {
'@typescript-eslint/no-unused-vars': [ '@typescript-eslint/no-unused-vars': [
'error', 'error',
{ argsIgnorePattern: '^_' }, { argsIgnorePattern: '^_' },
], ],
'no-multiple-empty-lines': ['error', { max: 1 }],
quotes: ['error', 'single', { avoidEscape: true }],
}, },
}, },
{ {

View File

@@ -8,15 +8,15 @@
"start": "pnpm run start:app:runtime", "start": "pnpm run start:app:runtime",
"start:app:runtime": "concurrently --kill-others --names build,server \"pnpm --filter @penpot/plugins-runtime run build:watch\" \"pnpm --filter @penpot/plugins-runtime run preview\"", "start:app:runtime": "concurrently --kill-others --names build,server \"pnpm --filter @penpot/plugins-runtime run build:watch\" \"pnpm --filter @penpot/plugins-runtime run preview\"",
"start:app:styles-example": "pnpm --filter example-styles dev", "start:app:styles-example": "pnpm --filter example-styles dev",
"start:plugin:poc-state": "pnpm --filter poc-state-plugin serve", "start:plugin:poc-state": "pnpm --filter poc-state-plugin run init",
"start:plugin:contrast": "pnpm --filter contrast-plugin serve", "start:plugin:contrast": "pnpm --filter contrast-plugin run init",
"start:plugin:icons": "pnpm --filter icons-plugin serve", "start:plugin:icons": "pnpm --filter icons-plugin run init",
"start:plugin:loremipsum": "pnpm --filter lorem-ipsum-plugin serve", "start:plugin:loremipsum": "pnpm --filter lorem-ipsum-plugin run init",
"start:plugin:palette": "pnpm --filter create-palette-plugin build:watch & pnpm --filter create-palette-plugin preview", "start:plugin:palette": "pnpm --filter create-palette-plugin run init",
"start:plugin:table": "pnpm --filter table-plugin serve", "start:plugin:table": "pnpm --filter table-plugin run init",
"start:plugin:renamelayers": "pnpm --filter rename-layers-plugin serve", "start:plugin:renamelayers": "pnpm --filter rename-layers-plugin run init",
"start:plugin:colors-to-tokens": "pnpm --filter colors-to-tokens-plugin serve", "start:plugin:colors-to-tokens": "pnpm --filter colors-to-tokens-plugin run init",
"start:plugin:poc-tokens": "pnpm --filter poc-tokens-plugin serve", "start:plugin:poc-tokens": "pnpm --filter poc-tokens-plugin run init",
"build:runtime": "pnpm --filter @penpot/plugins-runtime build", "build:runtime": "pnpm --filter @penpot/plugins-runtime build",
"build:plugins": "pnpm --filter './apps/*-plugin' --filter '!poc-state-plugin' build", "build:plugins": "pnpm --filter './apps/*-plugin' --filter '!poc-state-plugin' build",
"build:styles-example": "pnpm --filter example-styles build", "build:styles-example": "pnpm --filter example-styles build",

915
plugins/pnpm-lock.yaml generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,92 @@
import esbuild from 'esbuild';
import { existsSync } from 'fs';
import { readdir } from 'fs/promises';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = resolve(__dirname, '../..');
const appsDir = resolve(rootDir, 'apps');
const watch = process.argv.includes('--watch');
const filterPlugin = process.argv
.find((arg) => arg.startsWith('--plugin='))
?.replace('--plugin=', '');
async function getPluginEntryPoints() {
const entries = await readdir(appsDir, { withFileTypes: true });
const entryPoints = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (filterPlugin && entry.name !== filterPlugin) continue;
const pluginTs = resolve(appsDir, entry.name, 'src/plugin.ts');
const tsconfigPlugin = resolve(
appsDir,
entry.name,
'tsconfig.plugin.json',
);
if (existsSync(pluginTs) && existsSync(tsconfigPlugin)) {
entryPoints.push({
name: entry.name,
entryPoint: pluginTs,
tsconfig: tsconfigPlugin,
outdir: resolve(appsDir, entry.name, 'src/assets'),
});
}
}
return entryPoints;
}
async function buildPlugin(plugin) {
const options = {
entryPoints: [plugin.entryPoint],
bundle: true,
outfile: resolve(plugin.outdir, 'plugin.js'),
minify: !watch,
format: 'esm',
tsconfig: plugin.tsconfig,
logLevel: 'info',
};
if (watch) {
const ctx = await esbuild.context(options);
await ctx.watch();
console.log(`[buildPlugin] Watching ${plugin.name}...`);
return ctx;
} else {
await esbuild.build(options);
console.log(`[buildPlugin] Built ${plugin.name}`);
}
}
async function main() {
const plugins = await getPluginEntryPoints();
if (plugins.length === 0) {
console.warn('[buildPlugin] No plugins found to build.');
return;
}
console.log(
`[buildPlugin] ${watch ? 'Watching' : 'Building'} ${plugins.length} plugin(s): ${plugins.map((p) => p.name).join(', ')}`,
);
const results = await Promise.all(plugins.map(buildPlugin));
if (watch) {
process.on('SIGINT', async () => {
await Promise.all(results.map((ctx) => ctx?.dispose()));
process.exit(0);
});
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,217 @@
# Text Editor Architecture
## Overview (Simplified)
```mermaid
flowchart TB
subgraph Browser["Browser / DOM"]
CE[contenteditable]
Events[DOM Events]
end
subgraph CLJS["ClojureScript"]
InputHandler[text_editor_input.cljs]
Bindings[text_editor.cljs]
ContentCache[(content cache)]
end
subgraph WASM["WASM Boundary"]
FFI["_text_editor_* functions"]
end
subgraph Rust["Rust"]
subgraph StateModule["state/text_editor.rs"]
TES[TextEditorState]
Selection[TextSelection]
Cursor[TextCursor]
end
subgraph WASMImpl["wasm/text_editor.rs"]
StateOps[start / stop]
CursorOps[cursor / selection]
EditOps[insert / delete]
ExportOps[export content]
end
subgraph RenderMod["render/text_editor.rs"]
RenderOverlay[render_overlay]
end
Shapes[(ShapesPool)]
end
subgraph Skia["Skia"]
Canvas[Canvas]
Paragraph[Paragraph layout]
end
%% Flow
CE --> Events
Events --> InputHandler
InputHandler --> Bindings
Bindings --> FFI
FFI --> StateOps & CursorOps & EditOps & ExportOps
StateOps --> TES
CursorOps --> TES
EditOps --> TES
EditOps --> Shapes
ExportOps --> Shapes
TES --> Selection --> Cursor
RenderOverlay --> TES
RenderOverlay --> Shapes
Shapes --> Paragraph
RenderOverlay --> Canvas
Paragraph --> Canvas
ExportOps --> ContentCache
ContentCache --> InputHandler
```
---
## Detailed Architecture
```mermaid
flowchart TB
subgraph Browser["Browser / DOM"]
CE[contenteditable element]
KeyEvents[keydown / keyup]
MouseEvents[mousedown / mousemove]
IME[compositionstart / end]
end
subgraph CLJS["ClojureScript Layer"]
subgraph InputMod["text_editor_input.cljs"]
EventHandler[Event Handler]
BlinkLoop[RAF Blink Loop]
SyncFn[sync-content!]
end
subgraph BindingsMod["text_editor.cljs"]
direction TB
StartStop[start / stop]
CursorFns[set-cursor / move]
SelectFns[select-all / extend]
EditFns[insert / delete]
ExportFns[export-content]
StyleFns[apply-style]
end
ContentCache[(shape-text-contents<br/>atom)]
end
subgraph WASM["WASM Boundary"]
direction TB
FFI_State["_text_editor_start<br/>_text_editor_stop<br/>_text_editor_is_active"]
FFI_Cursor["_text_editor_set_cursor_from_point<br/>_text_editor_move_cursor<br/>_text_editor_select_all"]
FFI_Edit["_text_editor_insert_text<br/>_text_editor_delete_backward<br/>_text_editor_insert_paragraph"]
FFI_Query["_text_editor_export_content<br/>_text_editor_get_selection<br/>_text_editor_poll_event"]
FFI_Render["_text_editor_render_overlay<br/>_text_editor_update_blink"]
end
subgraph Rust["Rust Layer"]
subgraph StateMod["state/text_editor.rs"]
TES[TextEditorState]
Selection[TextSelection]
Cursor[TextCursor]
Events[EditorEvent queue]
end
subgraph WASMMod["wasm/text_editor.rs"]
direction TB
WStateOps[State ops]
WCursorOps[Cursor ops]
WEditOps[Edit ops]
WQueryOps[Query ops]
end
subgraph RenderMod["render/text_editor.rs"]
RenderOverlay[render_overlay]
RenderCursor[render_cursor]
RenderSelection[render_selection]
end
Shapes[(ShapesPool<br/>TextContent)]
end
subgraph Skia["Skia"]
Canvas[Canvas]
SkParagraph[textlayout::Paragraph]
TextBoxes[get_rects_for_range]
end
%% Browser to CLJS
CE --> KeyEvents & MouseEvents & IME
KeyEvents --> EventHandler
MouseEvents --> EventHandler
IME --> EventHandler
%% CLJS internal
EventHandler --> StartStop & CursorFns & EditFns & SelectFns
BlinkLoop --> FFI_Render
SyncFn --> ExportFns
ExportFns --> ContentCache
ContentCache --> SyncFn
StyleFns --> ContentCache
%% CLJS to WASM
StartStop --> FFI_State
CursorFns --> FFI_Cursor
SelectFns --> FFI_Cursor
EditFns --> FFI_Edit
ExportFns --> FFI_Query
%% WASM to Rust impl
FFI_State --> WStateOps
FFI_Cursor --> WCursorOps
FFI_Edit --> WEditOps
FFI_Query --> WQueryOps
FFI_Render --> RenderOverlay
%% Rust internal
WStateOps --> TES
WCursorOps --> TES
WEditOps --> TES
WEditOps --> Shapes
WQueryOps --> TES
WQueryOps --> Shapes
TES --> Selection
Selection --> Cursor
TES --> Events
%% Render flow
RenderOverlay --> RenderCursor & RenderSelection
RenderCursor --> TES
RenderSelection --> TES
RenderCursor --> Shapes
RenderSelection --> Shapes
%% Skia
Shapes --> SkParagraph
SkParagraph --> TextBoxes
RenderCursor --> Canvas
RenderSelection --> Canvas
```
---
## Key Files
| Layer | File | Purpose |
|-------|------|---------|
| DOM | - | contenteditable captures keyboard/IME input |
| CLJS | `text_editor_input.cljs` | Event handling, blink loop, content sync |
| CLJS | `text_editor.cljs` | WASM bindings, content cache, style application |
| Rust | `state/text_editor.rs` | TextEditorState, TextSelection, TextCursor |
| Rust | `wasm/text_editor.rs` | WASM exported functions |
| Rust | `render/text_editor.rs` | Cursor & selection overlay rendering |
## Data Flow
1. **Input**: DOM events → ClojureScript handler → WASM function → Rust state
2. **Edit**: Rust modifies TextContent in ShapesPool → triggers layout
3. **Sync**: Export content → merge with cached styles → update shape
4. **Render**: RAF loop → render_overlay → Skia draws cursor/selection

View File

@@ -301,11 +301,7 @@ pub extern "C" fn set_view_end() {
#[cfg(feature = "profile-macros")] #[cfg(feature = "profile-macros")]
{ {
let total_time = performance::get_time() - unsafe { VIEW_INTERACTION_START }; let total_time = performance::get_time() - unsafe { VIEW_INTERACTION_START };
performance::console_log!( performance::console_log!("[PERF] view_interaction: {}ms", total_time);
"[PERF] view_interaction (zoom_changed={}): {}ms",
zoom_changed,
total_time
);
} }
}); });
} }

View File

@@ -10,6 +10,7 @@ mod shadows;
mod strokes; mod strokes;
mod surfaces; mod surfaces;
pub mod text; pub mod text;
pub mod text_editor;
mod ui; mod ui;
use skia_safe::{self as skia, Matrix, RRect, Rect}; use skia_safe::{self as skia, Matrix, RRect, Rect};
@@ -22,7 +23,7 @@ pub use surfaces::{SurfaceId, Surfaces};
use crate::performance; use crate::performance;
use crate::shapes::{ use crate::shapes::{
all_with_ancestors, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor, Type, all_with_ancestors, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor, Stroke, Type,
}; };
use crate::state::{ShapesPoolMutRef, ShapesPoolRef}; use crate::state::{ShapesPoolMutRef, ShapesPoolRef};
use crate::tiles::{self, PendingTiles, TileRect}; use crate::tiles::{self, PendingTiles, TileRect};
@@ -33,8 +34,9 @@ use crate::wapi;
pub use fonts::*; pub use fonts::*;
pub use images::*; pub use images::*;
// This is the extra are used for tile rendering. // This is the extra area used for tile rendering (tiles beyond viewport).
const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 2; // Higher values pre-render more tiles, reducing empty squares during pan but using more memory.
const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3;
const MAX_BLOCKING_TIME_MS: i32 = 32; const MAX_BLOCKING_TIME_MS: i32 = 32;
const NODE_BATCH_THRESHOLD: i32 = 3; const NODE_BATCH_THRESHOLD: i32 = 3;
@@ -697,20 +699,17 @@ impl RenderState {
canvas.translate(translation); canvas.translate(translation);
}); });
for fill in shape.fills().rev() { fills::render(self, shape, &shape.fills, antialias, SurfaceId::Current);
fills::render(self, shape, fill, antialias, SurfaceId::Current);
}
for stroke in shape.visible_strokes().rev() { // Pass strokes in natural order; stroke merging handles top-most ordering internally.
strokes::render( let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect();
self, strokes::render(
shape, self,
stroke, shape,
Some(SurfaceId::Current), &visible_strokes,
None, Some(SurfaceId::Current),
antialias, antialias,
); );
}
self.surfaces.apply_mut(SurfaceId::Current as u32, |s| { self.surfaces.apply_mut(SurfaceId::Current as u32, |s| {
s.canvas().restore(); s.canvas().restore();
@@ -1014,33 +1013,35 @@ impl RenderState {
{ {
if let Some(fills_to_render) = self.nested_fills.last() { if let Some(fills_to_render) = self.nested_fills.last() {
let fills_to_render = fills_to_render.clone(); let fills_to_render = fills_to_render.clone();
for fill in fills_to_render.iter() { fills::render(self, shape, &fills_to_render, antialias, fills_surface_id);
fills::render(self, shape, fill, antialias, fills_surface_id);
}
} }
} else { } else {
for fill in shape.fills().rev() { fills::render(self, shape, &shape.fills, antialias, fills_surface_id);
fills::render(self, shape, fill, antialias, fills_surface_id);
}
} }
for stroke in shape.visible_strokes().rev() { // Skip stroke rendering for clipped frames - they are drawn in render_shape_exit
// over the children. Drawing twice would cause incorrect opacity blending.
let skip_strokes = matches!(shape.shape_type, Type::Frame(_)) && shape.clip_content;
if !skip_strokes {
// Pass strokes in natural order; stroke merging handles top-most ordering internally.
let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect();
strokes::render( strokes::render(
self, self,
shape, shape,
stroke, &visible_strokes,
Some(strokes_surface_id), Some(strokes_surface_id),
None,
antialias, antialias,
); );
if !fast_mode { if !fast_mode {
shadows::render_stroke_inner_shadows( for stroke in &visible_strokes {
self, shadows::render_stroke_inner_shadows(
shape, self,
stroke, shape,
antialias, stroke,
innershadows_surface_id, antialias,
); innershadows_surface_id,
);
}
} }
} }
@@ -1240,8 +1241,6 @@ impl RenderState {
if self.render_in_progress { if self.render_in_progress {
if tree.len() != 0 { if tree.len() != 0 {
self.render_shape_tree_partial(base_object, tree, timestamp, true)?; self.render_shape_tree_partial(base_object, tree, timestamp, true)?;
} else {
println!("Empty tree");
} }
self.flush_and_submit(); self.flush_and_submit();
@@ -1264,8 +1263,6 @@ impl RenderState {
) -> Result<(), String> { ) -> Result<(), String> {
if tree.len() != 0 { if tree.len() != 0 {
self.render_shape_tree_partial(base_object, tree, timestamp, false)?; self.render_shape_tree_partial(base_object, tree, timestamp, false)?;
} else {
println!("Empty tree");
} }
self.flush_and_submit(); self.flush_and_submit();
@@ -1402,6 +1399,10 @@ impl RenderState {
element_strokes.to_mut().clear_fills(); element_strokes.to_mut().clear_fills();
element_strokes.to_mut().clear_shadows(); element_strokes.to_mut().clear_shadows();
element_strokes.to_mut().clip_content = false; element_strokes.to_mut().clip_content = false;
// Frame blur is applied at the save_layer level - avoid double blur on the stroke paint
if Self::frame_clip_layer_blur(element).is_some() {
element_strokes.to_mut().set_blur(None);
}
self.render_shape( self.render_shape(
&element_strokes, &element_strokes,
clip_bounds, clip_bounds,
@@ -1551,6 +1552,11 @@ impl RenderState {
plain_shape_mut.clear_shadows(); plain_shape_mut.clear_shadows();
plain_shape_mut.blur = None; plain_shape_mut.blur = None;
// Shadow rendering uses a single render_shape call with no render_shape_exit,
// so strokes must be drawn here. Disable clip_content to avoid skip_strokes
// (which defers strokes to render_shape_exit for clipped frames).
plain_shape_mut.clip_content = false;
let Some(drop_filter) = transformed_shadow.get_drop_shadow_filter() else { let Some(drop_filter) = transformed_shadow.get_drop_shadow_filter() else {
return; return;
}; };
@@ -1660,6 +1666,158 @@ impl RenderState {
} }
} }
/// Renders element drop shadows to DropShadows surface and composites to Current.
/// Used for both normal shadow rendering and pre-layer rendering (frame_clip_layer_blur).
#[allow(clippy::too_many_arguments)]
fn render_element_drop_shadows_and_composite(
&mut self,
element: &Shape,
tree: ShapesPoolRef,
extrect: &mut Option<Rect>,
clip_bounds: Option<ClipStack>,
scale: f32,
translation: (f32, f32),
node_render_state: &NodeRenderState,
) {
let element_extrect = extrect.get_or_insert_with(|| element.extrect(tree, scale));
let inherited_layer_blur = match element.shape_type {
Type::Frame(_) | Type::Group(_) => element.blur,
_ => None,
};
for shadow in element.drop_shadows_visible() {
let paint = skia::Paint::default();
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
self.surfaces
.canvas(SurfaceId::DropShadows)
.save_layer(&layer_rec);
self.render_drop_black_shadow(
element,
element_extrect,
shadow,
clip_bounds.clone(),
scale,
translation,
None,
);
if !matches!(element.shape_type, Type::Bool(_)) {
for shadow_shape_id in element.children.iter() {
let Some(shadow_shape) = tree.get(shadow_shape_id) else {
continue;
};
if shadow_shape.hidden {
continue;
}
let nested_clip_bounds =
node_render_state.get_nested_shadow_clip_bounds(element, shadow);
if !matches!(shadow_shape.shape_type, Type::Text(_)) {
self.render_drop_black_shadow(
shadow_shape,
&shadow_shape.extrect(tree, scale),
shadow,
nested_clip_bounds,
scale,
translation,
inherited_layer_blur,
);
} else {
let paint = skia::Paint::default();
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
self.surfaces
.canvas(SurfaceId::DropShadows)
.save_layer(&layer_rec);
self.surfaces
.canvas(SurfaceId::DropShadows)
.scale((scale, scale));
self.surfaces
.canvas(SurfaceId::DropShadows)
.translate(translation);
let mut transformed_shadow: Cow<Shadow> = Cow::Borrowed(shadow);
transformed_shadow.to_mut().color = skia::Color::BLACK;
transformed_shadow.to_mut().blur = transformed_shadow.blur * scale;
transformed_shadow.to_mut().spread = transformed_shadow.spread * scale;
let mut new_shadow_paint = skia::Paint::default();
new_shadow_paint
.set_image_filter(transformed_shadow.get_drop_shadow_filter());
new_shadow_paint.set_blend_mode(skia::BlendMode::SrcOver);
self.with_nested_blurs_suppressed(|state| {
state.render_shape(
shadow_shape,
nested_clip_bounds,
SurfaceId::DropShadows,
SurfaceId::DropShadows,
SurfaceId::DropShadows,
SurfaceId::DropShadows,
true,
None,
Some(vec![new_shadow_paint.clone()]),
);
});
self.surfaces.canvas(SurfaceId::DropShadows).restore();
}
}
}
let mut paint = skia::Paint::default();
paint.set_color(shadow.color);
paint.set_blend_mode(skia::BlendMode::SrcIn);
self.surfaces
.canvas(SurfaceId::DropShadows)
.draw_paint(&paint);
self.surfaces.canvas(SurfaceId::DropShadows).restore();
}
if let Some(clips) = clip_bounds.as_ref() {
let antialias = element.should_use_antialias(scale);
self.surfaces.canvas(SurfaceId::Current).save();
for (bounds, corners, transform) in clips.iter() {
let mut total_matrix = Matrix::new_identity();
total_matrix.pre_scale((scale, scale), None);
total_matrix.pre_translate((translation.0, translation.1));
total_matrix.pre_concat(transform);
self.surfaces
.canvas(SurfaceId::Current)
.concat(&total_matrix);
if let Some(corners) = corners {
let rrect = RRect::new_rect_radii(*bounds, corners);
self.surfaces.canvas(SurfaceId::Current).clip_rrect(
rrect,
skia::ClipOp::Intersect,
antialias,
);
} else {
self.surfaces.canvas(SurfaceId::Current).clip_rect(
*bounds,
skia::ClipOp::Intersect,
antialias,
);
}
self.surfaces
.canvas(SurfaceId::Current)
.concat(&total_matrix.invert().unwrap_or_default());
}
self.surfaces
.draw_into(SurfaceId::DropShadows, SurfaceId::Current, None);
self.surfaces.canvas(SurfaceId::Current).restore();
} else {
self.surfaces
.draw_into(SurfaceId::DropShadows, SurfaceId::Current, None);
}
self.surfaces
.canvas(SurfaceId::DropShadows)
.clear(skia::Color::TRANSPARENT);
}
pub fn render_shape_tree_partial_uncached( pub fn render_shape_tree_partial_uncached(
&mut self, &mut self,
tree: ShapesPoolRef, tree: ShapesPoolRef,
@@ -1742,6 +1900,33 @@ impl RenderState {
// If a container was flattened, it doesn't affect children visually, so we skip // If a container was flattened, it doesn't affect children visually, so we skip
// the expensive enter/exit operations and process children directly // the expensive enter/exit operations and process children directly
if !element.can_flatten() { if !element.can_flatten() {
// Enter focus early so shadow_before_layer can run (it needs focus_mode.is_active())
self.focus_mode.enter(&element.id);
// For frames with layer blur, render shadow BEFORE the layer so it doesn't get
// the layer blur (which would make it more diffused than without clipping)
let shadow_before_layer = !node_render_state.is_root()
&& self.focus_mode.is_active()
&& !self.options.is_fast_mode()
&& !matches!(element.shape_type, Type::Text(_))
&& Self::frame_clip_layer_blur(element).is_some()
&& element.drop_shadows_visible().next().is_some();
if shadow_before_layer {
let translation = self
.surfaces
.get_render_context_translation(self.render_area, scale);
self.render_element_drop_shadows_and_composite(
element,
tree,
&mut extrect,
clip_bounds.clone(),
scale,
translation,
&node_render_state,
);
}
self.render_shape_enter(element, mask); self.render_shape_enter(element, mask);
} }
@@ -1753,180 +1938,25 @@ impl RenderState {
// Skip expensive drop shadow rendering in fast mode (during pan/zoom) // Skip expensive drop shadow rendering in fast mode (during pan/zoom)
let skip_shadows = self.options.is_fast_mode(); let skip_shadows = self.options.is_fast_mode();
// Skip shadow block when already rendered before the layer (frame_clip_layer_blur)
let shadows_already_rendered = Self::frame_clip_layer_blur(element).is_some();
// For text shapes, render drop shadow using text rendering logic // For text shapes, render drop shadow using text rendering logic
if !skip_shadows && !matches!(element.shape_type, Type::Text(_)) { if !skip_shadows
// Shadow rendering technique: Two-pass approach for proper opacity handling && !shadows_already_rendered
// && !matches!(element.shape_type, Type::Text(_))
// The shadow rendering uses a two-pass technique to ensure that overlapping {
// shadow areas maintain correct opacity without unwanted darkening: self.render_element_drop_shadows_and_composite(
// element,
// 1. First pass: Render shadow shape in pure black (alpha channel preserved) tree,
// - This creates the shadow silhouette with proper alpha gradients &mut extrect,
// - The black color acts as a mask for the final shadow color clip_bounds.clone(),
// scale,
// 2. Second pass: Apply actual shadow color using SrcIn blend mode translation,
// - SrcIn preserves the alpha channel from the black shadow &node_render_state,
// - Only the color channels are replaced, maintaining transparency );
// - This prevents overlapping shadows from accumulating opacity
//
// This approach is essential for complex shapes with transparency where
// multiple shadow areas might overlap, ensuring visual consistency.
let inherited_layer_blur = match element.shape_type {
Type::Frame(_) | Type::Group(_) => element.blur,
_ => None,
};
for shadow in element.drop_shadows_visible() {
let paint = skia::Paint::default();
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
self.surfaces
.canvas(SurfaceId::DropShadows)
.save_layer(&layer_rec);
// First pass: Render shadow in black to establish alpha mask
let element_extrect =
extrect.get_or_insert_with(|| element.extrect(tree, scale));
self.render_drop_black_shadow(
element,
element_extrect,
shadow,
clip_bounds.clone(),
scale,
translation,
None,
);
if !matches!(element.shape_type, Type::Bool(_)) {
// Nested shapes shadowing - apply black shadow to child shapes too
for shadow_shape_id in element.children.iter() {
let Some(shadow_shape) = tree.get(shadow_shape_id) else {
continue;
};
if shadow_shape.hidden {
continue;
}
let clip_bounds = node_render_state
.get_nested_shadow_clip_bounds(element, shadow);
if !matches!(shadow_shape.shape_type, Type::Text(_)) {
self.render_drop_black_shadow(
shadow_shape,
&shadow_shape.extrect(tree, scale),
shadow,
clip_bounds,
scale,
translation,
inherited_layer_blur,
);
} else {
let paint = skia::Paint::default();
let layer_rec =
skia::canvas::SaveLayerRec::default().paint(&paint);
self.surfaces
.canvas(SurfaceId::DropShadows)
.save_layer(&layer_rec);
self.surfaces
.canvas(SurfaceId::DropShadows)
.scale((scale, scale));
self.surfaces
.canvas(SurfaceId::DropShadows)
.translate(translation);
let mut transformed_shadow: Cow<Shadow> = Cow::Borrowed(shadow);
transformed_shadow.to_mut().color = skia::Color::BLACK;
transformed_shadow.to_mut().blur =
transformed_shadow.blur * scale;
transformed_shadow.to_mut().spread =
transformed_shadow.spread * scale;
let mut new_shadow_paint = skia::Paint::default();
new_shadow_paint.set_image_filter(
transformed_shadow.get_drop_shadow_filter(),
);
new_shadow_paint.set_blend_mode(skia::BlendMode::SrcOver);
self.with_nested_blurs_suppressed(|state| {
state.render_shape(
shadow_shape,
clip_bounds,
SurfaceId::DropShadows,
SurfaceId::DropShadows,
SurfaceId::DropShadows,
SurfaceId::DropShadows,
true,
None,
Some(vec![new_shadow_paint.clone()]),
);
});
self.surfaces.canvas(SurfaceId::DropShadows).restore();
}
}
}
// Second pass: Apply actual shadow color using SrcIn blend mode
// This preserves the alpha channel from the black shadow while
// replacing only the color channels, preventing opacity accumulation
let mut paint = skia::Paint::default();
paint.set_color(shadow.color);
paint.set_blend_mode(skia::BlendMode::SrcIn);
self.surfaces
.canvas(SurfaceId::DropShadows)
.draw_paint(&paint);
self.surfaces.canvas(SurfaceId::DropShadows).restore();
}
} }
if let Some(clips) = clip_bounds.as_ref() {
let antialias = element.should_use_antialias(scale);
self.surfaces.canvas(SurfaceId::Current).save();
for (bounds, corners, transform) in clips.iter() {
let mut total_matrix = Matrix::new_identity();
total_matrix.pre_scale((scale, scale), None);
total_matrix.pre_translate((translation.0, translation.1));
total_matrix.pre_concat(transform);
self.surfaces
.canvas(SurfaceId::Current)
.concat(&total_matrix);
if let Some(corners) = corners {
let rrect = RRect::new_rect_radii(*bounds, corners);
self.surfaces.canvas(SurfaceId::Current).clip_rrect(
rrect,
skia::ClipOp::Intersect,
antialias,
);
} else {
self.surfaces.canvas(SurfaceId::Current).clip_rect(
*bounds,
skia::ClipOp::Intersect,
antialias,
);
}
self.surfaces
.canvas(SurfaceId::Current)
.concat(&total_matrix.invert().unwrap_or_default());
}
self.surfaces
.draw_into(SurfaceId::DropShadows, SurfaceId::Current, None);
self.surfaces.canvas(SurfaceId::Current).restore();
} else {
self.surfaces
.draw_into(SurfaceId::DropShadows, SurfaceId::Current, None);
}
self.surfaces
.canvas(SurfaceId::DropShadows)
.clear(skia::Color::TRANSPARENT);
self.render_shape( self.render_shape(
element, element,
clip_bounds.clone(), clip_bounds.clone(),
@@ -2063,8 +2093,13 @@ impl RenderState {
} }
} else { } else {
performance::begin_measure!("render_shape_tree::uncached"); performance::begin_measure!("render_shape_tree::uncached");
// Only allow stopping (yielding) if the current tile is NOT visible.
// This ensures all visible tiles render synchronously before showing,
// eliminating empty squares during zoom. Interest-area tiles can still yield.
let tile_is_visible = self.tile_viewbox.is_visible(&current_tile);
let can_stop = allow_stop && !tile_is_visible;
let (is_empty, early_return) = let (is_empty, early_return) =
self.render_shape_tree_partial_uncached(tree, timestamp, allow_stop)?; self.render_shape_tree_partial_uncached(tree, timestamp, can_stop)?;
if early_return { if early_return {
return Ok(()); return Ok(());
@@ -2189,17 +2224,20 @@ impl RenderState {
* Given a shape, check the indexes and update it's location in the tile set * Given a shape, check the indexes and update it's location in the tile set
* returns the tiles that have changed in the process. * returns the tiles that have changed in the process.
*/ */
pub fn update_shape_tiles(&mut self, shape: &Shape, tree: ShapesPoolRef) -> Vec<tiles::Tile> { pub fn update_shape_tiles(
&mut self,
shape: &Shape,
tree: ShapesPoolRef,
) -> HashSet<tiles::Tile> {
let TileRect(rsx, rsy, rex, rey) = self.get_tiles_for_shape(shape, tree); let TileRect(rsx, rsy, rex, rey) = self.get_tiles_for_shape(shape, tree);
let old_tiles = self // Collect old tiles to avoid borrow conflict with remove_shape_at
let old_tiles: Vec<_> = self
.tiles .tiles
.get_tiles_of(shape.id) .get_tiles_of(shape.id)
.map_or(Vec::new(), |tiles| tiles.iter().copied().collect()); .map_or(Vec::new(), |t| t.iter().copied().collect());
let new_tiles = (rsx..=rex).flat_map(|x| (rsy..=rey).map(move |y| tiles::Tile::from(x, y))); let mut result = HashSet::<tiles::Tile>::with_capacity(old_tiles.len());
let mut result = HashSet::<tiles::Tile>::new();
// First, remove the shape from all tiles where it was previously located // First, remove the shape from all tiles where it was previously located
for tile in old_tiles { for tile in old_tiles {
@@ -2208,12 +2246,66 @@ impl RenderState {
} }
// Then, add the shape to the new tiles // Then, add the shape to the new tiles
for tile in new_tiles { for tile in (rsx..=rex).flat_map(|x| (rsy..=rey).map(move |y| tiles::Tile::from(x, y))) {
self.tiles.add_shape_at(tile, shape.id); self.tiles.add_shape_at(tile, shape.id);
result.insert(tile); result.insert(tile);
} }
result.iter().copied().collect() result
}
/*
* Incremental version of update_shape_tiles for pan/zoom operations.
* Updates the tile index and returns ONLY tiles that need cache invalidation.
*
* During pan operations, shapes don't move in world coordinates. The interest
* area (viewport) moves, which changes which tiles we track in the index, but
* tiles that were already cached don't need re-rendering just because the
* viewport moved.
*
* This function:
* 1. Updates the tile index (adds/removes shapes from tiles based on interest area)
* 2. Returns empty vec for cache invalidation (pan doesn't change tile content)
*
* Tile cache invalidation only happens when shapes actually move or change,
* which is handled by rebuild_touched_tiles, not during pan/zoom.
*/
pub fn update_shape_tiles_incremental(
&mut self,
shape: &Shape,
tree: ShapesPoolRef,
) -> Vec<tiles::Tile> {
let TileRect(rsx, rsy, rex, rey) = self.get_tiles_for_shape(shape, tree);
let old_tiles: HashSet<tiles::Tile> = self
.tiles
.get_tiles_of(shape.id)
.map_or(HashSet::new(), |tiles| tiles.iter().copied().collect());
let new_tiles: HashSet<tiles::Tile> = (rsx..=rex)
.flat_map(|x| (rsy..=rey).map(move |y| tiles::Tile::from(x, y)))
.collect();
// Tiles where shape is being removed from index (left interest area)
let removed: Vec<_> = old_tiles.difference(&new_tiles).copied().collect();
// Tiles where shape is being added to index (entered interest area)
let added: Vec<_> = new_tiles.difference(&old_tiles).copied().collect();
// Update the index: remove from old tiles
for tile in &removed {
self.tiles.remove_shape_at(*tile, shape.id);
}
// Update the index: add to new tiles
for tile in &added {
self.tiles.add_shape_at(*tile, shape.id);
}
// Don't invalidate cache for pan/zoom - the tile content hasn't changed,
// only the interest area moved. Tiles that were cached are still valid.
// New tiles that entered the interest area will be rendered fresh since
// they weren't in the cache anyway.
Vec::new()
} }
/* /*
@@ -2239,12 +2331,22 @@ impl RenderState {
pub fn rebuild_tiles_shallow(&mut self, tree: ShapesPoolRef) { pub fn rebuild_tiles_shallow(&mut self, tree: ShapesPoolRef) {
performance::begin_measure!("rebuild_tiles_shallow"); performance::begin_measure!("rebuild_tiles_shallow");
let mut all_tiles = HashSet::<tiles::Tile>::new(); // Check if zoom changed - if so, we need full cache invalidation
// because tiles are rendered at specific zoom levels
let zoom_changed = self.zoom_changed();
let mut tiles_to_invalidate = HashSet::<tiles::Tile>::new();
let mut nodes = vec![Uuid::nil()]; let mut nodes = vec![Uuid::nil()];
while let Some(shape_id) = nodes.pop() { while let Some(shape_id) = nodes.pop() {
if let Some(shape) = tree.get(&shape_id) { if let Some(shape) = tree.get(&shape_id) {
if shape_id != Uuid::nil() { if shape_id != Uuid::nil() {
all_tiles.extend(self.update_shape_tiles(shape, tree)); if zoom_changed {
// Zoom changed: use full update that tracks all affected tiles
tiles_to_invalidate.extend(self.update_shape_tiles(shape, tree));
} else {
// Pan only: use incremental update that preserves valid cached tiles
self.update_shape_tiles_incremental(shape, tree);
}
} else { } else {
// We only need to rebuild tiles from the first level. // We only need to rebuild tiles from the first level.
for child_id in shape.children_ids_iter(false) { for child_id in shape.children_ids_iter(false) {
@@ -2256,9 +2358,6 @@ impl RenderState {
// Invalidate changed tiles - old content stays visible until new tiles render // Invalidate changed tiles - old content stays visible until new tiles render
self.surfaces.remove_cached_tiles(self.background_color); self.surfaces.remove_cached_tiles(self.background_color);
for tile in all_tiles {
self.remove_cached_tile(tile);
}
performance::end_measure!("rebuild_tiles_shallow"); performance::end_measure!("rebuild_tiles_shallow");
} }
@@ -2307,7 +2406,7 @@ impl RenderState {
let mut all_tiles = HashSet::<tiles::Tile>::new(); let mut all_tiles = HashSet::<tiles::Tile>::new();
let ids = self.touched_ids.clone(); let ids = std::mem::take(&mut self.touched_ids);
for shape_id in ids.iter() { for shape_id in ids.iter() {
if let Some(shape) = tree.get(shape_id) { if let Some(shape) = tree.get(shape_id) {
@@ -2322,8 +2421,6 @@ impl RenderState {
self.remove_cached_tile(tile); self.remove_cached_tile(tile);
} }
self.clean_touched();
performance::end_measure!("rebuild_touched_tiles"); performance::end_measure!("rebuild_touched_tiles");
} }
@@ -2380,6 +2477,7 @@ impl RenderState {
self.touched_ids.insert(uuid); self.touched_ids.insert(uuid);
} }
#[allow(dead_code)]
pub fn clean_touched(&mut self) { pub fn clean_touched(&mut self) {
self.touched_ids.clear(); self.touched_ids.clear();
} }

View File

@@ -2,7 +2,7 @@ use skia_safe::{self as skia, Paint, RRect};
use super::{filters, RenderState, SurfaceId}; use super::{filters, RenderState, SurfaceId};
use crate::render::get_source_rect; use crate::render::get_source_rect;
use crate::shapes::{Fill, Frame, ImageFill, Rect, Shape, Type}; use crate::shapes::{merge_fills, Fill, Frame, ImageFill, Rect, Shape, Type};
fn draw_image_fill( fn draw_image_fill(
render_state: &mut RenderState, render_state: &mut RenderState,
@@ -92,6 +92,76 @@ fn draw_image_fill(
* This SHOULD be the only public function in this module. * This SHOULD be the only public function in this module.
*/ */
pub fn render( pub fn render(
render_state: &mut RenderState,
shape: &Shape,
fills: &[Fill],
antialias: bool,
surface_id: SurfaceId,
) {
if fills.is_empty() {
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 {
for fill in fills.iter().rev() {
render_single_fill(render_state, shape, fill, antialias, surface_id);
}
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(
render_state,
bounds,
surface_id,
|state, temp_surface| {
let mut filtered_paint = paint.clone();
filtered_paint.set_image_filter(image_filter.clone());
draw_fill_to_surface(state, shape, temp_surface, &filtered_paint);
},
) {
return;
} else {
paint.set_image_filter(image_filter);
}
}
draw_fill_to_surface(render_state, shape, surface_id, &paint);
}
/// Draws a single paint (with a merged shader) to the appropriate surface
/// based on the shape type.
fn draw_fill_to_surface(
render_state: &mut RenderState,
shape: &Shape,
surface_id: SurfaceId,
paint: &Paint,
) {
match &shape.shape_type {
Type::Rect(_) | Type::Frame(_) => {
render_state.surfaces.draw_rect_to(surface_id, shape, paint);
}
Type::Circle => {
render_state
.surfaces
.draw_circle_to(surface_id, shape, paint);
}
Type::Path(_) | Type::Bool(_) => {
render_state.surfaces.draw_path_to(surface_id, shape, paint);
}
Type::Group(_) => {}
_ => unreachable!("This shape should not have fills"),
}
}
fn render_single_fill(
render_state: &mut RenderState, render_state: &mut RenderState,
shape: &Shape, shape: &Shape,
fill: &Fill, fill: &Fill,
@@ -108,7 +178,14 @@ pub fn render(
|state, temp_surface| { |state, temp_surface| {
let mut filtered_paint = paint.clone(); let mut filtered_paint = paint.clone();
filtered_paint.set_image_filter(image_filter.clone()); filtered_paint.set_image_filter(image_filter.clone());
draw_fill_to_surface(state, shape, fill, antialias, temp_surface, &filtered_paint); draw_single_fill_to_surface(
state,
shape,
fill,
antialias,
temp_surface,
&filtered_paint,
);
}, },
) { ) {
return; return;
@@ -117,10 +194,10 @@ pub fn render(
} }
} }
draw_fill_to_surface(render_state, shape, fill, antialias, surface_id, &paint); draw_single_fill_to_surface(render_state, shape, fill, antialias, surface_id, &paint);
} }
fn draw_fill_to_surface( fn draw_single_fill_to_surface(
render_state: &mut RenderState, render_state: &mut RenderState,
shape: &Shape, shape: &Shape,
fill: &Fill, fill: &Fill,
@@ -153,8 +230,6 @@ fn draw_fill_to_surface(
(_, Type::Group(_)) => { (_, Type::Group(_)) => {
// Groups can have fills but they propagate them to their children // Groups can have fills but they propagate them to their children
} }
(_, _) => { _ => unreachable!("This shape should not have fills"),
unreachable!("This shape should not have fills")
}
} }
} }

View File

@@ -40,7 +40,7 @@ pub fn render_stroke_inner_shadows(
if !shape.has_fills() { if !shape.has_fills() {
for shadow in shape.inner_shadows_visible() { for shadow in shape.inner_shadows_visible() {
let filter = shadow.get_inner_shadow_filter(); let filter = shadow.get_inner_shadow_filter();
strokes::render( strokes::render_single(
render_state, render_state,
shape, shape,
stroke, stroke,

View File

@@ -1,7 +1,7 @@
use crate::math::{Matrix, Point, Rect}; use crate::math::{Matrix, Point, Rect};
use crate::shapes::{ use crate::shapes::{
Corners, Fill, ImageFill, Path, Shape, Stroke, StrokeCap, StrokeKind, SvgAttrs, Type, merge_fills, Corners, Fill, ImageFill, Path, Shape, Stroke, StrokeCap, StrokeKind, Type,
}; };
use skia_safe::{self as skia, ImageFilter, RRect}; use skia_safe::{self as skia, ImageFilter, RRect};
@@ -9,32 +9,28 @@ use super::{filters, RenderState, SurfaceId};
use crate::render::filters::compose_filters; use crate::render::filters::compose_filters;
use crate::render::{get_dest_rect, get_source_rect}; use crate::render::{get_dest_rect, get_source_rect};
// FIXME: See if we can simplify these arguments
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn draw_stroke_on_rect( fn draw_stroke_on_rect(
canvas: &skia::Canvas, canvas: &skia::Canvas,
stroke: &Stroke, stroke: &Stroke,
rect: &Rect, rect: &Rect,
selrect: &Rect,
corners: &Option<Corners>, corners: &Option<Corners>,
svg_attrs: Option<&SvgAttrs>, paint: &skia::Paint,
scale: f32, scale: f32,
shadow: Option<&ImageFilter>, shadow: Option<&ImageFilter>,
blur: Option<&ImageFilter>, blur: Option<&ImageFilter>,
antialias: bool, antialias: bool,
) { ) {
// Draw the different kind of strokes for a rect is straightforward, we just need apply a stroke to:
// - The same rect if it's a center stroke
// - A bigger rect if it's an outer stroke
// - A smaller rect if it's an outer stroke
let stroke_rect = stroke.aligned_rect(rect, scale); let stroke_rect = stroke.aligned_rect(rect, scale);
let mut paint = stroke.to_paint(selrect, svg_attrs, antialias); let mut paint = paint.clone();
// Apply both blur and shadow filters if present, composing them if necessary. // Apply both blur and shadow filters if present, composing them if necessary.
let filter = compose_filters(blur, shadow); let filter = compose_filters(blur, shadow);
paint.set_image_filter(filter); paint.set_image_filter(filter);
match corners { // By default just draw the rect. Only dotted inner/outer strokes need
// clipping to prevent the dotted pattern from appearing in wrong areas.
let draw_stroke = || match corners {
Some(radii) => { Some(radii) => {
let radii = stroke.outer_corners(radii); let radii = stroke.outer_corners(radii);
let rrect = RRect::new_rect_radii(stroke_rect, &radii); let rrect = RRect::new_rect_radii(stroke_rect, &radii);
@@ -43,34 +39,58 @@ fn draw_stroke_on_rect(
None => { None => {
canvas.draw_rect(stroke_rect, &paint); canvas.draw_rect(stroke_rect, &paint);
} }
};
if let Some(clip_op) = stroke.clip_op() {
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
canvas.save_layer(&layer_rec);
match corners {
Some(radii) => {
let rrect = RRect::new_rect_radii(*rect, radii);
canvas.clip_rrect(rrect, clip_op, antialias);
}
None => {
canvas.clip_rect(*rect, clip_op, antialias);
}
}
draw_stroke();
canvas.restore();
} else {
draw_stroke();
} }
} }
// FIXME: See if we can simplify these arguments
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn draw_stroke_on_circle( fn draw_stroke_on_circle(
canvas: &skia::Canvas, canvas: &skia::Canvas,
stroke: &Stroke, stroke: &Stroke,
rect: &Rect, rect: &Rect,
selrect: &Rect, paint: &skia::Paint,
svg_attrs: Option<&SvgAttrs>,
scale: f32, scale: f32,
shadow: Option<&ImageFilter>, shadow: Option<&ImageFilter>,
blur: Option<&ImageFilter>, blur: Option<&ImageFilter>,
antialias: bool, antialias: bool,
) { ) {
// Draw the different kind of strokes for an oval is straightforward, we just need apply a stroke to:
// - The same oval if it's a center stroke
// - A bigger oval if it's an outer stroke
// - A smaller oval if it's an outer stroke
let stroke_rect = stroke.aligned_rect(rect, scale); let stroke_rect = stroke.aligned_rect(rect, scale);
let mut paint = stroke.to_paint(selrect, svg_attrs, antialias); let mut paint = paint.clone();
// Apply both blur and shadow filters if present, composing them if necessary. // Apply both blur and shadow filters if present, composing them if necessary.
let filter = compose_filters(blur, shadow); let filter = compose_filters(blur, shadow);
paint.set_image_filter(filter); paint.set_image_filter(filter);
canvas.draw_oval(stroke_rect, &paint); // 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);
canvas.save_layer(&layer_rec);
let mut clip_path = skia::Path::new();
clip_path.add_oval(rect, None);
canvas.clip_path(&clip_path, clip_op, antialias);
canvas.draw_oval(stroke_rect, &paint);
canvas.restore();
} else {
canvas.draw_oval(stroke_rect, &paint);
}
} }
fn draw_outer_stroke_path( fn draw_outer_stroke_path(
@@ -122,15 +142,13 @@ fn draw_inner_stroke_path(
} }
// For outer stroke we draw a center stroke (with double width) and use another path with blend mode clear to remove the inner stroke added // For outer stroke we draw a center stroke (with double width) and use another path with blend mode clear to remove the inner stroke added
// FIXME: See if we can simplify these arguments
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn draw_stroke_on_path( fn draw_stroke_on_path(
canvas: &skia::Canvas, canvas: &skia::Canvas,
stroke: &Stroke, stroke: &Stroke,
path: &Path, path: &Path,
selrect: &Rect, paint: &skia::Paint,
path_transform: Option<&Matrix>, path_transform: Option<&Matrix>,
svg_attrs: Option<&SvgAttrs>,
shadow: Option<&ImageFilter>, shadow: Option<&ImageFilter>,
blur: Option<&ImageFilter>, blur: Option<&ImageFilter>,
antialias: bool, antialias: bool,
@@ -140,31 +158,28 @@ pub fn draw_stroke_on_path(
let is_open = path.is_open(); let is_open = path.is_open();
let mut paint: skia_safe::Handle<_> = let mut draw_paint = paint.clone();
stroke.to_stroked_paint(is_open, selrect, svg_attrs, antialias);
let filter = compose_filters(blur, shadow); let filter = compose_filters(blur, shadow);
paint.set_image_filter(filter); draw_paint.set_image_filter(filter);
match stroke.render_kind(is_open) { match stroke.render_kind(is_open) {
StrokeKind::Inner => { StrokeKind::Inner => {
draw_inner_stroke_path(canvas, &skia_path, &paint, blur, antialias); draw_inner_stroke_path(canvas, &skia_path, &draw_paint, blur, antialias);
} }
StrokeKind::Center => { StrokeKind::Center => {
canvas.draw_path(&skia_path, &paint); canvas.draw_path(&skia_path, &draw_paint);
} }
StrokeKind::Outer => { StrokeKind::Outer => {
draw_outer_stroke_path(canvas, &skia_path, &paint, blur, antialias); draw_outer_stroke_path(canvas, &skia_path, &draw_paint, blur, antialias);
} }
} }
handle_stroke_caps( handle_stroke_caps(
&mut skia_path, &mut skia_path,
stroke, stroke,
selrect,
canvas, canvas,
is_open, is_open,
svg_attrs, paint,
blur, blur,
antialias, antialias,
); );
@@ -207,17 +222,15 @@ fn handle_stroke_cap(
} }
} }
// FIXME: See if we can simplify these arguments
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn handle_stroke_caps( fn handle_stroke_caps(
path: &mut skia::Path, path: &mut skia::Path,
stroke: &Stroke, stroke: &Stroke,
selrect: &Rect,
canvas: &skia::Canvas, canvas: &skia::Canvas,
is_open: bool, is_open: bool,
svg_attrs: Option<&SvgAttrs>, paint: &skia::Paint,
blur: Option<&ImageFilter>, blur: Option<&ImageFilter>,
antialias: bool, _antialias: bool,
) { ) {
let mut points = vec![Point::default(); path.count_points()]; let mut points = vec![Point::default(); path.count_points()];
path.get_points(&mut points); path.get_points(&mut points);
@@ -230,7 +243,7 @@ fn handle_stroke_caps(
let first_point = points.first().unwrap(); let first_point = points.first().unwrap();
let last_point = points.last().unwrap(); let last_point = points.last().unwrap();
let mut paint_stroke = stroke.to_stroked_paint(is_open, selrect, svg_attrs, antialias); let mut paint_stroke = paint.clone();
if let Some(filter) = blur { if let Some(filter) = blur {
paint_stroke.set_image_filter(filter.clone()); paint_stroke.set_image_filter(filter.clone());
@@ -405,30 +418,25 @@ fn draw_image_stroke_in_container(
match &shape.shape_type { match &shape.shape_type {
shape_type @ (Type::Rect(_) | Type::Frame(_)) => { shape_type @ (Type::Rect(_) | Type::Frame(_)) => {
let paint = stroke.to_paint(&outer_rect, svg_attrs, antialias);
draw_stroke_on_rect( draw_stroke_on_rect(
canvas, canvas,
stroke, stroke,
container, container,
&outer_rect,
&shape_type.corners(), &shape_type.corners(),
svg_attrs, &paint,
scale, scale,
None, None,
None, None,
antialias, antialias,
); );
} }
Type::Circle => draw_stroke_on_circle( Type::Circle => {
canvas, let paint = stroke.to_paint(&outer_rect, svg_attrs, antialias);
stroke, draw_stroke_on_circle(
container, canvas, stroke, container, &paint, scale, None, None, antialias,
&outer_rect, );
svg_attrs, }
scale,
None,
None,
antialias,
),
shape_type @ (Type::Path(_) | Type::Bool(_)) => { shape_type @ (Type::Path(_) | Type::Bool(_)) => {
if let Some(p) = shape_type.path() { if let Some(p) = shape_type.path() {
@@ -446,21 +454,21 @@ fn draw_image_stroke_in_container(
} }
} }
let is_open = p.is_open(); let is_open = p.is_open();
let mut paint = stroke.to_stroked_paint(is_open, &outer_rect, svg_attrs, antialias); let paint = stroke.to_stroked_paint(is_open, &outer_rect, svg_attrs, antialias);
canvas.draw_path(&path, &paint); canvas.draw_path(&path, &paint);
if stroke.render_kind(is_open) == StrokeKind::Outer { if stroke.render_kind(is_open) == StrokeKind::Outer {
// Small extra inner stroke to overlap with the fill // Small extra inner stroke to overlap with the fill
// and avoid unnecesary artifacts. // and avoid unnecesary artifacts.
paint.set_stroke_width(1. / scale); let mut thin_paint = paint.clone();
canvas.draw_path(&path, &paint); thin_paint.set_stroke_width(1. / scale);
canvas.draw_path(&path, &thin_paint);
} }
handle_stroke_caps( handle_stroke_caps(
&mut path, &mut path,
stroke, stroke,
&outer_rect,
canvas, canvas,
is_open, is_open,
svg_attrs, &paint,
shape.image_filter(1.).as_ref(), shape.image_filter(1.).as_ref(),
antialias, antialias,
); );
@@ -509,8 +517,230 @@ fn draw_image_stroke_in_container(
canvas.restore(); canvas.restore();
} }
#[allow(clippy::too_many_arguments)] /// Renders all strokes for a shape. Merges strokes that share the same
/// geometry (kind, width, style, caps) into a single draw call to avoid
/// anti-aliasing edge bleed between them.
pub fn render( pub fn render(
render_state: &mut RenderState,
shape: &Shape,
strokes: &[&Stroke],
surface_id: Option<SurfaceId>,
antialias: bool,
) {
if strokes.is_empty() {
return;
}
let has_image_fills = strokes.iter().any(|s| matches!(s.fill, Fill::Image(_)));
let can_merge = !has_image_fills && strokes.len() > 1 && strokes_share_geometry(strokes);
if !can_merge {
// When blur is active, render all strokes into a single offscreen surface
// and apply blur once to the composite. This prevents blur from making
// edges semi-transparent and revealing strokes underneath.
if let Some(image_filter) = shape.image_filter(1.) {
let mut content_bounds = shape.selrect;
let max_margin = strokes
.iter()
.map(|s| s.bounds_width(shape.is_open()))
.fold(0.0f32, f32::max);
if max_margin > 0.0 {
content_bounds.inset((-max_margin, -max_margin));
}
let max_cap = strokes
.iter()
.map(|s| s.cap_bounds_margin())
.fold(0.0f32, f32::max);
if max_cap > 0.0 {
content_bounds.inset((-max_cap, -max_cap));
}
let bounds = image_filter.compute_fast_bounds(content_bounds);
let target = surface_id.unwrap_or(SurfaceId::Strokes);
if filters::render_with_filter_surface(
render_state,
bounds,
target,
|state, temp_surface| {
// Use save_layer with the blur filter so it applies once
// to the composite of all strokes, not per-stroke.
let canvas = state.surfaces.canvas(temp_surface);
let mut blur_paint = skia::Paint::default();
blur_paint.set_image_filter(image_filter.clone());
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&blur_paint);
canvas.save_layer(&layer_rec);
for stroke in strokes.iter().rev() {
// bypass_filter=true prevents each stroke from creating
// its own filter surface. The blur on the paint inside
// draw functions is harmless — it composes with the
// layer's filter but the layer filter is the dominant one.
render_single_internal(
state,
shape,
stroke,
Some(temp_surface),
None,
antialias,
true,
true,
);
}
state.surfaces.canvas(temp_surface).restore();
},
) {
return;
}
}
// No blur or filter surface unavailable — draw strokes individually.
for stroke in strokes.iter().rev() {
render_single(render_state, shape, stroke, surface_id, None, antialias);
}
return;
}
render_merged(render_state, shape, strokes, surface_id, antialias, false);
}
fn strokes_share_geometry(strokes: &[&Stroke]) -> bool {
strokes.windows(2).all(|pair| {
pair[0].kind == pair[1].kind
&& pair[0].width == pair[1].width
&& pair[0].style == pair[1].style
&& pair[0].cap_start == pair[1].cap_start
&& pair[0].cap_end == pair[1].cap_end
})
}
fn render_merged(
render_state: &mut RenderState,
shape: &Shape,
strokes: &[&Stroke],
surface_id: Option<SurfaceId>,
antialias: bool,
bypass_filter: bool,
) {
let representative = *strokes
.last()
.expect("render_merged expects at least one stroke");
let blur_filter = if bypass_filter {
None
} else {
shape.image_filter(1.)
};
// Handle blur filter
if !bypass_filter {
if let Some(image_filter) = blur_filter.clone() {
let mut content_bounds = shape.selrect;
let stroke_margin = representative.bounds_width(shape.is_open());
if stroke_margin > 0.0 {
content_bounds.inset((-stroke_margin, -stroke_margin));
}
let cap_margin = representative.cap_bounds_margin();
if cap_margin > 0.0 {
content_bounds.inset((-cap_margin, -cap_margin));
}
let bounds = image_filter.compute_fast_bounds(content_bounds);
let target = surface_id.unwrap_or(SurfaceId::Strokes);
if filters::render_with_filter_surface(
render_state,
bounds,
target,
|state, temp_surface| {
let blur_filter = image_filter.clone();
state.surfaces.apply_mut(temp_surface as u32, |surface| {
let canvas = surface.canvas();
let mut blur_paint = skia::Paint::default();
blur_paint.set_image_filter(blur_filter.clone());
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&blur_paint);
canvas.save_layer(&layer_rec);
});
render_merged(state, shape, strokes, Some(temp_surface), antialias, true);
state.surfaces.apply_mut(temp_surface as u32, |surface| {
surface.canvas().restore();
});
},
) {
return;
}
}
}
// `merge_fills` puts fills[0] on top (each new fill goes under the accumulated shader
// via SrcOver), matching the non-merged path where strokes[0] is drawn last (on top).
let fills: Vec<Fill> = strokes.iter().map(|s| s.fill.clone()).collect();
let merged = merge_fills(&fills, shape.selrect);
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();
match &shape.shape_type {
shape_type @ (Type::Rect(_) | Type::Frame(_)) => {
let mut paint = representative.to_paint(&selrect, svg_attrs, antialias);
paint.set_shader(merged.shader());
draw_stroke_on_rect(
canvas,
representative,
&selrect,
&shape_type.corners(),
&paint,
scale,
None,
blur_filter.as_ref(),
antialias,
);
}
Type::Circle => {
let mut paint = representative.to_paint(&selrect, svg_attrs, antialias);
paint.set_shader(merged.shader());
draw_stroke_on_circle(
canvas,
representative,
&selrect,
&paint,
scale,
None,
blur_filter.as_ref(),
antialias,
);
}
Type::Text(_) => {}
shape_type @ (Type::Path(_) | Type::Bool(_)) => {
if let Some(path) = shape_type.path() {
let is_open = path.is_open();
let mut paint =
representative.to_stroked_paint(is_open, &selrect, svg_attrs, antialias);
paint.set_shader(merged.shader());
draw_stroke_on_path(
canvas,
representative,
path,
&paint,
path_transform.as_ref(),
None,
blur_filter.as_ref(),
antialias,
);
}
}
_ => unreachable!("This shape should not have strokes"),
}
}
/// Renders a single stroke. Used by the shadow module which needs per-stroke
/// shadow filters.
#[allow(clippy::too_many_arguments)]
pub fn render_single(
render_state: &mut RenderState, render_state: &mut RenderState,
shape: &Shape, shape: &Shape,
stroke: &Stroke, stroke: &Stroke,
@@ -518,7 +748,7 @@ pub fn render(
shadow: Option<&ImageFilter>, shadow: Option<&ImageFilter>,
antialias: bool, antialias: bool,
) { ) {
render_internal( render_single_internal(
render_state, render_state,
shape, shape,
stroke, stroke,
@@ -526,34 +756,12 @@ pub fn render(
shadow, shadow,
antialias, antialias,
false, false,
false,
); );
} }
/// Internal function to render a stroke with support for offscreen blur rendering.
///
/// # Parameters
/// - `render_state`: The rendering state containing surfaces and context.
/// - `shape`: The shape to render the stroke for.
/// - `stroke`: The stroke configuration (width, fill, style, etc.).
/// - `surface_id`: Optional target surface ID. Defaults to `SurfaceId::Strokes` if `None`.
/// - `shadow`: Optional shadow filter to apply to the stroke.
/// - `antialias`: Whether to use antialiasing for rendering.
/// - `bypass_filter`:
/// - If `false`, attempts to use offscreen filter surface for blur effects.
/// - If `true`, renders directly to the target surface (used for recursive calls to avoid infinite loops when rendering into the filter surface).
///
/// # Behavior
/// When `bypass_filter` is `false` and the shape has a blur filter:
/// 1. Calculates bounds including stroke width and cap margins.
/// 2. Attempts to render into an offscreen filter surface at unscaled coordinates.
/// 3. If successful, composites the result back to the target surface and returns early.
/// 4. If the offscreen render fails or `bypass_filter` is `true`, renders directly to the target
/// surface using the appropriate drawing function for the shape type.
///
/// The recursive call with `bypass_filter=true` ensures that when rendering into the filter
/// surface, we don't attempt to create another filter surface, avoiding infinite recursion.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn render_internal( fn render_single_internal(
render_state: &mut RenderState, render_state: &mut RenderState,
shape: &Shape, shape: &Shape,
stroke: &Stroke, stroke: &Stroke,
@@ -561,10 +769,10 @@ fn render_internal(
shadow: Option<&ImageFilter>, shadow: Option<&ImageFilter>,
antialias: bool, antialias: bool,
bypass_filter: bool, bypass_filter: bool,
skip_blur: bool,
) { ) {
if !bypass_filter { if !bypass_filter {
if let Some(image_filter) = shape.image_filter(1.) { if let Some(image_filter) = shape.image_filter(1.) {
// We have to calculate the bounds considering the stroke and the cap margins.
let mut content_bounds = shape.selrect; let mut content_bounds = shape.selrect;
let stroke_margin = stroke.bounds_width(shape.is_open()); let stroke_margin = stroke.bounds_width(shape.is_open());
if stroke_margin > 0.0 { if stroke_margin > 0.0 {
@@ -582,7 +790,7 @@ fn render_internal(
bounds, bounds,
target, target,
|state, temp_surface| { |state, temp_surface| {
render_internal( render_single_internal(
state, state,
shape, shape,
stroke, stroke,
@@ -590,6 +798,7 @@ fn render_internal(
shadow, shadow,
antialias, antialias,
true, true,
true,
); );
}, },
) { ) {
@@ -605,6 +814,12 @@ fn render_internal(
let path_transform = shape.to_path_transform(); let path_transform = shape.to_path_transform();
let svg_attrs = shape.svg_attrs.as_ref(); let svg_attrs = shape.svg_attrs.as_ref();
let blur = if skip_blur {
None
} else {
shape.image_filter(1.)
};
if !matches!(shape.shape_type, Type::Text(_)) if !matches!(shape.shape_type, Type::Text(_))
&& shadow.is_none() && shadow.is_none()
&& matches!(stroke.fill, Fill::Image(_)) && matches!(stroke.fill, Fill::Image(_))
@@ -622,42 +837,45 @@ fn render_internal(
} else { } else {
match &shape.shape_type { match &shape.shape_type {
shape_type @ (Type::Rect(_) | Type::Frame(_)) => { shape_type @ (Type::Rect(_) | Type::Frame(_)) => {
let paint = stroke.to_paint(&selrect, svg_attrs, antialias);
draw_stroke_on_rect( draw_stroke_on_rect(
canvas, canvas,
stroke, stroke,
&selrect, &selrect,
&selrect,
&shape_type.corners(), &shape_type.corners(),
svg_attrs, &paint,
scale, scale,
shadow, shadow,
shape.image_filter(1.).as_ref(), blur.as_ref(),
antialias,
);
}
Type::Circle => {
let paint = stroke.to_paint(&selrect, svg_attrs, antialias);
draw_stroke_on_circle(
canvas,
stroke,
&selrect,
&paint,
scale,
shadow,
blur.as_ref(),
antialias, antialias,
); );
} }
Type::Circle => draw_stroke_on_circle(
canvas,
stroke,
&selrect,
&selrect,
svg_attrs,
scale,
shadow,
shape.image_filter(1.).as_ref(),
antialias,
),
Type::Text(_) => {} Type::Text(_) => {}
shape_type @ (Type::Path(_) | Type::Bool(_)) => { shape_type @ (Type::Path(_) | Type::Bool(_)) => {
if let Some(path) = shape_type.path() { if let Some(path) = shape_type.path() {
let is_open = path.is_open();
let paint = stroke.to_stroked_paint(is_open, &selrect, svg_attrs, antialias);
draw_stroke_on_path( draw_stroke_on_path(
canvas, canvas,
stroke, stroke,
path, path,
&selrect, &paint,
path_transform.as_ref(), path_transform.as_ref(),
svg_attrs,
shadow, shadow,
shape.image_filter(1.).as_ref(), blur.as_ref(),
antialias, antialias,
); );
} }

View File

@@ -0,0 +1,240 @@
use crate::shapes::{Shape, TextContent, Type, VerticalAlign};
use crate::state::{TextEditorState, TextSelection};
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
use skia_safe::{BlendMode, Canvas, Matrix, Paint, Rect};
pub fn render_overlay(
canvas: &Canvas,
editor_state: &TextEditorState,
shape: &Shape,
transform: &Matrix,
) {
if !editor_state.is_active {
return;
}
let Type::Text(text_content) = &shape.shape_type else {
return;
};
canvas.save();
canvas.concat(transform);
if editor_state.selection.is_selection() {
render_selection(canvas, editor_state, text_content, shape);
}
if editor_state.cursor_visible {
render_cursor(canvas, editor_state, text_content, shape);
}
canvas.restore();
}
fn render_cursor(
canvas: &Canvas,
editor_state: &TextEditorState,
text_content: &TextContent,
shape: &Shape,
) {
let Some(rect) = calculate_cursor_rect(editor_state, text_content, shape) else {
return;
};
let mut paint = Paint::default();
paint.set_color(editor_state.theme.cursor_color);
paint.set_anti_alias(true);
canvas.draw_rect(rect, &paint);
}
fn render_selection(
canvas: &Canvas,
editor_state: &TextEditorState,
text_content: &TextContent,
shape: &Shape,
) {
let selection = &editor_state.selection;
let rects = calculate_selection_rects(selection, text_content, shape);
if rects.is_empty() {
return;
}
let mut paint = Paint::default();
paint.set_blend_mode(BlendMode::Multiply);
paint.set_color(editor_state.theme.selection_color);
paint.set_anti_alias(true);
for rect in rects {
canvas.draw_rect(rect, &paint);
}
}
fn vertical_align_offset(
shape: &Shape,
layout_paragraphs: &[&skia_safe::textlayout::Paragraph],
) -> f32 {
let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum();
match shape.vertical_align() {
VerticalAlign::Center => (shape.selrect().height() - total_height) / 2.0,
VerticalAlign::Bottom => shape.selrect().height() - total_height,
_ => 0.0,
}
}
fn calculate_cursor_rect(
editor_state: &TextEditorState,
text_content: &TextContent,
shape: &Shape,
) -> Option<Rect> {
let cursor = editor_state.selection.focus;
let paragraphs = text_content.paragraphs();
if cursor.paragraph >= paragraphs.len() {
return None;
}
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
if cursor.paragraph >= layout_paragraphs.len() {
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;
// For cursor, we get a zero-width range at the position
// We need to handle edge cases:
// - At start of paragraph: use position 0
// - At end of paragraph: use last position
let para = &paragraphs[cursor.paragraph];
let para_char_count: usize = para
.children()
.iter()
.map(|span| span.text.chars().count())
.sum();
let (cursor_x, cursor_height) = if para_char_count == 0 {
// Empty paragraph - use default height
(0.0, laid_out_para.height())
} else if char_pos == 0 {
let rects = laid_out_para.get_rects_for_range(
0..1,
RectHeightStyle::Max,
RectWidthStyle::Tight,
);
if !rects.is_empty() {
(rects[0].rect.left(), rects[0].rect.height())
} else {
(0.0, laid_out_para.height())
}
} else if char_pos >= para_char_count {
let rects = laid_out_para.get_rects_for_range(
para_char_count.saturating_sub(1)..para_char_count,
RectHeightStyle::Max,
RectWidthStyle::Tight,
);
if !rects.is_empty() {
(rects[0].rect.right(), rects[0].rect.height())
} else {
(laid_out_para.longest_line(), laid_out_para.height())
}
} else {
let rects = laid_out_para.get_rects_for_range(
char_pos..char_pos + 1,
RectHeightStyle::Max,
RectWidthStyle::Tight,
);
if !rects.is_empty() {
(rects[0].rect.left(), rects[0].rect.height())
} else {
// Fallback: use glyph position
let pos = laid_out_para.get_glyph_position_at_coordinate((0.0, 0.0));
(pos.position as f32, laid_out_para.height())
}
};
return Some(Rect::from_xywh(
selrect.x() + cursor_x,
selrect.y() + y_offset,
editor_state.theme.cursor_width,
cursor_height,
));
}
y_offset += laid_out_para.height();
}
None
}
fn calculate_selection_rects(
selection: &TextSelection,
text_content: &TextContent,
shape: &Shape,
) -> Vec<Rect> {
let mut rects = Vec::new();
let start = selection.start();
let end = selection.end();
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() {
let para_height = laid_out_para.height();
// Check if this paragraph is in selection range
if para_idx < start.paragraph || para_idx > end.paragraph {
y_offset += para_height;
continue;
}
// Calculate character range for this paragraph
let para = &paragraphs[para_idx];
let para_char_count: usize = para
.children()
.iter()
.map(|span| span.text.chars().count())
.sum();
let range_start = if para_idx == start.paragraph {
start.char_offset
} else {
0
};
let range_end = if para_idx == end.paragraph {
end.char_offset
} else {
para_char_count
};
if range_start < range_end {
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
let text_boxes = laid_out_para.get_rects_for_range(
range_start..range_end,
RectHeightStyle::Max,
RectWidthStyle::Tight,
);
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.width(),
r.height(),
));
}
}
y_offset += para_height;
}
rects
}

View File

@@ -620,6 +620,7 @@ impl Shape {
(added, removed) (added, removed)
} }
#[allow(dead_code)]
pub fn fills(&self) -> std::slice::Iter<'_, Fill> { pub fn fills(&self) -> std::slice::Iter<'_, Fill> {
self.fills.iter() self.fills.iter()
} }
@@ -1119,6 +1120,28 @@ impl Shape {
} }
} }
/// Returns children in forward (non-reversed) order - useful for layout calculations
pub fn children_ids_iter_forward(
&self,
include_hidden: bool,
) -> Box<dyn Iterator<Item = &Uuid> + '_> {
if include_hidden {
return Box::new(self.children.iter());
}
if let Type::Bool(_) = self.shape_type {
Box::new([].iter())
} else if let Type::Group(group) = self.shape_type {
if group.masked {
Box::new(self.children.iter().skip(1))
} else {
Box::new(self.children.iter())
}
} else {
Box::new(self.children.iter())
}
}
pub fn all_children( pub fn all_children(
&self, &self,
shapes: ShapesPoolRef, shapes: ShapesPoolRef,

View File

@@ -241,10 +241,14 @@ pub fn merge_fills(fills: &[Fill], bounding_box: Rect) -> skia::Paint {
if let Some(shader) = shader { if let Some(shader) = shader {
combined_shader = match combined_shader { combined_shader = match combined_shader {
// Use SrcOver and treat the newly encountered fill as the source (top),
// overlaying it over the previously composed shader (destination/bottom).
// This avoids edge bleed from underlying fills when anti-aliasing causes
// fractional coverage at shape boundaries.
Some(existing_shader) => Some(skia::shaders::blend( Some(existing_shader) => Some(skia::shaders::blend(
skia::Blender::mode(skia::BlendMode::DstOver), skia::Blender::mode(skia::BlendMode::SrcOver),
existing_shader,
shader, shader,
existing_shader,
)), )),
None => Some(shader), None => Some(shader),
}; };

View File

@@ -300,7 +300,20 @@ fn propagate_reflow(
Type::Frame(Frame { Type::Frame(Frame {
layout: Some(_), .. layout: Some(_), ..
}) => { }) => {
layout_reflows.insert(*id); 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);
}
} }
Type::Group(Group { masked: true }) => { Type::Group(Group { masked: true }) => {
let children_ids = shape.children_ids(true); let children_ids = shape.children_ids(true);
@@ -417,28 +430,26 @@ pub fn propagate_modifiers(
} }
} }
} }
// We sort the reflows so they are processed deepest-first in the
let mut layout_reflows_vec: Vec<Uuid> = layout_reflows.into_iter().collect(); // tree structure. This way we can be sure that the children layouts
// are already reflowed before their parents.
// We sort the reflows so they are process first the ones that are more let mut layout_reflows_vec: Vec<Uuid> =
// deep in the tree structure. This way we can be sure that the children layouts std::mem::take(&mut layout_reflows).into_iter().collect();
// are already reflowed.
layout_reflows_vec.sort_unstable_by(|id_a, id_b| { layout_reflows_vec.sort_unstable_by(|id_a, id_b| {
let da = shapes.get_depth(id_a); let da = shapes.get_depth(id_a);
let db = shapes.get_depth(id_b); let db = shapes.get_depth(id_b);
db.cmp(&da) db.cmp(&da)
}); });
let mut bounds_temp = bounds.clone(); for id in &layout_reflows_vec {
for id in layout_reflows_vec.iter() {
if reflown.contains(id) { if reflown.contains(id) {
continue; continue;
} }
reflow_shape(id, state, &mut reflown, &mut entries, &mut bounds_temp); reflow_shape(id, state, &mut reflown, &mut entries, &mut bounds);
} }
layout_reflows = HashSet::new();
} }
#[allow(dead_code)]
modifiers modifiers
.iter() .iter()
.map(|(key, val)| TransformEntry::from_input(*key, *val)) .map(|(key, val)| TransformEntry::from_input(*key, *val))

View File

@@ -184,15 +184,18 @@ fn initialize_tracks(
) -> Vec<TrackData> { ) -> Vec<TrackData> {
let mut tracks = Vec::<TrackData>::new(); let mut tracks = Vec::<TrackData>::new();
let mut current_track = TrackData::default(); let mut current_track = TrackData::default();
let mut children = shape.children_ids(true);
let mut first = true; let mut first = true;
if flex_data.is_reverse() { // When is_reverse() is true, we need forward order (children_ids_iter_forward).
children.reverse(); // When is_reverse() is false, we need reversed order (children_ids_iter).
} let children_iter: Box<dyn Iterator<Item = Uuid>> = if flex_data.is_reverse() {
Box::new(shape.children_ids_iter_forward(true).copied())
} else {
Box::new(shape.children_ids_iter(true).copied())
};
for child_id in children.iter() { for child_id in children_iter {
let Some(child) = shapes.get(child_id) else { let Some(child) = shapes.get(&child_id) else {
continue; continue;
}; };
@@ -293,7 +296,7 @@ fn distribute_fill_main_space(layout_axis: &LayoutAxis, tracks: &mut [TrackData]
track.main_size += delta; track.main_size += delta;
if (child.main_size - child.max_main_size).abs() < MIN_SIZE { if (child.main_size - child.max_main_size).abs() < MIN_SIZE {
to_resize_children.remove(i); to_resize_children.swap_remove(i);
} }
} }
} }
@@ -330,7 +333,7 @@ fn distribute_fill_across_space(layout_axis: &LayoutAxis, tracks: &mut [TrackDat
left_space -= delta; left_space -= delta;
if (track.across_size - track.max_across_size).abs() < MIN_SIZE { if (track.across_size - track.max_across_size).abs() < MIN_SIZE {
to_resize_tracks.remove(i); to_resize_tracks.swap_remove(i);
} }
} }
} }

View File

@@ -6,7 +6,7 @@ use crate::shapes::{
}; };
use crate::state::ShapesPoolRef; use crate::state::ShapesPoolRef;
use crate::uuid::Uuid; use crate::uuid::Uuid;
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, HashSet, VecDeque};
use super::common::GetBounds; use super::common::GetBounds;
@@ -537,7 +537,7 @@ fn cell_bounds(
pub fn create_cell_data<'a>( pub fn create_cell_data<'a>(
layout_bounds: &Bounds, layout_bounds: &Bounds,
children: &[Uuid], children: &HashSet<Uuid>,
shapes: ShapesPoolRef<'a>, shapes: ShapesPoolRef<'a>,
cells: &Vec<GridCell>, cells: &Vec<GridCell>,
column_tracks: &[TrackData], column_tracks: &[TrackData],
@@ -614,7 +614,7 @@ pub fn grid_cell_data<'a>(
let bounds = &mut HashMap::<Uuid, Bounds>::new(); let bounds = &mut HashMap::<Uuid, Bounds>::new();
let layout_bounds = shape.bounds(); let layout_bounds = shape.bounds();
let children = shape.children_ids(false); let children: HashSet<Uuid> = shape.children_ids_iter(false).copied().collect();
let column_tracks = calculate_tracks( let column_tracks = calculate_tracks(
true, true,
@@ -707,7 +707,7 @@ pub fn reflow_grid_layout(
) -> VecDeque<Modifier> { ) -> VecDeque<Modifier> {
let mut result = VecDeque::new(); let mut result = VecDeque::new();
let layout_bounds = bounds.find(shape); let layout_bounds = bounds.find(shape);
let children = shape.children_ids(true); let children: HashSet<Uuid> = shape.children_ids_iter(true).copied().collect();
let column_tracks = calculate_tracks( let column_tracks = calculate_tracks(
true, true,

View File

@@ -119,6 +119,19 @@ impl Stroke {
self.width *= value; self.width *= value;
} }
/// Returns the clip operation for dotted inner/outer strokes.
/// Returns `None` when no clipping is needed (center or non-dotted).
pub fn clip_op(&self) -> Option<skia::ClipOp> {
if self.style != StrokeStyle::Dotted || self.kind == StrokeKind::Center {
return None;
}
match self.kind {
StrokeKind::Inner => Some(skia::ClipOp::Intersect),
StrokeKind::Outer => Some(skia::ClipOp::Difference),
StrokeKind::Center => None,
}
}
pub fn delta(&self) -> f32 { pub fn delta(&self) -> f32 {
match self.kind { match self.kind {
StrokeKind::Inner => 0., StrokeKind::Inner => 0.,
@@ -128,20 +141,28 @@ impl Stroke {
} }
pub fn outer_rect(&self, rect: &Rect) -> Rect { pub fn outer_rect(&self, rect: &Rect) -> Rect {
match self.kind { match (self.kind, self.style) {
StrokeKind::Inner => Rect::from_xywh( (StrokeKind::Inner, StrokeStyle::Dotted) | (StrokeKind::Outer, StrokeStyle::Dotted) => {
rect.left + (self.width / 2.), // Boundary so circles center on it and semicircles match after clipping
rect.top + (self.width / 2.), *rect
rect.width() - self.width, }
rect.height() - self.width, _ => match self.kind {
), StrokeKind::Inner => Rect::from_xywh(
StrokeKind::Center => Rect::from_xywh(rect.left, rect.top, rect.width(), rect.height()), rect.left + (self.width / 2.),
StrokeKind::Outer => Rect::from_xywh( rect.top + (self.width / 2.),
rect.left - (self.width / 2.), rect.width() - self.width,
rect.top - (self.width / 2.), rect.height() - self.width,
rect.width() + self.width, ),
rect.height() + self.width, StrokeKind::Center => {
), Rect::from_xywh(rect.left, rect.top, rect.width(), rect.height())
}
StrokeKind::Outer => Rect::from_xywh(
rect.left - (self.width / 2.),
rect.top - (self.width / 2.),
rect.width() + self.width,
rect.height() + self.width,
),
},
} }
} }
@@ -155,6 +176,11 @@ impl Stroke {
} }
pub fn outer_corners(&self, corners: &Corners) -> Corners { pub fn outer_corners(&self, corners: &Corners) -> Corners {
if matches!(self.style, StrokeStyle::Dotted | StrokeStyle::Dashed) {
// Path at boundary so no corner offset
return *corners;
}
let offset = match self.kind { let offset = match self.kind {
StrokeKind::Center => 0.0, StrokeKind::Center => 0.0,
StrokeKind::Inner => -self.width / 2.0, StrokeKind::Inner => -self.width / 2.0,

View File

@@ -116,6 +116,7 @@ impl TextContentSize {
pub struct TextPositionWithAffinity { pub struct TextPositionWithAffinity {
pub position_with_affinity: PositionWithAffinity, pub position_with_affinity: PositionWithAffinity,
pub paragraph: i32, pub paragraph: i32,
#[allow(dead_code)]
pub span: i32, pub span: i32,
pub offset: i32, pub offset: i32,
} }
@@ -316,6 +317,10 @@ impl TextContent {
&self.paragraphs &self.paragraphs
} }
pub fn paragraphs_mut(&mut self) -> &mut Vec<Paragraph> {
&mut self.paragraphs
}
pub fn width(&self) -> f32 { pub fn width(&self) -> f32 {
self.size.width self.size.width
} }
@@ -428,8 +433,16 @@ impl TextContent {
let end_y = offset_y + layout_paragraph.height(); let end_y = offset_y + layout_paragraph.height();
// We only test against paragraphs that can contain the current y // We only test against paragraphs that can contain the current y
// coordinate. // coordinate. Use >= for start and handle zero-height paragraphs.
if point.y > start_y && point.y < end_y { let paragraph_height = layout_paragraph.height();
let matches = if paragraph_height > 0.0 {
point.y >= start_y && point.y < end_y
} else {
// For zero-height paragraphs (empty lines), match if we're at the start position
point.y >= start_y && point.y <= start_y + 1.0
};
if matches {
let position_with_affinity = let position_with_affinity =
layout_paragraph.get_glyph_position_at_coordinate(*point); 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 as usize) {
@@ -438,18 +451,37 @@ impl TextContent {
// in which span we are. // in which span we are.
let mut computed_position = 0; let mut computed_position = 0;
let mut span_offset = 0; let mut span_offset = 0;
for span in paragraph.children() {
span_index += 1; // If paragraph has no spans, default to span 0, offset 0
let length = span.text.len(); if paragraph.children().is_empty() {
let start_position = computed_position; span_index = 0;
let end_position = computed_position + length; span_offset = 0;
let current_position = position_with_affinity.position as usize; } else {
if start_position <= current_position && end_position >= current_position { for span in paragraph.children() {
span_offset = position_with_affinity.position - start_position as i32; span_index += 1;
break; let length = span.text.chars().count();
let start_position = computed_position;
let end_position = computed_position + length;
let current_position = position_with_affinity.position as usize;
// 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;
break;
}
if start_position <= current_position
&& end_position >= current_position
{
span_offset =
position_with_affinity.position - start_position as i32;
break;
}
computed_position += length;
} }
computed_position += length;
} }
return Some(TextPositionWithAffinity::new( return Some(TextPositionWithAffinity::new(
position_with_affinity, position_with_affinity,
paragraph_index, paragraph_index,
@@ -460,6 +492,26 @@ impl TextContent {
} }
offset_y += layout_paragraph.height(); offset_y += layout_paragraph.height();
} }
// Handle completely empty text shapes: if there are no paragraphs or all paragraphs
// are empty, and the click is within the text shape bounds, return a default position
if (self.paragraphs().is_empty() || self.layout.paragraphs.is_empty())
&& self.bounds.contains(*point)
{
// Create a default position at the start of the text
use skia_safe::textlayout::Affinity;
let default_position = PositionWithAffinity {
position: 0,
affinity: Affinity::Downstream,
};
return Some(TextPositionWithAffinity::new(
default_position,
0, // paragraph 0
0, // span 0
0, // offset 0
));
}
None None
} }
@@ -838,6 +890,10 @@ impl Paragraph {
&self.children &self.children
} }
pub fn children_mut(&mut self) -> &mut Vec<TextSpan> {
&mut self.children
}
#[allow(dead_code)] #[allow(dead_code)]
fn add_span(&mut self, span: TextSpan) { fn add_span(&mut self, span: TextSpan) {
self.children.push(span); self.children.push(span);
@@ -847,6 +903,26 @@ impl Paragraph {
self.line_height self.line_height
} }
pub fn letter_spacing(&self) -> f32 {
self.letter_spacing
}
pub fn text_align(&self) -> TextAlign {
self.text_align
}
pub fn text_direction(&self) -> TextDirection {
self.text_direction
}
pub fn text_decoration(&self) -> Option<TextDecoration> {
self.text_decoration
}
pub fn text_transform(&self) -> Option<TextTransform> {
self.text_transform
}
pub fn paragraph_to_style(&self) -> ParagraphStyle { pub fn paragraph_to_style(&self) -> ParagraphStyle {
let mut style = ParagraphStyle::default(); let mut style = ParagraphStyle::default();
@@ -1228,14 +1304,21 @@ pub fn calculate_text_layout_data(
let current_y = para_layout.y; let current_y = para_layout.y;
let text_paragraph = text_paragraphs.get(paragraph_index); let text_paragraph = text_paragraphs.get(paragraph_index);
if let Some(text_para) = text_paragraph { if let Some(text_para) = text_paragraph {
let mut span_ranges: Vec<(usize, usize, usize)> = vec![]; let mut span_ranges: Vec<(usize, usize, usize, String, String)> = vec![];
let mut cur = 0; let mut cur = 0;
for (span_index, span) in text_para.children().iter().enumerate() { for (span_index, span) in text_para.children().iter().enumerate() {
let text: String = span.apply_text_transform(); let transformed_text: String = span.apply_text_transform();
span_ranges.push((cur, cur + text.len(), span_index)); let original_text = span.text.clone();
cur += text.len(); let text = transformed_text.clone();
let text_len = text.len();
span_ranges.push((cur, cur + text_len, span_index, text, original_text));
cur += text_len;
} }
for (start, end, span_index) in span_ranges { for (start, end, span_index, transformed_text, original_text) in span_ranges {
// Skip empty spans to avoid invalid rect calculations
if start >= end {
continue;
}
let rects = para_layout.paragraph.get_rects_for_range( let rects = para_layout.paragraph.get_rects_for_range(
start..end, start..end,
RectHeightStyle::Tight, RectHeightStyle::Tight,
@@ -1245,22 +1328,43 @@ pub fn calculate_text_layout_data(
let direction = textbox.direct; let direction = textbox.direct;
let mut rect = textbox.rect; let mut rect = textbox.rect;
let cy = rect.top + rect.height() / 2.0; let cy = rect.top + rect.height() / 2.0;
let start_pos = para_layout
// Get byte positions from Skia's transformed text layout
let glyph_start = para_layout
.paragraph .paragraph
.get_glyph_position_at_coordinate((rect.left + 0.1, cy)) .get_glyph_position_at_coordinate((rect.left + 0.1, cy))
.position as usize; .position as usize;
let end_pos = para_layout let glyph_end = para_layout
.paragraph .paragraph
.get_glyph_position_at_coordinate((rect.right - 0.1, cy)) .get_glyph_position_at_coordinate((rect.right - 0.1, cy))
.position as usize; .position as usize;
let start_pos = start_pos.saturating_sub(start);
let end_pos = end_pos.saturating_sub(start); // Convert to byte positions relative to this span
let byte_start = glyph_start.saturating_sub(start);
let byte_end = glyph_end.saturating_sub(start);
// Convert byte positions to character positions in ORIGINAL text
// This handles multi-byte UTF-8 and text transform differences
let char_start = transformed_text
.char_indices()
.position(|(i, _)| i >= byte_start)
.unwrap_or(0);
let char_end = transformed_text
.char_indices()
.position(|(i, _)| i >= byte_end)
.unwrap_or_else(|| transformed_text.chars().count());
// Clamp to original text length for safety
let original_char_count = original_text.chars().count();
let final_start = char_start.min(original_char_count);
let final_end = char_end.min(original_char_count);
rect.offset((x, current_y)); rect.offset((x, current_y));
position_data.push(PositionData { position_data.push(PositionData {
paragraph: paragraph_index as u32, paragraph: paragraph_index as u32,
span: span_index as u32, span: span_index as u32,
start_pos: start_pos as u32, start_pos: final_start as u32,
end_pos: end_pos as u32, end_pos: final_end as u32,
x: rect.x(), x: rect.x(),
y: rect.y(), y: rect.y(),
width: rect.width(), width: rect.width(),

View File

@@ -1,9 +1,226 @@
#![allow(dead_code)] #![allow(dead_code)]
use crate::shapes::TextPositionWithAffinity; use crate::shapes::TextPositionWithAffinity;
use crate::uuid::Uuid;
use skia_safe::Color;
/// TODO: Now this is just a tuple with 2 i32 working /// Cursor position within text content.
/// as indices (paragraph and span). /// 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,
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct TextSelection {
pub anchor: TextCursor,
pub focus: TextCursor,
}
impl TextSelection {
pub fn new() -> Self {
Self::default()
}
pub fn from_cursor(cursor: TextCursor) -> Self {
Self {
anchor: cursor,
focus: cursor,
}
}
pub fn is_collapsed(&self) -> bool {
self.anchor == self.focus
}
pub fn is_selection(&self) -> bool {
!self.is_collapsed()
}
pub fn set_caret(&mut self, cursor: TextCursor) {
self.anchor = cursor;
self.focus = cursor;
}
pub fn extend_to(&mut self, cursor: TextCursor) {
self.focus = cursor;
}
pub fn collapse_to_focus(&mut self) {
self.anchor = self.focus;
}
pub fn collapse_to_anchor(&mut self) {
self.focus = self.anchor;
}
pub fn start(&self) -> TextCursor {
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 {
self.anchor
} else {
self.focus
}
}
pub fn end(&self) -> TextCursor {
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 {
self.anchor
} else {
self.focus
}
}
}
/// Events that the text editor can emit for frontend synchronization
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum EditorEvent {
None = 0,
ContentChanged = 1,
SelectionChanged = 2,
NeedsLayout = 3,
}
/// FIXME: It should be better to get these constants from the frontend through the API.
const SELECTION_COLOR: Color = Color::from_argb(255, 0, 209, 184);
const CURSOR_WIDTH: f32 = 1.5;
const CURSOR_COLOR: Color = Color::BLACK;
const CURSOR_BLINK_INTERVAL_MS: f64 = 530.0;
pub struct TextEditorTheme {
pub selection_color: Color,
pub cursor_width: f32,
pub cursor_color: Color,
}
pub struct TextEditorState {
pub theme: TextEditorTheme,
pub selection: TextSelection,
pub is_active: bool,
pub active_shape_id: Option<Uuid>,
pub cursor_visible: bool,
pub last_blink_time: f64,
pending_events: Vec<EditorEvent>,
}
impl TextEditorState {
pub fn new() -> Self {
Self {
theme: TextEditorTheme {
selection_color: SELECTION_COLOR,
cursor_width: CURSOR_WIDTH,
cursor_color: CURSOR_COLOR,
},
selection: TextSelection::new(),
is_active: false,
active_shape_id: None,
cursor_visible: true,
last_blink_time: 0.0,
pending_events: Vec::new(),
}
}
pub fn start(&mut self, shape_id: Uuid) {
self.is_active = true;
self.active_shape_id = Some(shape_id);
self.cursor_visible = true;
self.last_blink_time = 0.0;
self.selection = TextSelection::new();
self.pending_events.clear();
}
pub fn stop(&mut self) {
self.is_active = false;
self.active_shape_id = None;
self.cursor_visible = 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 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);
self.reset_blink();
self.push_event(EditorEvent::SelectionChanged);
}
pub fn update_blink(&mut self, timestamp_ms: f64) {
if !self.is_active {
return;
}
if self.last_blink_time == 0.0 {
self.last_blink_time = timestamp_ms;
self.cursor_visible = true;
return;
}
let elapsed = timestamp_ms - self.last_blink_time;
if elapsed >= CURSOR_BLINK_INTERVAL_MS {
self.cursor_visible = !self.cursor_visible;
self.last_blink_time = timestamp_ms;
}
}
pub fn reset_blink(&mut self) {
self.cursor_visible = true;
self.last_blink_time = 0.0;
}
pub fn push_event(&mut self, event: EditorEvent) {
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 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)] #[derive(Debug, PartialEq, Clone, Copy)]
pub struct TextNodePosition { pub struct TextNodePosition {
pub paragraph: i32, pub paragraph: i32,
@@ -15,89 +232,7 @@ impl TextNodePosition {
Self { paragraph, span } Self { paragraph, span }
} }
#[allow(dead_code)]
pub fn is_invalid(&self) -> bool { pub fn is_invalid(&self) -> bool {
self.paragraph < 0 || self.span < 0 self.paragraph < 0 || self.span < 0
} }
} }
pub struct TextPosition {
node: Option<TextNodePosition>,
offset: i32,
}
impl TextPosition {
pub fn new() -> Self {
Self {
node: None,
offset: -1,
}
}
pub fn set(&mut self, node: Option<TextNodePosition>, offset: i32) {
self.node = node;
self.offset = offset;
}
}
pub struct TextSelection {
focus: TextPosition,
anchor: TextPosition,
}
impl TextSelection {
pub fn new() -> Self {
Self {
focus: TextPosition::new(),
anchor: TextPosition::new(),
}
}
#[allow(dead_code)]
pub fn is_caret(&self) -> bool {
self.focus.node == self.anchor.node && self.focus.offset == self.anchor.offset
}
#[allow(dead_code)]
pub fn is_selection(&self) -> bool {
!self.is_caret()
}
pub fn set_focus(&mut self, node: Option<TextNodePosition>, offset: i32) {
self.focus.set(node, offset);
}
pub fn set_anchor(&mut self, node: Option<TextNodePosition>, offset: i32) {
self.anchor.set(node, offset);
}
pub fn set(&mut self, node: Option<TextNodePosition>, offset: i32) {
self.set_focus(node, offset);
self.set_anchor(node, offset);
}
}
pub struct TextEditorState {
selection: TextSelection,
}
impl TextEditorState {
pub fn new() -> Self {
Self {
selection: TextSelection::new(),
}
}
pub fn set_caret_position_from(
&mut self,
text_position_with_affinity: TextPositionWithAffinity,
) {
self.selection.set(
Some(TextNodePosition::new(
text_position_with_affinity.paragraph,
text_position_with_affinity.span,
)),
text_position_with_affinity.offset,
);
}
}

View File

@@ -209,16 +209,19 @@ impl PendingTiles {
} }
} }
pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces) { // Generate tiles in spiral order from center
self.list.clear(); fn generate_spiral(rect: &TileRect) -> Vec<Tile> {
let columns = rect.width();
let columns = tile_viewbox.interest_rect.width(); let rows = rect.height();
let rows = tile_viewbox.interest_rect.height();
let total = columns * rows; let total = columns * rows;
let mut cx = tile_viewbox.interest_rect.center_x(); if total <= 0 {
let mut cy = tile_viewbox.interest_rect.center_y(); return Vec::new();
}
let mut result = Vec::with_capacity(total as usize);
let mut cx = rect.center_x();
let mut cy = rect.center_y();
let ratio = (columns as f32 / rows as f32).ceil() as i32; let ratio = (columns as f32 / rows as f32).ceil() as i32;
@@ -228,7 +231,7 @@ impl PendingTiles {
let mut direction = 0; let mut direction = 0;
let mut current = 0; let mut current = 0;
self.list.push(Tile(cx, cy)); result.push(Tile(cx, cy));
while current < total { while current < total {
match direction { match direction {
0 => cx += 1, 0 => cx += 1,
@@ -238,7 +241,7 @@ impl PendingTiles {
_ => unreachable!("Invalid direction"), _ => unreachable!("Invalid direction"),
} }
self.list.push(Tile(cx, cy)); result.push(Tile(cx, cy));
direction_current += 1; direction_current += 1;
let direction_total = if direction % 2 == 0 { let direction_total = if direction % 2 == 0 {
@@ -258,18 +261,44 @@ impl PendingTiles {
} }
current += 1; current += 1;
} }
self.list.reverse(); result.reverse();
result
}
// Create a new list where the cached tiles go first pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces) {
let iter1 = self self.list.clear();
.list
.iter() // Generate spiral for the interest area (viewport + margin)
.filter(|t| surfaces.has_cached_tile_surface(**t)); let spiral = Self::generate_spiral(&tile_viewbox.interest_rect);
let iter2 = self
.list // Partition tiles into 4 priority groups (highest priority = processed last due to pop()):
.iter() // 1. visible + cached (fastest - just blit from cache)
.filter(|t| !surfaces.has_cached_tile_surface(**t)); // 2. visible + uncached (user sees these, render next)
self.list = iter1.chain(iter2).copied().collect(); // 3. interest + cached (pre-rendered area, blit from cache)
// 4. interest + uncached (lowest priority - background pre-render)
let mut visible_cached = Vec::new();
let mut visible_uncached = Vec::new();
let mut interest_cached = Vec::new();
let mut interest_uncached = Vec::new();
for tile in spiral {
let is_visible = tile_viewbox.visible_rect.contains(&tile);
let is_cached = surfaces.has_cached_tile_surface(tile);
match (is_visible, is_cached) {
(true, true) => visible_cached.push(tile),
(true, false) => visible_uncached.push(tile),
(false, true) => interest_cached.push(tile),
(false, false) => interest_uncached.push(tile),
}
}
// Build final list with lowest priority first (they get popped last)
// Order: interest_uncached, interest_cached, visible_uncached, visible_cached
self.list.extend(interest_uncached);
self.list.extend(interest_cached);
self.list.extend(visible_uncached);
self.list.extend(visible_cached);
} }
pub fn pop(&mut self) -> Option<Tile> { pub fn pop(&mut self) -> Option<Tile> {

View File

@@ -9,3 +9,4 @@ pub mod shapes;
pub mod strokes; pub mod strokes;
pub mod svg_attrs; pub mod svg_attrs;
pub mod text; pub mod text;
pub mod text_editor;

View File

File diff suppressed because it is too large Load Diff