Compare commits

..

6 Commits

Author SHA1 Message Date
Andrey Antukh
e2f355ce07 Make constrast plugin be able to run on subpath 2026-02-10 18:58:17 +01:00
Andrey Antukh
483ead59fe Make penpot depend on local plugins runtime
This removes the need to publish versions to pnpn
2026-02-10 18:53:52 +01:00
Andrey Antukh
d8d532ed4f Make the colors-to-tokens plugin work on subpath 2026-02-10 18:53:52 +01:00
Andrey Antukh
a7cec4573d Make the table-plugin work correctly in subpath 2026-02-10 18:53:52 +01:00
Andrey Antukh
6049fa1c96 Add better approach for handling plugin iframe url
Ensure params are passed correctly to plugins declared to be version
2 and are prepared to run in a subpath.
2026-02-10 18:53:52 +01:00
Andrey Antukh
a0fef67c16 Update devenv nginx to serve locally builded plugins 2026-02-10 18:38:51 +01:00
106 changed files with 8071 additions and 24327 deletions

View File

@@ -2,30 +2,4 @@
## Reporting a Vulnerability
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.
Please report security issues to `support@penpot.app`

View File

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

View File

@@ -126,6 +126,12 @@ http {
proxy_http_version 1.1;
}
location /plugins {
autoindex on;
alias /home/penpot/penpot/plugins/dist/apps;
proxy_http_version 1.1;
}
location /mcp/ws {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';

View File

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

View File

@@ -8,7 +8,9 @@ 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,
to test it, use it by you or your team, or even customize and extend it any way you like.
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.
If you need more context you can look at the <a
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
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
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:

View File

@@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC",
"private": true,
"packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8",
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
"browserslist": [
"defaults"
],
@@ -42,12 +42,13 @@
"clear:shadow-cache": "rm -rf .shadow-cljs",
"watch": "exit 0",
"watch:app": "pnpm run clear:shadow-cache && pnpm run build:wasm && concurrently --kill-others-on-fail \"pnpm run watch:app:assets\" \"pnpm run watch:app:main\" \"pnpm run watch:app:libs\"",
"watch:storybook": "pnpm run build:storybook:assets && concurrently --kill-others-on-fail \"storybook dev -p 6006 --no-open\" \"node ./scripts/watch-storybook.js\""
"watch:storybook": "pnpm run build:storybook:assets && concurrently --kill-others-on-fail \"storybook dev -p 6006 --no-open\" \"node ./scripts/watch-storybook.js\"",
"postinstall": "(cd ../plugins/libs/plugins-runtime; pnpm run build)"
},
"devDependencies": {
"@penpot/draft-js": "workspace:./packages/draft-js",
"@penpot/mousetrap": "workspace:./packages/mousetrap",
"@penpot/plugins-runtime": "1.4.2",
"@penpot/plugins-runtime": "link:../plugins/libs/plugins-runtime",
"@penpot/svgo": "penpot/svgo#v3.2",
"@penpot/text-editor": "workspace:./text-editor",
"@penpot/tokenscript": "workspace:./packages/tokenscript",

View File

@@ -8,6 +8,6 @@
"author": "Andrey Antukh",
"license": "MPL-2.0",
"dependencies": {
"@tokens-studio/tokenscript-interpreter": "^0.26.0"
"@tokens-studio/tokenscript-interpreter": "^0.23.1"
}
}

View File

@@ -1,12 +1,12 @@
// Auto-generated by @tokens-studio/tokenscript-schemas
// Version: @tokens-studio/tokenscript-schemas@v0.4.0
// Version: @tokens-studio/tokenscript-schemas@v0.1.2
// GitHub: https://github.com/tokens-studio/tokenscript-schemas
// Command: npx @tokens-studio/tokenscript-schemas bundle preset:css preset:cssColors --output ./schemas.js
// Generated: 2026-02-11T08:46:40.467Z
// Command: npx @tokens-studio/tokenscript-schemas bundle preset:css --output ./tokenscript-schemas.js
// Generated: 2026-01-07T09:21:11.478Z
import { Config } from "@tokens-studio/tokenscript-interpreter";
export const SCHEMAS = [
const SCHEMAS = [
{
uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hex-color/0/",
schema: {
@@ -31,127 +31,7 @@ export const SCHEMAS = [
}
}
],
"conversions": [
{
"source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/",
"target": "$self",
"description": "Converts sRGB (0-1) to Hex format",
"lossless": true,
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// sRGB to Hex Conversion\n// Converts sRGB (0-1) to hexadecimal string format\n//\n// Examples:\n// sRGB(1, 0, 0) → #ff0000\n// sRGB(0, 1, 0.5) → #00ff80\n\nvariable hex: String = \"#\";\nvariable value: Number = 0;\n\n// Red channel\nvalue = round({input}.r * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Green channel\nvalue = round({input}.g * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Blue channel\nvalue = round({input}.b * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\nreturn hex;"
}
},
{
"source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/p3-color/0/",
"target": "$self",
"description": "Converts Display P3 to Hex format (clamps to sRGB gamut)",
"lossless": false,
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// Display P3 to Hex Conversion\n// Converts P3 (0-1) to hexadecimal string format\n// Note: P3 colors may be out of sRGB gamut, values are clamped to 0-1\n//\n// Examples:\n// P3(1, 0, 0) → #ff0000\n// P3(0, 1, 0.5) → #00ff80\n\nvariable hex: String = \"#\";\nvariable value: Number = 0;\n\n// Red channel (clamp P3 to sRGB range)\nvalue = {input}.r;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Green channel\nvalue = {input}.g;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Blue channel\nvalue = {input}.b;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\nreturn hex;"
}
},
{
"source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hsl-color/0/",
"target": "$self",
"description": "Converts HSL to Hex format",
"lossless": true,
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// HSL to Hex Conversion\n// Converts HSL to hexadecimal string format\n// Reference: Standard HSL to RGB algorithm\n//\n// Input: Color.HSL with h (0-360), s (0-1), l (0-1)\n// Output: Hex string #rrggbb\n\n// Get input HSL values\nvariable h: Number = {input}.h;\nvariable s: Number = {input}.s;\nvariable l: Number = {input}.l;\n\n// Normalize hue to 0-1 range\nvariable hue: Number = h / 360;\n\n// RGB values (default to achromatic)\nvariable r: Number = l;\nvariable g: Number = l;\nvariable b: Number = l;\n\n// Only calculate if there's saturation\nif (s > 0) [\n variable q: Number = 0;\n if (l < 0.5) [\n q = l * (1 + s);\n ] else [\n q = l + s - l * s;\n ];\n\n variable p: Number = 2 * l - q;\n\n // Red (hue + 1/3)\n variable tr: Number = hue + 0.333333333333333;\n if (tr < 0) [ tr = tr + 1; ];\n if (tr > 1) [ tr = tr - 1; ];\n\n if (tr < 0.166666666666667) [\n r = p + (q - p) * 6 * tr;\n ] else [\n if (tr < 0.5) [\n r = q;\n ] else [\n if (tr < 0.666666666666667) [\n r = p + (q - p) * (0.666666666666667 - tr) * 6;\n ] else [\n r = p;\n ];\n ];\n ];\n\n // Green (hue)\n variable tg: Number = hue;\n if (tg < 0) [ tg = tg + 1; ];\n if (tg > 1) [ tg = tg - 1; ];\n\n if (tg < 0.166666666666667) [\n g = p + (q - p) * 6 * tg;\n ] else [\n if (tg < 0.5) [\n g = q;\n ] else [\n if (tg < 0.666666666666667) [\n g = p + (q - p) * (0.666666666666667 - tg) * 6;\n ] else [\n g = p;\n ];\n ];\n ];\n\n // Blue (hue - 1/3)\n variable tb: Number = hue - 0.333333333333333;\n if (tb < 0) [ tb = tb + 1; ];\n if (tb > 1) [ tb = tb - 1; ];\n\n if (tb < 0.166666666666667) [\n b = p + (q - p) * 6 * tb;\n ] else [\n if (tb < 0.5) [\n b = q;\n ] else [\n if (tb < 0.666666666666667) [\n b = p + (q - p) * (0.666666666666667 - tb) * 6;\n ] else [\n b = p;\n ];\n ];\n ];\n];\n\n// Convert RGB to hex\nvariable hex: String = \"#\";\nvariable value: Number = 0;\n\n// Red\nvalue = round(r * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Green\nvalue = round(g * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Blue\nvalue = round(b * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\nreturn hex;"
}
},
{
"source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklch-color/0/",
"target": "$self",
"description": "Converts OKLCH to Hex format (clamps to sRGB gamut)",
"lossless": false,
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// OKLCH to Hex Conversion\n// Converts OKLCH perceptual color to hexadecimal string format\n// Path: OKLCH → OKLab → XYZ-D65 → Linear sRGB → sRGB → Hex\n//\n// Input: Color.OKLCH with l (0-1), c, h (0-360)\n// Output: Hex string #rrggbb\n\n// Get input OKLCH values\nvariable ok_l: Number = {input}.l;\nvariable ok_c: Number = {input}.c;\nvariable ok_h: Number = {input}.h;\n\n// === Step 1: OKLCH to OKLab (polar to cartesian) ===\nvariable pi: Number = pi();\nvariable deg_to_rad: Number = pi / 180;\nvariable h_rad: Number = ok_h * deg_to_rad;\n\nvariable lab_a: Number = ok_c * cos(h_rad);\nvariable lab_b: Number = ok_c * sin(h_rad);\n\n// === Step 2: OKLab to XYZ-D65 ===\n// Inverse Lab-to-LMS matrix\nvariable lms_l: Number = 1.0 * ok_l + 0.3963377773761749 * lab_a + 0.2158037573099136 * lab_b;\nvariable lms_m: Number = 1.0 * ok_l + -0.1055613458156586 * lab_a + -0.0638541728258133 * lab_b;\nvariable lms_s: Number = 1.0 * ok_l + -0.0894841775298119 * lab_a + -1.2914855480194092 * lab_b;\n\n// Cube the values (inverse of cube root)\nvariable lms_l_cubed: Number = lms_l * lms_l * lms_l;\nvariable lms_m_cubed: Number = lms_m * lms_m * lms_m;\nvariable lms_s_cubed: Number = lms_s * lms_s * lms_s;\n\n// Inverse LMS-to-XYZ matrix\nvariable xyz_x: Number = 1.2268798758459243 * lms_l_cubed + -0.5578149944602171 * lms_m_cubed + 0.2813910456659647 * lms_s_cubed;\nvariable xyz_y: Number = -0.0405757452148008 * lms_l_cubed + 1.1122868032803170 * lms_m_cubed + -0.0717110580655164 * lms_s_cubed;\nvariable xyz_z: Number = -0.0763729366746601 * lms_l_cubed + -0.4214933324022432 * lms_m_cubed + 1.5869240198367816 * lms_s_cubed;\n\n// === Step 3: XYZ-D65 to Linear sRGB ===\nvariable linear_r: Number = 3.2409699419045226 * xyz_x + -1.537383177570094 * xyz_y + -0.4986107602930034 * xyz_z;\nvariable linear_g: Number = -0.9692436362808796 * xyz_x + 1.8759675015077202 * xyz_y + 0.04155505740717559 * xyz_z;\nvariable linear_b: Number = 0.05563007969699366 * xyz_x + -0.20397695888897652 * xyz_y + 1.0569715142428786 * xyz_z;\n\n// === Step 4: Linear sRGB to sRGB (gamma correction) ===\nvariable threshold: Number = 0.0031308;\nvariable linear_scale: Number = 12.92;\nvariable gamma_offset: Number = 0.055;\nvariable gamma_scale: Number = 1.055;\nvariable gamma_exp: Number = 0.416666666666667;\n\nvariable srgb_r: Number = 0;\nif (linear_r <= threshold) [\n srgb_r = linear_r * linear_scale;\n] else [\n if (linear_r > 0) [\n srgb_r = gamma_scale * pow(linear_r, gamma_exp) - gamma_offset;\n ] else [\n srgb_r = 0;\n ];\n];\n\nvariable srgb_g: Number = 0;\nif (linear_g <= threshold) [\n srgb_g = linear_g * linear_scale;\n] else [\n if (linear_g > 0) [\n srgb_g = gamma_scale * pow(linear_g, gamma_exp) - gamma_offset;\n ] else [\n srgb_g = 0;\n ];\n];\n\nvariable srgb_b: Number = 0;\nif (linear_b <= threshold) [\n srgb_b = linear_b * linear_scale;\n] else [\n if (linear_b > 0) [\n srgb_b = gamma_scale * pow(linear_b, gamma_exp) - gamma_offset;\n ] else [\n srgb_b = 0;\n ];\n];\n\n// === Step 5: sRGB to Hex ===\nvariable hex: String = \"#\";\nvariable value: Number = 0;\n\n// Red (clamp to 0-1)\nvalue = srgb_r;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Green\nvalue = srgb_g;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Blue\nvalue = srgb_b;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\nreturn hex;"
}
}
]
}
},
{
uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/",
schema: {
"name": "SRGB",
"type": "color",
"description": "sRGB color space with normalized 0-1 range. The standard color space for web and displays.",
"schema": {
"type": "object",
"properties": {
"r": {
"type": "number",
"description": "Red channel (0-1)"
},
"g": {
"type": "number",
"description": "Green channel (0-1)"
},
"b": {
"type": "number",
"description": "Blue channel (0-1)"
}
},
"required": [
"r",
"g",
"b"
],
"order": [
"r",
"g",
"b"
],
"additionalProperties": false
},
"initializers": [
{
"title": "sRGB Color Initializer",
"keyword": "srgb",
"description": "Creates an sRGB color from normalized 0-1 values",
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// sRGB Color Initializer\n// Creates an sRGB color from normalized 0-1 values\n// Input: List of [r, g, b] or [r, g, b, alpha] values in 0-1 range\n\nvariable color_values: List = {input};\nvariable output: Color.SRGB;\n\noutput.r = color_values.get(0);\noutput.g = color_values.get(1);\noutput.b = color_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (color_values.length() > 3) [\n output.alpha = color_values.get(3);\n];\n\nreturn output;"
}
}
],
"conversions": [
{
"source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/rgb-color/0/",
"target": "$self",
"description": "Converts RGB (0-255) to sRGB (0-1) by normalizing",
"lossless": true,
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// RGB to sRGB Conversion\n// Converts RGB (0-255) to sRGB (0-1) by normalizing\n// Input: Color.Rgb with r, g, b in 0-255 range\n// Output: Color.SRGB with r, g, b in 0-1 range\n// Lossless: Yes (simple division)\n\nvariable r_normalized: Number = {input}.r / 255;\nvariable g_normalized: Number = {input}.g / 255;\nvariable b_normalized: Number = {input}.b / 255;\n\nvariable output: Color.SRGB;\noutput.r = r_normalized;\noutput.g = g_normalized;\noutput.b = b_normalized;\n\nreturn output;"
}
},
{
"source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hsl-color/0/",
"target": "$self",
"description": "Converts HSL to sRGB",
"lossless": true,
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// HSL to sRGB Conversion\n// Reference: https://github.com/color-js/color.js/blob/main/src/spaces/hsl.js\n//\n// Algorithm:\n// 1. If saturation is 0, it's achromatic: R=G=B=L\n// 2. Otherwise use the HSL to RGB formula:\n// - Calculate intermediate values based on L\n// - Use hue to determine RGB components\n//\n// Input: Color.HSL with h (0-360), s (0-1), l (0-1)\n// Output: Color.SRGB with r, g, b in 0-1 range\n\n// Get input HSL values\nvariable h: Number = {input}.h;\nvariable s: Number = {input}.s;\nvariable l: Number = {input}.l;\n\n// Normalize hue to 0-1 range\nvariable hue: Number = h / 360;\n\n// Output values\nvariable r: Number = l;\nvariable g: Number = l;\nvariable b: Number = l;\n\n// Only calculate if there's saturation (not achromatic)\nif (s > 0) [\n // Calculate intermediate value\n variable q: Number = 0;\n if (l < 0.5) [\n q = l * (1 + s);\n ] else [\n q = l + s - l * s;\n ];\n \n variable p: Number = 2 * l - q;\n \n // Helper function logic inlined for R (hue + 1/3)\n variable tr: Number = hue + 0.333333333333333;\n if (tr < 0) [ tr = tr + 1; ];\n if (tr > 1) [ tr = tr - 1; ];\n \n if (tr < 0.166666666666667) [\n r = p + (q - p) * 6 * tr;\n ] else [\n if (tr < 0.5) [\n r = q;\n ] else [\n if (tr < 0.666666666666667) [\n r = p + (q - p) * (0.666666666666667 - tr) * 6;\n ] else [\n r = p;\n ];\n ];\n ];\n \n // Helper function logic inlined for G (hue)\n variable tg: Number = hue;\n if (tg < 0) [ tg = tg + 1; ];\n if (tg > 1) [ tg = tg - 1; ];\n \n if (tg < 0.166666666666667) [\n g = p + (q - p) * 6 * tg;\n ] else [\n if (tg < 0.5) [\n g = q;\n ] else [\n if (tg < 0.666666666666667) [\n g = p + (q - p) * (0.666666666666667 - tg) * 6;\n ] else [\n g = p;\n ];\n ];\n ];\n \n // Helper function logic inlined for B (hue - 1/3)\n variable tb: Number = hue - 0.333333333333333;\n if (tb < 0) [ tb = tb + 1; ];\n if (tb > 1) [ tb = tb - 1; ];\n \n if (tb < 0.166666666666667) [\n b = p + (q - p) * 6 * tb;\n ] else [\n if (tb < 0.5) [\n b = q;\n ] else [\n if (tb < 0.666666666666667) [\n b = p + (q - p) * (0.666666666666667 - tb) * 6;\n ] else [\n b = p;\n ];\n ];\n ];\n];\n\n// Create output\nvariable output: Color.SRGB;\noutput.r = r;\noutput.g = g;\noutput.b = b;\n\nreturn output;"
}
},
{
"source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-linear-color/0/",
"target": "$self",
"description": "Converts Linear sRGB to sRGB by applying gamma correction",
"lossless": true,
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// Linear sRGB to sRGB Conversion\n// Applies gamma correction (transfer function)\n// Reference: IEC 61966-2-1:1999 (sRGB specification)\n//\n// Algorithm:\n// if linear ≤ 0.0031308: srgb = linear * 12.92\n// else: srgb = 1.055 * linear^(1/2.4) - 0.055\n//\n// Input: Color.LinearSRGB with r, g, b in linear 0-1 range\n// Output: Color.SRGB with r, g, b in gamma-corrected 0-1 range\n\n// Gamma correction constants (IEC 61966-2-1)\nvariable threshold: Number = 0.0031308;\nvariable linear_scale: Number = 12.92;\nvariable gamma_offset: Number = 0.055;\nvariable gamma_scale: Number = 1.055;\nvariable gamma_exponent: Number = 0.416666666666667;\n\n// Get input linear values\nvariable linear_r: Number = {input}.r;\nvariable linear_g: Number = {input}.g;\nvariable linear_b: Number = {input}.b;\n\n// Convert red channel\nvariable srgb_r: Number = 0;\nif (linear_r <= threshold) [\n srgb_r = linear_r * linear_scale;\n] else [\n srgb_r = gamma_scale * pow(linear_r, gamma_exponent) - gamma_offset;\n];\n\n// Convert green channel\nvariable srgb_g: Number = 0;\nif (linear_g <= threshold) [\n srgb_g = linear_g * linear_scale;\n] else [\n srgb_g = gamma_scale * pow(linear_g, gamma_exponent) - gamma_offset;\n];\n\n// Convert blue channel\nvariable srgb_b: Number = 0;\nif (linear_b <= threshold) [\n srgb_b = linear_b * linear_scale;\n] else [\n srgb_b = gamma_scale * pow(linear_b, gamma_exponent) - gamma_offset;\n];\n\n// Create output\nvariable output: Color.SRGB;\noutput.r = srgb_r;\noutput.g = srgb_g;\noutput.b = srgb_b;\n\nreturn output;"
}
}
]
"conversions": []
}
},
{
@@ -297,6 +177,85 @@ export const SCHEMAS = [
]
}
},
{
uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/",
schema: {
"name": "SRGB",
"type": "color",
"description": "sRGB color space with normalized 0-1 range. The standard color space for web and displays.",
"schema": {
"type": "object",
"properties": {
"r": {
"type": "number",
"description": "Red channel (0-1)"
},
"g": {
"type": "number",
"description": "Green channel (0-1)"
},
"b": {
"type": "number",
"description": "Blue channel (0-1)"
}
},
"required": [
"r",
"g",
"b"
],
"order": [
"r",
"g",
"b"
],
"additionalProperties": false
},
"initializers": [
{
"title": "sRGB Color Initializer",
"keyword": "srgb",
"description": "Creates an sRGB color from normalized 0-1 values",
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// sRGB Color Initializer\n// Creates an sRGB color from normalized 0-1 values\n// Input: List of [r, g, b] or [r, g, b, alpha] values in 0-1 range\n\nvariable color_values: List = {input};\nvariable output: Color.SRGB;\n\noutput.r = color_values.get(0);\noutput.g = color_values.get(1);\noutput.b = color_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (color_values.length() > 3) [\n output.alpha = color_values.get(3);\n];\n\nreturn output;"
}
}
],
"conversions": [
{
"source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/rgb-color/0/",
"target": "$self",
"description": "Converts RGB (0-255) to sRGB (0-1) by normalizing",
"lossless": true,
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// RGB to sRGB Conversion\n// Converts RGB (0-255) to sRGB (0-1) by normalizing\n// Input: Color.Rgb with r, g, b in 0-255 range\n// Output: Color.SRGB with r, g, b in 0-1 range\n// Lossless: Yes (simple division)\n\nvariable r_normalized: Number = {input}.r / 255;\nvariable g_normalized: Number = {input}.g / 255;\nvariable b_normalized: Number = {input}.b / 255;\n\nvariable output: Color.SRGB;\noutput.r = r_normalized;\noutput.g = g_normalized;\noutput.b = b_normalized;\n\nreturn output;"
}
},
{
"source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hsl-color/0/",
"target": "$self",
"description": "Converts HSL to sRGB",
"lossless": true,
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// HSL to sRGB Conversion\n// Reference: https://github.com/color-js/color.js/blob/main/src/spaces/hsl.js\n//\n// Algorithm:\n// 1. If saturation is 0, it's achromatic: R=G=B=L\n// 2. Otherwise use the HSL to RGB formula:\n// - Calculate intermediate values based on L\n// - Use hue to determine RGB components\n//\n// Input: Color.HSL with h (0-360), s (0-1), l (0-1)\n// Output: Color.SRGB with r, g, b in 0-1 range\n\n// Get input HSL values\nvariable h: Number = {input}.h;\nvariable s: Number = {input}.s;\nvariable l: Number = {input}.l;\n\n// Normalize hue to 0-1 range\nvariable hue: Number = h / 360;\n\n// Output values\nvariable r: Number = l;\nvariable g: Number = l;\nvariable b: Number = l;\n\n// Only calculate if there's saturation (not achromatic)\nif (s > 0) [\n // Calculate intermediate value\n variable q: Number = 0;\n if (l < 0.5) [\n q = l * (1 + s);\n ] else [\n q = l + s - l * s;\n ];\n \n variable p: Number = 2 * l - q;\n \n // Helper function logic inlined for R (hue + 1/3)\n variable tr: Number = hue + 0.333333333333333;\n if (tr < 0) [ tr = tr + 1; ];\n if (tr > 1) [ tr = tr - 1; ];\n \n if (tr < 0.166666666666667) [\n r = p + (q - p) * 6 * tr;\n ] else [\n if (tr < 0.5) [\n r = q;\n ] else [\n if (tr < 0.666666666666667) [\n r = p + (q - p) * (0.666666666666667 - tr) * 6;\n ] else [\n r = p;\n ];\n ];\n ];\n \n // Helper function logic inlined for G (hue)\n variable tg: Number = hue;\n if (tg < 0) [ tg = tg + 1; ];\n if (tg > 1) [ tg = tg - 1; ];\n \n if (tg < 0.166666666666667) [\n g = p + (q - p) * 6 * tg;\n ] else [\n if (tg < 0.5) [\n g = q;\n ] else [\n if (tg < 0.666666666666667) [\n g = p + (q - p) * (0.666666666666667 - tg) * 6;\n ] else [\n g = p;\n ];\n ];\n ];\n \n // Helper function logic inlined for B (hue - 1/3)\n variable tb: Number = hue - 0.333333333333333;\n if (tb < 0) [ tb = tb + 1; ];\n if (tb > 1) [ tb = tb - 1; ];\n \n if (tb < 0.166666666666667) [\n b = p + (q - p) * 6 * tb;\n ] else [\n if (tb < 0.5) [\n b = q;\n ] else [\n if (tb < 0.666666666666667) [\n b = p + (q - p) * (0.666666666666667 - tb) * 6;\n ] else [\n b = p;\n ];\n ];\n ];\n];\n\n// Create output\nvariable output: Color.SRGB;\noutput.r = r;\noutput.g = g;\noutput.b = b;\n\nreturn output;"
}
},
{
"source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-linear-color/0/",
"target": "$self",
"description": "Converts Linear sRGB to sRGB by applying gamma correction",
"lossless": true,
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// Linear sRGB to sRGB Conversion\n// Applies gamma correction (transfer function)\n// Reference: IEC 61966-2-1:1999 (sRGB specification)\n//\n// Algorithm:\n// if linear ≤ 0.0031308: srgb = linear * 12.92\n// else: srgb = 1.055 * linear^(1/2.4) - 0.055\n//\n// Input: Color.LinearSRGB with r, g, b in linear 0-1 range\n// Output: Color.SRGB with r, g, b in gamma-corrected 0-1 range\n\n// Gamma correction constants (IEC 61966-2-1)\nvariable threshold: Number = 0.0031308;\nvariable linear_scale: Number = 12.92;\nvariable gamma_offset: Number = 0.055;\nvariable gamma_scale: Number = 1.055;\nvariable gamma_exponent: Number = 0.416666666666667;\n\n// Get input linear values\nvariable linear_r: Number = {input}.r;\nvariable linear_g: Number = {input}.g;\nvariable linear_b: Number = {input}.b;\n\n// Convert red channel\nvariable srgb_r: Number = 0;\nif (linear_r <= threshold) [\n srgb_r = linear_r * linear_scale;\n] else [\n srgb_r = gamma_scale * pow(linear_r, gamma_exponent) - gamma_offset;\n];\n\n// Convert green channel\nvariable srgb_g: Number = 0;\nif (linear_g <= threshold) [\n srgb_g = linear_g * linear_scale;\n] else [\n srgb_g = gamma_scale * pow(linear_g, gamma_exponent) - gamma_offset;\n];\n\n// Convert blue channel\nvariable srgb_b: Number = 0;\nif (linear_b <= threshold) [\n srgb_b = linear_b * linear_scale;\n] else [\n srgb_b = gamma_scale * pow(linear_b, gamma_exponent) - gamma_offset;\n];\n\n// Create output\nvariable output: Color.SRGB;\noutput.r = srgb_r;\noutput.g = srgb_g;\noutput.b = srgb_b;\n\nreturn output;"
}
}
]
}
},
{
uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-linear-color/0/",
schema: {
@@ -770,65 +729,6 @@ export const SCHEMAS = [
]
}
},
{
uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/p3-color/0/",
schema: {
"name": "P3",
"type": "color",
"description": "Display-P3 color space with sRGB transfer function. Wider gamut than sRGB, common on modern Apple displays.",
"schema": {
"type": "object",
"properties": {
"r": {
"type": "number",
"description": "Red channel (0-1, can exceed for out-of-gamut)"
},
"g": {
"type": "number",
"description": "Green channel (0-1, can exceed for out-of-gamut)"
},
"b": {
"type": "number",
"description": "Blue channel (0-1, can exceed for out-of-gamut)"
}
},
"required": [
"r",
"g",
"b"
],
"order": [
"r",
"g",
"b"
],
"additionalProperties": false
},
"initializers": [
{
"title": "Display-P3 Color Initializer",
"keyword": "p3",
"description": "Creates a Display-P3 color from 0-1 values",
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// Display-P3 Color Initializer\n// Creates a Display-P3 color from 0-1 values\n// Input: List of [r, g, b] or [r, g, b, alpha] values\n\nvariable color_values: List = {input};\nvariable output: Color.P3;\n\noutput.r = color_values.get(0);\noutput.g = color_values.get(1);\noutput.b = color_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (color_values.length() > 3) [\n output.alpha = color_values.get(3);\n];\n\nreturn output;"
}
}
],
"conversions": [
{
"source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/p3-linear-color/0/",
"target": "$self",
"description": "Converts Linear P3 to P3 by applying sRGB transfer function",
"lossless": true,
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// Linear P3 to P3 Conversion\n// Applies sRGB transfer function (gamma encoding)\n// P3 uses the same transfer function as sRGB\n// Reference: CSS Color Level 4\n//\n// Algorithm (same as sRGB):\n// if linear ≤ 0.0031308: encoded = 12.92 × linear\n// else: encoded = 1.055 × linear^(1/2.4) - 0.055\n//\n// Input: Color.LinearP3 with linear r, g, b values\n// Output: Color.P3 with gamma-encoded r, g, b values\n\n// Transfer function constants (same as sRGB)\nvariable threshold: Number = 0.0031308;\nvariable linear_scale: Number = 12.92;\nvariable gamma_scale: Number = 1.055;\nvariable gamma_offset: Number = 0.055;\nvariable gamma_exponent: Number = 0.4166666666666667;\n\n// Get linear values\nvariable linear_r: Number = {input}.r;\nvariable linear_g: Number = {input}.g;\nvariable linear_b: Number = {input}.b;\n\n// Convert red channel\nvariable encoded_r: Number = 0;\nif (linear_r <= threshold) [\n encoded_r = linear_scale * linear_r;\n] else [\n encoded_r = gamma_scale * pow(linear_r, gamma_exponent) - gamma_offset;\n];\n\n// Convert green channel\nvariable encoded_g: Number = 0;\nif (linear_g <= threshold) [\n encoded_g = linear_scale * linear_g;\n] else [\n encoded_g = gamma_scale * pow(linear_g, gamma_exponent) - gamma_offset;\n];\n\n// Convert blue channel\nvariable encoded_b: Number = 0;\nif (linear_b <= threshold) [\n encoded_b = linear_scale * linear_b;\n] else [\n encoded_b = gamma_scale * pow(linear_b, gamma_exponent) - gamma_offset;\n];\n\n// Create output\nvariable output: Color.P3;\noutput.r = encoded_r;\noutput.g = encoded_g;\noutput.b = encoded_b;\n\nreturn output;"
}
}
]
}
},
{
uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/css-color/0/",
schema: {
@@ -1286,6 +1186,65 @@ export const SCHEMAS = [
]
}
},
{
uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/p3-color/0/",
schema: {
"name": "P3",
"type": "color",
"description": "Display-P3 color space with sRGB transfer function. Wider gamut than sRGB, common on modern Apple displays.",
"schema": {
"type": "object",
"properties": {
"r": {
"type": "number",
"description": "Red channel (0-1, can exceed for out-of-gamut)"
},
"g": {
"type": "number",
"description": "Green channel (0-1, can exceed for out-of-gamut)"
},
"b": {
"type": "number",
"description": "Blue channel (0-1, can exceed for out-of-gamut)"
}
},
"required": [
"r",
"g",
"b"
],
"order": [
"r",
"g",
"b"
],
"additionalProperties": false
},
"initializers": [
{
"title": "Display-P3 Color Initializer",
"keyword": "p3",
"description": "Creates a Display-P3 color from 0-1 values",
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// Display-P3 Color Initializer\n// Creates a Display-P3 color from 0-1 values\n// Input: List of [r, g, b] or [r, g, b, alpha] values\n\nvariable color_values: List = {input};\nvariable output: Color.P3;\n\noutput.r = color_values.get(0);\noutput.g = color_values.get(1);\noutput.b = color_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (color_values.length() > 3) [\n output.alpha = color_values.get(3);\n];\n\nreturn output;"
}
}
],
"conversions": [
{
"source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/p3-linear-color/0/",
"target": "$self",
"description": "Converts Linear P3 to P3 by applying sRGB transfer function",
"lossless": true,
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// Linear P3 to P3 Conversion\n// Applies sRGB transfer function (gamma encoding)\n// P3 uses the same transfer function as sRGB\n// Reference: CSS Color Level 4\n//\n// Algorithm (same as sRGB):\n// if linear ≤ 0.0031308: encoded = 12.92 × linear\n// else: encoded = 1.055 × linear^(1/2.4) - 0.055\n//\n// Input: Color.LinearP3 with linear r, g, b values\n// Output: Color.P3 with gamma-encoded r, g, b values\n\n// Transfer function constants (same as sRGB)\nvariable threshold: Number = 0.0031308;\nvariable linear_scale: Number = 12.92;\nvariable gamma_scale: Number = 1.055;\nvariable gamma_offset: Number = 0.055;\nvariable gamma_exponent: Number = 0.4166666666666667;\n\n// Get linear values\nvariable linear_r: Number = {input}.r;\nvariable linear_g: Number = {input}.g;\nvariable linear_b: Number = {input}.b;\n\n// Convert red channel\nvariable encoded_r: Number = 0;\nif (linear_r <= threshold) [\n encoded_r = linear_scale * linear_r;\n] else [\n encoded_r = gamma_scale * pow(linear_r, gamma_exponent) - gamma_offset;\n];\n\n// Convert green channel\nvariable encoded_g: Number = 0;\nif (linear_g <= threshold) [\n encoded_g = linear_scale * linear_g;\n] else [\n encoded_g = gamma_scale * pow(linear_g, gamma_exponent) - gamma_offset;\n];\n\n// Convert blue channel\nvariable encoded_b: Number = 0;\nif (linear_b <= threshold) [\n encoded_b = linear_scale * linear_b;\n] else [\n encoded_b = gamma_scale * pow(linear_b, gamma_exponent) - gamma_offset;\n];\n\n// Create output\nvariable output: Color.P3;\noutput.r = encoded_r;\noutput.g = encoded_g;\noutput.b = encoded_b;\n\nreturn output;"
}
}
]
}
},
{
uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/function/lighten/0/",
schema: {
@@ -1465,165 +1424,6 @@ export const SCHEMAS = [
]
}
},
{
uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/constants/css-hex-colors/0/",
schema: {
"name": "CSS Hex Colors",
"type": "constants",
"description": "CSS named colors mapped to their hex values (CSS Color Level 4)",
"inline": true,
"values": {
"aliceblue": "#F0F8FF",
"antiquewhite": "#FAEBD7",
"aqua": "#00FFFF",
"aquamarine": "#7FFFD4",
"azure": "#F0FFFF",
"beige": "#F5F5DC",
"bisque": "#FFE4C4",
"black": "#000000",
"blanchedalmond": "#FFEBCD",
"blue": "#0000FF",
"blueviolet": "#8A2BE2",
"brown": "#A52A2A",
"burlywood": "#DEB887",
"cadetblue": "#5F9EA0",
"chartreuse": "#7FFF00",
"chocolate": "#D2691E",
"coral": "#FF7F50",
"cornflowerblue": "#6495ED",
"cornsilk": "#FFF8DC",
"crimson": "#DC143C",
"cyan": "#00FFFF",
"darkblue": "#00008B",
"darkcyan": "#008B8B",
"darkgoldenrod": "#B8860B",
"darkgray": "#A9A9A9",
"darkgreen": "#006400",
"darkgrey": "#A9A9A9",
"darkkhaki": "#BDB76B",
"darkmagenta": "#8B008B",
"darkolivegreen": "#556B2F",
"darkorange": "#FF8C00",
"darkorchid": "#9932CC",
"darkred": "#8B0000",
"darksalmon": "#E9967A",
"darkseagreen": "#8FBC8F",
"darkslateblue": "#483D8B",
"darkslategray": "#2F4F4F",
"darkslategrey": "#2F4F4F",
"darkturquoise": "#00CED1",
"darkviolet": "#9400D3",
"deeppink": "#FF1493",
"deepskyblue": "#00BFFF",
"dimgray": "#696969",
"dimgrey": "#696969",
"dodgerblue": "#1E90FF",
"firebrick": "#B22222",
"floralwhite": "#FFFAF0",
"forestgreen": "#228B22",
"fuchsia": "#FF00FF",
"gainsboro": "#DCDCDC",
"ghostwhite": "#F8F8FF",
"gold": "#FFD700",
"goldenrod": "#DAA520",
"gray": "#808080",
"green": "#008000",
"greenyellow": "#ADFF2F",
"grey": "#808080",
"honeydew": "#F0FFF0",
"hotpink": "#FF69B4",
"indianred": "#CD5C5C",
"indigo": "#4B0082",
"ivory": "#FFFFF0",
"khaki": "#F0E68C",
"lavender": "#E6E6FA",
"lavenderblush": "#FFF0F5",
"lawngreen": "#7CFC00",
"lemonchiffon": "#FFFACD",
"lightblue": "#ADD8E6",
"lightcoral": "#F08080",
"lightcyan": "#E0FFFF",
"lightgoldenrodyellow": "#FAFAD2",
"lightgray": "#D3D3D3",
"lightgreen": "#90EE90",
"lightgrey": "#D3D3D3",
"lightpink": "#FFB6C1",
"lightsalmon": "#FFA07A",
"lightseagreen": "#20B2AA",
"lightskyblue": "#87CEFA",
"lightslategray": "#778899",
"lightslategrey": "#778899",
"lightsteelblue": "#B0C4DE",
"lightyellow": "#FFFFE0",
"lime": "#00FF00",
"limegreen": "#32CD32",
"linen": "#FAF0E6",
"magenta": "#FF00FF",
"maroon": "#800000",
"mediumaquamarine": "#66CDAA",
"mediumblue": "#0000CD",
"mediumorchid": "#BA55D3",
"mediumpurple": "#9370DB",
"mediumseagreen": "#3CB371",
"mediumslateblue": "#7B68EE",
"mediumspringgreen": "#00FA9A",
"mediumturquoise": "#48D1CC",
"mediumvioletred": "#C71585",
"midnightblue": "#191970",
"mintcream": "#F5FFFA",
"mistyrose": "#FFE4E1",
"moccasin": "#FFE4B5",
"navajowhite": "#FFDEAD",
"navy": "#000080",
"oldlace": "#FDF5E6",
"olive": "#808000",
"olivedrab": "#6B8E23",
"orange": "#FFA500",
"orangered": "#FF4500",
"orchid": "#DA70D6",
"palegoldenrod": "#EEE8AA",
"palegreen": "#98FB98",
"paleturquoise": "#AFEEEE",
"palevioletred": "#DB7093",
"papayawhip": "#FFEFD5",
"peachpuff": "#FFDAB9",
"peru": "#CD853F",
"pink": "#FFC0CB",
"plum": "#DDA0DD",
"powderblue": "#B0E0E6",
"purple": "#800080",
"rebeccapurple": "#663399",
"red": "#FF0000",
"rosybrown": "#BC8F8F",
"royalblue": "#4169E1",
"saddlebrown": "#8B4513",
"salmon": "#FA8072",
"sandybrown": "#F4A460",
"seagreen": "#2E8B57",
"seashell": "#FFF5EE",
"sienna": "#A0522D",
"silver": "#C0C0C0",
"skyblue": "#87CEEB",
"slateblue": "#6A5ACD",
"slategray": "#708090",
"slategrey": "#708090",
"snow": "#FFFAFA",
"springgreen": "#00FF7F",
"steelblue": "#4682B4",
"tan": "#D2B48C",
"teal": "#008080",
"thistle": "#D8BFD8",
"tomato": "#FF6347",
"turquoise": "#40E0D0",
"violet": "#EE82EE",
"wheat": "#F5DEB3",
"white": "#FFFFFF",
"whitesmoke": "#F5F5F5",
"yellow": "#FFFF00",
"yellowgreen": "#9ACD32"
}
}
},
];
export function makeConfig() {

View File

@@ -1,147 +0,0 @@
{
"~: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 {
static async init(page) {
await super.init(page);
await WasmWorkspacePage.mockConfigFlags(page, WASM_FLAGS);
await WorkspacePage.mockConfigFlags(page, WASM_FLAGS);
await page.addInitScript(() => {
document.addEventListener("penpot:wasm:loaded", () => {
@@ -27,14 +27,6 @@ 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) {
super(page);
this.canvas = page.getByTestId("canvas-wasm-shapes");

View File

@@ -165,7 +165,6 @@ test("Updates canvas background", async ({ page }) => {
});
await canvasBackgroundInput.fill("FABADA");
await workspace.page.keyboard.press("Enter");
await workspace.waitForFirstRenderWithoutUI();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -197,7 +196,7 @@ test("Renders a file with blurs applied to any kind of shape", async ({
test("Renders a file with shadows applied to any kind of shape", async ({
page,
}) => {
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("render-wasm/get-file-shadows.json");
@@ -291,24 +290,6 @@ test("Renders a file with nested clipping frames", async ({ page }) => {
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 ({
page,
}) => {
@@ -324,35 +305,3 @@ test("Renders a clipped frame with a large blur drop shadow", async ({
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: 348 KiB

After

Width:  |  Height:  |  Size: 360 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

After

Width:  |  Height:  |  Size: 216 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 124 KiB

View File

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

View File

@@ -66,22 +66,22 @@
(update-in state [:workspace-local :open-plugins] (fnil disj #{}) id))))
(defn- load-plugin!
[{:keys [plugin-id name description host code icon permissions]}]
[{:keys [plugin-id name version description host code icon permissions]}]
(try
(st/emit! (save-current-plugin plugin-id)
(reset-plugin-flags plugin-id))
(.ɵloadPlugin
^js ug/global
#js {:pluginId plugin-id
:name name
:description description
:host host
:code code
:icon icon
:permissions (apply array permissions)}
(fn []
(st/emit! (remove-current-plugin plugin-id))))
(.ɵloadPlugin ^js ug/global
#js {:pluginId plugin-id
:name name
:description description
:version version
:host host
:code code
:icon icon
:permissions (apply array permissions)}
(fn []
(st/emit! (remove-current-plugin plugin-id))))
(catch :default e
(st/emit! (remove-current-plugin plugin-id))

View File

@@ -32,7 +32,6 @@
[app.main.features :as features]
[app.main.fonts :as fonts]
[app.main.router :as rt]
[app.render-wasm.api :as wasm.api]
[app.util.text-editor :as ted]
[app.util.text.content.styles :as styles]
[app.util.timers :as ts]
@@ -509,12 +508,12 @@
ptk/EffectEvent
(effect [_ state _]
(when (features/active-feature? state "text-editor/v2")
(when-let [instance (:workspace-editor state)]
(let [styles (some-> (editor.v2/getCurrentStyle instance)
(styles/get-styles-from-style-declaration :removed-mixed true)
((comp update-node-fn migrate-node))
(styles/attrs->styles))]
(editor.v2/applyStylesToSelection instance styles))))))))
(let [instance (:workspace-editor state)
styles (some-> (editor.v2/getCurrentStyle instance)
(styles/get-styles-from-style-declaration :removed-mixed true)
((comp update-node-fn migrate-node))
(styles/attrs->styles))]
(editor.v2/applyStylesToSelection instance styles)))))))
;; --- RESIZE UTILS
@@ -778,30 +777,17 @@
(rx/of (v2-update-text-editor-styles id attrs)))
(when (features/active-feature? state "render-wasm/v1")
(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))))))))
(rx/of (dwwt/resize-wasm-text-debounce id)))))))
ptk/EffectEvent
(effect [_ state _]
(when (features/active-feature? state "text-editor/v2")
(when-let [instance (:workspace-editor state)]
(let [attrs-to-override (some-> (editor.v2/getCurrentStyle instance)
(styles/get-styles-from-style-declaration))
overriden-attrs (merge attrs-to-override attrs)
styles (styles/attrs->styles overriden-attrs)]
(editor.v2/applyStylesToSelection instance styles)))))))
(let [instance (:workspace-editor state)
attrs-to-override (some-> (editor.v2/getCurrentStyle instance)
(styles/get-styles-from-style-declaration))
overriden-attrs (merge attrs-to-override attrs)
styles (styles/attrs->styles overriden-attrs)]
(editor.v2/applyStylesToSelection instance styles))))))
(defn update-all-attrs
[ids attrs]

View File

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

View File

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

View File

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

View File

@@ -29,23 +29,6 @@
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"] {
line-height: inherit;
user-select: text;

View File

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

View File

@@ -19,14 +19,10 @@
[app.main.data.workspace.media :as dwm]
[app.main.data.workspace.path :as dwdp]
[app.main.data.workspace.specialized-panel :as-alias dwsp]
[app.main.data.workspace.texts :as dwt]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.workspace.sidebar.assets.components :as wsac]
[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.dnd :as dnd]
[app.util.dom.normalize-wheel :as nw]
@@ -95,17 +91,7 @@
::dwsp/interrupt)
(when (and (not= edition id) (or text-editing? grid-editing?))
(st/emit! (dw/clear-edition-mode))
;; Sync and stop WASM text editor when exiting edit mode
(when (and text-editing?
(features/active-feature? @st/state "render-wasm/v1")
wasm.wasm/context-initialized?)
(when-let [{:keys [shape-id content]} (wasm.api/text-editor-sync-content)]
(st/emit! (dwt/v2-update-text-shape-content
shape-id content
:update-name? true
:finalize? true)))
(wasm.api/text-editor-stop)))
(st/emit! (dw/clear-edition-mode)))
(when (and (not text-editing?)
(not blocked)
@@ -198,20 +184,6 @@
(not drawing-tool))
(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?
(not @space?)
(not edition)
@@ -251,15 +223,8 @@
(when (and (not drawing-path?) shape)
(cond
(and editable? (not= id edition) (not read-only?))
(do
(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)))
(st/emit! (dw/select-shape id)
(dw/start-editing-selected))
(some? selected-shape)
(do

View File

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

View File

@@ -66,14 +66,6 @@
(gpt/divide zoom)
(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?
[target]
(dom/is-child? @viewport-ref target))

View File

@@ -54,7 +54,6 @@
[app.main.ui.workspace.viewport.viewport-ref :refer [create-viewport-ref]]
[app.main.ui.workspace.viewport.widgets :as widgets]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.text-editor-input :refer [text-editor-input]]
[app.util.debug :as dbg]
[app.util.text-editor :as ted]
[beicon.v2.core :as rx]
@@ -408,14 +407,7 @@
(when picking-color?
[:> pixel-overlay/pixel-overlay-wasm* {:viewport-ref viewport-ref
:canvas-ref canvas-ref}])
;; WASM text editor contenteditable (must be outside SVG to work)
(when (and show-text-editor?
(features/active-feature? @st/state "text-editor-wasm/v1"))
[:& text-editor-input {:shape editing-shape
:zoom zoom
:vbox vbox}])]
:canvas-ref canvas-ref}])]
[:canvas {:id "render"
:data-testid "canvas-wasm-shapes"
@@ -460,10 +452,7 @@
:height (max 0 (- (:height vbox) rule-area-size))}]]]
[:g {:style {:pointer-events (if disable-events? "none" "auto")}}
;; Text editor handling:
;; - When text-editor-wasm/v1 is active, contenteditable is rendered in viewport-overlays (HTML DOM)
(when (and show-text-editor?
(not (features/active-feature? @st/state "text-editor-wasm/v1")))
(when show-text-editor?
(if (features/active-feature? @st/state "text-editor/v2")
[:& editor-v2/text-editor {:shape editing-shape
:canvas-ref canvas-ref

View File

@@ -78,6 +78,7 @@
(d/without-nils
{:plugin-id plugin-id
:url (str plugin-url)
:version vers
:name name
:description desc
:host origin

View File

@@ -39,7 +39,6 @@
[app.render-wasm.serializers :as sr]
[app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.svg-filters :as svg-filters]
[app.render-wasm.text-editor :as text-editor]
[app.render-wasm.wasm :as wasm]
[app.util.debug :as dbg]
[app.util.dom :as dom]
@@ -75,18 +74,6 @@
;; Threshold below which we use synchronous processing (no chunking overhead)
(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
(if use-dpr? (if (exists? js/window) js/window.devicePixelRatio 1.0) 1.0))
@@ -122,36 +109,11 @@
(mf/element object-svg #js {:shape shape})
(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.
(defn- render
[timestamp]
(when (and wasm/context-initialized? (not @wasm/context-lost?))
(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)
(ug/dispatch! (ug/event "penpot:wasm:render"))))
@@ -225,6 +187,25 @@
(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
[id]
(when wasm/context-initialized?
@@ -235,47 +216,6 @@
(aget buffer 2)
(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
[id]
(let [buffer (uuid/get-u32 id)]
@@ -919,6 +859,22 @@
(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
[grow-type]
(h/call wasm/internal-module "_set_shape_grow_type" (sr/translate-grow-type grow-type)))
@@ -1116,7 +1072,7 @@
(defn- set-objects-async
"Asynchronously process shapes in chunks, yielding to the browser between chunks.
Returns a promise that resolves when all shapes are processed.
Renders a preview only periodically during loading to show progress,
then does a full tile-based render at the end."
[shapes render-callback]
@@ -1601,41 +1557,33 @@
(persistent! result)))
result
(into []
(keep
(fn [{:keys [paragraph span start-pos end-pos direction x y width height]}]
(let [content (:content shape)
element (-> content :children
(get 0) :children ;; paragraph-set
(get paragraph) :children ;; paragraph
(get span))
element-text (:text element)]
(->> result
(mapv
(fn [{:keys [paragraph span start-pos end-pos direction x y width height]}]
(let [content (:content shape)
element (-> content :children
(get 0) :children ;; paragraph-set
(get paragraph) :children ;; paragraph
(get span))
text (subs (:text element) start-pos end-pos)]
;; Add comprehensive nil-safety checks
(when (and element
element-text
(>= start-pos 0)
(<= end-pos (count element-text))
(<= start-pos end-pos))
(let [text (subs element-text start-pos end-pos)]
(d/patch-object
txt/default-text-attrs
(d/without-nils
{:x x
:y (+ y height)
:width width
:height height
:direction (dr/translate-direction direction)
:font-family (get element :font-family)
:font-size (get element :font-size)
:font-weight (get element :font-weight)
:text-transform (get element :text-transform)
:text-decoration (get element :text-decoration)
:letter-spacing (get element :letter-spacing)
:font-style (get element :font-style)
:fills (get element :fills)
:text text})))))))
result)]
(d/patch-object
txt/default-text-attrs
(d/without-nils
{:x x
:y (+ y height)
:width width
:height height
:direction (dr/translate-direction direction)
:font-family (get element :font-family)
:font-size (get element :font-size)
:font-weight (get element :font-weight)
:text-transform (get element :text-transform)
:text-decoration (get element :text-decoration)
:letter-spacing (get element :letter-spacing)
:font-style (get element :font-style)
:fills (d/nilv (get element :fills) [{:fill-color "#000000"}])
:text text}))))))]
(mem/free)
result)))
@@ -1669,4 +1617,7 @@
(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
:weight weight
:emoji? emoji?
:fallback? fallback?
:fallbck? fallback?
:asset-id asset-id}))
(defn store-font

View File

@@ -240,12 +240,3 @@ export const RawGrowType = {
"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,29 +61,6 @@
[]
(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
"Returns a copy of a portion of a typed array into a new typed array
object selected from start to end."

View File

@@ -1,300 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.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

@@ -1,240 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.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

@@ -10,7 +10,6 @@
(:refer-clojure :exclude [set! new get merge clone contains? array? into-array reify class])
#?(:cljs (:require-macros [app.util.object]))
(:require
[app.common.data :as d]
[app.common.json :as json]
[app.common.schema :as sm]
[clojure.core :as c]
@@ -157,7 +156,6 @@
this-sym (with-meta (gensym (str rsym "-this-")) {:tag 'js})
target-sym (with-meta (gensym (str rsym "-target-")) {:tag 'js})
cause-sym (gensym "cause-")
make-sym
(fn [pname prefix]
@@ -178,7 +176,6 @@
wrap (c/get params :wrap)
schema-1 (c/get params :schema-1)
this? (c/get params :this false)
on-error (c/get params :on-error)
decode-expr
(c/get params :decode/fn)
@@ -217,16 +214,7 @@
(with-meta {:tag 'function}))
val-sym
(gensym (str "val-" (str/slug pname) "-"))
wrap-error-handling
(if on-error
(fn [expr]
`(try
~expr
(catch :default ~cause-sym
(~on-error ~cause-sym))))
identity)]
(gensym (str "val-" (str/slug pname) "-"))]
(concat
(when wrap
@@ -238,13 +226,8 @@
`(fn []
(let [~this-sym (~'js* "this")
~fn-sym ~get-expr]
~(wrap-error-handling
`(.call ~fn-sym ~this-sym ~this-sym))))
`(fn []
(let [~this-sym (~'js* "this")
~fn-sym ~get-expr]
~(wrap-error-handling
`(.call ~fn-sym ~this-sym)))))])
(.call ~fn-sym ~this-sym ~this-sym)))
get-expr)])
(when set-expr
[schema-sym schema-n
@@ -258,35 +241,28 @@
(make-sym pname "set-fn")
`(fn [~val-sym]
~(wrap-error-handling
`(let [~this-sym (~'js* "this")
~fn-sym ~set-expr
(let [~this-sym (~'js* "this")
~fn-sym ~set-expr
;; We only emit schema and coercer bindings if
;; schema-n is provided
~@(if (some? schema-n)
[schema-sym
`(if (fn? ~schema-sym)
(~schema-sym ~val-sym)
~schema-sym)
;; We only emit schema and coercer bindings if
;; schema-n is provided
~@(if (some? schema-n)
[schema-sym `(if (fn? ~schema-sym)
(~schema-sym ~val-sym)
~schema-sym)
coercer-sym
`(if (nil? ~coercer-sym)
(sm/coercer ~schema-sym)
~coercer-sym)
coercer-sym `(if (nil? ~coercer-sym)
(sm/coercer ~schema-sym)
~coercer-sym)
val-sym (if (not= decode-expr 'app.common.json/->clj)
`(~decode-sym ~val-sym)
`(~decode-sym ~val-sym ~decode-options))
val-sym `(~coercer-sym ~val-sym)]
[])]
val-sym
(if (not= decode-expr 'app.common.json/->clj)
`(~decode-sym ~val-sym)
`(~decode-sym ~val-sym ~decode-options))
val-sym
`(~coercer-sym ~val-sym)]
[])]
~(if this?
`(.call ~fn-sym ~this-sym ~this-sym ~val-sym)
`(.call ~fn-sym ~this-sym ~val-sym)))))])
~(if this?
`(.call ~fn-sym ~this-sym ~this-sym ~val-sym)
`(.call ~fn-sym ~this-sym ~val-sym))))])
(when fn-expr
[schema-sym (or schema-n schema-1)
@@ -299,12 +275,7 @@
(make-sym pname "get-fn")
`(fn []
(let [~this-sym (~'js* "this")
~fn-sym ~(if (and (list? fn-expr)
(= 'fn (first fn-expr)))
(let [[sa sb & sother] fn-expr]
`(~sa ~sb ~(wrap-error-handling `(do ~@sother))))
fn-expr)
~fn-sym ~fn-expr
~fn-sym ~(if this?
`(.bind ~fn-sym ~this-sym ~this-sym)
`(.bind ~fn-sym ~this-sym))
@@ -313,31 +284,25 @@
;; schema-n or schema-1 is provided
~@(if (or schema-n schema-1)
[fn-sym `(fn* [~@(if schema-1 [val-sym] [])]
~(wrap-error-handling
`(let [~@(if schema-n
[val-sym `(into-array (cljs.core/js-arguments))]
[])
~val-sym
~(if (not= decode-expr 'app.common.json/->clj)
`(~decode-sym ~val-sym)
`(~decode-sym ~val-sym ~decode-options))
(let [~@(if schema-n
[val-sym `(into-array (cljs.core/js-arguments))]
[])
~val-sym ~(if (not= decode-expr 'app.common.json/->clj)
`(~decode-sym ~val-sym)
`(~decode-sym ~val-sym ~decode-options))
~schema-sym
(if (fn? ~schema-sym)
(~schema-sym ~val-sym)
~schema-sym)
~schema-sym (if (fn? ~schema-sym)
(~schema-sym ~val-sym)
~schema-sym)
~coercer-sym
(if (nil? ~coercer-sym)
(sm/coercer ~schema-sym)
~coercer-sym)
~coercer-sym (if (nil? ~coercer-sym)
(sm/coercer ~schema-sym)
~coercer-sym)
~val-sym
(~coercer-sym ~val-sym)]
~(if schema-1
`(~fn-sym ~val-sym)
`(apply ~fn-sym ~val-sym)))))]
~val-sym (~coercer-sym ~val-sym)]
~(if schema-1
`(~fn-sym ~val-sym)
`(apply ~fn-sym ~val-sym))))]
[])]
~(if wrap
`(~wrap-sym ~fn-sym)
@@ -408,19 +373,14 @@
(= :property curr)
(let [definition (first params)]
(prn definition (meta definition))
(if (some? definition)
(let [definition (if (map? definition)
(c/merge {:wrap (:wrap tmeta)
:on-error (:on-error tmeta)}
definition)
(c/merge {:wrap (:wrap tmeta)} definition)
(-> {:enumerable false}
(c/merge (meta definition))
(assoc :wrap (:wrap tmeta))
(assoc :on-error (:on-error tmeta))
(assoc :fn definition)
(dissoc :get :set :line :column)
(d/without-nils)))
(dissoc :get :set)))
definition (assoc definition :name (name ckey))]
(recur (rest params)
@@ -465,13 +425,6 @@
(let [o (get o type-symbol)]
(= o t))))
#?(:cljs
(def Proxy
(app.util.object/class
:name "Proxy"
:extends js/Object
:constructor (constantly nil))))
(defmacro reify
"A domain specific variation of reify that creates anonymous objects
on demand with the ability to assign protocol implementations and
@@ -489,7 +442,7 @@
obj-sym
(gensym "obj-")]
`(let [~obj-sym (new Proxy)
`(let [~obj-sym (cljs.core/js-obj)
~f-sym (fn [] ~type-name)]
(add-properties! ~obj-sym
{:name ~'js/Symbol.toStringTag

View File

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

View File

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

View File

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

View File

@@ -90,6 +90,32 @@ This bootstrap command will:
* build all components (`pnpm -r run build`)
* 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
> [!NOTE]

View File

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

View File

@@ -25,6 +25,7 @@ export class PenpotUtils {
id: shape.id,
name: shape.name,
type: shape.type,
children: children,
};
// add layout information if present
@@ -47,23 +48,6 @@ 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;
}
@@ -71,9 +55,9 @@ export class PenpotUtils {
* 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 root - The root shape to start the search from (if null, searches all pages)
* @param root - The root shape to start the search from (defaults to penpot.root)
*/
public static findShapes(predicate: (shape: Shape) => boolean, root: Shape | null = null): Shape[] {
public static findShapes(predicate: (shape: Shape) => boolean, root: Shape | null = penpot.root): Shape[] {
let result = new Array<Shape>();
let find = function (shape: Shape | null) {
@@ -90,16 +74,7 @@ export class PenpotUtils {
}
};
if (root === null) {
const pages = penpot.currentFile?.pages;
if (pages) {
for (let page of pages) {
find(page.root);
}
}
} else {
find(root);
}
find(root);
return result;
}
@@ -447,94 +422,4 @@ export class PenpotUtils {
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

@@ -1,333 +0,0 @@
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

@@ -0,0 +1,267 @@
# 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,7 +1,18 @@
import { existsSync, readFileSync } from "fs";
import { join } from "path";
import { readFileSync, existsSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
import yaml from "js-yaml";
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.
*
@@ -12,7 +23,7 @@ import { createLogger } from "./logger.js";
export class ConfigurationLoader {
private readonly logger = createLogger("ConfigurationLoader");
private readonly baseDir: string;
private initialInstructions: string;
private promptsConfig: PromptsConfig | null = null;
/**
* Creates a new configuration loader instance.
@@ -21,14 +32,34 @@ export class ConfigurationLoader {
*/
constructor(baseDir: string) {
this.baseDir = baseDir;
this.initialInstructions = this.loadFileContent(join(this.baseDir, "data", "initial_instructions.md"));
}
private loadFileContent(filePath: string): string {
if (!existsSync(filePath)) {
throw new Error(`Configuration file not found at ${filePath}`);
/**
* Loads the prompts configuration from the YAML file.
*
* 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;
}
/**
@@ -37,6 +68,18 @@ export class ConfigurationLoader {
* @returns The initial instructions string, or undefined if not configured
*/
public getInitialInstructions(): string {
return this.initialInstructions;
const config = this.getPromptsConfig();
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
pnpx concurrently --kill-others-on-fail -s last -k \
"caddy file-server --root ../../plugins/dist/doc/ --listen :9090" \
"bash ../types-generator/build $URL";
"../types-generator/build $URL";
else
bash ../types-generator/build $URL;
../types-generator/build $URL;
fi
popd

View File

@@ -1,8 +1,7 @@
# Types Generator
This subproject contains helper scripts used in the development of the
Penpot MCP server, specifically for the generation of a YAML file containing
Penpot plugin API types and their documentation.
Penpot MCP server for generate the types yaml.
## Setup
@@ -13,41 +12,15 @@ Install the environment via (optional, already handled by `build` script)
pixi install
## Running the API Documentation Preparation Script
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
### Buld API types
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.
Successful execution will generate the output file `../packages/server/data/api_types.yml`.
### Generating the YAML File for a Given URL
Running the script:
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
./build <optional-url>
This will generate `../packages/server/data/api_types.yml`.

View File

@@ -80,25 +80,6 @@ class PenpotAPIContentMarkdownConverter(MarkdownConverter):
# return as code block
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
return super().process_tag(node, parent_tags=parent_tags)
@@ -154,7 +135,7 @@ class YamlConverter:
class PenpotAPIDocsProcessor:
def __init__(self, url: str):
def __init__(self, url=None):
self.md_converter = PenpotAPIContentMarkdownConverter()
self.base_url = url
self.types: dict[str, TypeInfo] = {}
@@ -176,7 +157,7 @@ class PenpotAPIDocsProcessor:
type_name = href.split("/")[-1].replace(".html", "")
log.info("Processing page: %s", type_name)
type_info = self.process_page(href, type_name)
log.info(f"Adding '{type_name}' with {type_info}")
print(f"Adding '{type_name}' with {type_info}")
self.types[type_name] = type_info
# add type reference information
@@ -220,21 +201,11 @@ class PenpotAPIDocsProcessor:
members_in_group = {}
members[members_type] = members_in_group
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)
if member_anchor is not None:
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}"
member_name = member_anchor.attrs["id"]
member_heading = member_tag.find("h3")
# 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.
member_heading = member_tag.find("h3")
if member_heading:
tags_in_heading = member_heading.find_all(attrs={"class": "tsd-tag"})
if tags_in_heading:
@@ -266,29 +237,25 @@ class PenpotAPIDocsProcessor:
)
LOCAL_API_DOCS_URL = "http://localhost:9090"
PROD_API_DOCS_URL = "https://doc.plugins.penpot.app"
DEFAULT_API_DOCS_URL = LOCAL_API_DOCS_URL
DEFAULT_API_DOCS_URL = "http://localhost:9090"
def main():
target_dir = Path(__file__).parent.parent / "packages" / "server" / "data"
url = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_API_DOCS_URL
log.info("Fetching plugin data from: {}".format(url))
print("Fetching plugin data from: {}".format(url))
PenpotAPIDocsProcessor(url).run(target_dir=str(target_dir))
def debug_type_conversion(rel_url: str, base_url: str):
def debug_type_conversion(rel_url: str):
"""
This function is for debugging purposes only.
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")
"""
type_name = rel_url.split("/")[-1].replace(".html", "")
processor = PenpotAPIDocsProcessor(url=base_url)
type_name = rel_url.split("/")[-1]
processor = PenpotAPIDocsProcessor()
type_info = processor.process_page(rel_url, type_name)
print(f"--- overview ---\n{type_info.overview}\n")
for member_type, members in type_info.members.items():
@@ -298,5 +265,5 @@ def debug_type_conversion(rel_url: str, base_url: str):
if __name__ == '__main__':
# debug_type_conversion("interfaces/Path.html", LOCAL_API_DOCS_URL)
# debug_type_conversion("interfaces/LayoutChildProperties")
logging.run_main(main)

View File

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

View File

@@ -12,7 +12,10 @@
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/apps/contrast-plugin",
"outputPath": {
"base": "dist/apps/contrast-plugin",
"browser": "",
},
"index": "apps/contrast-plugin/src/index.html",
"browser": "apps/contrast-plugin/src/main.ts",
"polyfills": ["zone.js"],
@@ -20,6 +23,7 @@
"assets": [
"apps/contrast-plugin/src/_headers",
"apps/contrast-plugin/src/favicon.ico",
"apps/contrast-plugin/src/manifest.json",
"apps/contrast-plugin/src/assets"
],
"styles": [
@@ -218,7 +222,10 @@
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/apps/table-plugin",
"outputPath": {
"base": "dist/apps/table-plugin",
"browser": ""
},
"index": "apps/table-plugin/src/index.html",
"browser": "apps/table-plugin/src/main.ts",
"polyfills": ["zone.js"],
@@ -226,6 +233,7 @@
"assets": [
"apps/table-plugin/src/_headers",
"apps/table-plugin/src/favicon.ico",
"apps/table-plugin/src/manifest.json",
"apps/table-plugin/src/assets"
],
"styles": [
@@ -356,7 +364,10 @@
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/apps/colors-to-tokens-plugin",
"outputPath": {
"base": "dist/apps/colors-to-tokens-plugin",
"browser": ""
},
"index": "apps/colors-to-tokens-plugin/src/index.html",
"browser": "apps/colors-to-tokens-plugin/src/main.ts",
"polyfills": ["zone.js"],
@@ -364,6 +375,7 @@
"assets": [
"apps/colors-to-tokens-plugin/src/_headers",
"apps/colors-to-tokens-plugin/src/favicon.ico",
"apps/colors-to-tokens-plugin/src/manifest.json",
"apps/colors-to-tokens-plugin/src/assets"
],
"styles": [

View File

@@ -4,12 +4,10 @@
"private": true,
"type": "module",
"scripts": {
"build": "ng build colors-to-tokens-plugin && pnpm run build:plugin",
"build": "ng build colors-to-tokens-plugin",
"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",
"watch": "ng build colors-to-tokens-plugin --configuration development --watch",
"serve": "ng serve colors-to-tokens-plugin",
"init": "concurrently --kill-others --names plugin,serve \"pnpm run build:plugin:watch\" \"pnpm run serve\"",
"lint": "eslint .",
"test": "vitest"
}

View File

@@ -1,6 +1,8 @@
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideRouter, withHashLocation } from '@angular/router';
export const appConfig: ApplicationConfig = {
providers: [provideRouter([])],
};
providers: [
provideRouter([], withHashLocation())
],
};

View File

@@ -3,7 +3,6 @@
<head>
<meta charset="utf-8" />
<title>colors-to-tokens-plugin</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>

View File

@@ -1,7 +1,8 @@
{
"name": "Colors to Tokens",
"description": "Generate a design tokens file from a list of colors",
"code": "/assets/plugin.js",
"icon": "/assets/icon.png",
"version": 2,
"code": "assets/plugin.js",
"icon": "assets/icon.png",
"permissions": ["content:read", "library:read", "allow:downloads"]
}

View File

@@ -4,12 +4,9 @@
"private": true,
"type": "module",
"scripts": {
"build": "ng build contrast-plugin && pnpm run build:plugin",
"build": "ng build contrast-plugin",
"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",
"init": "concurrently --kill-others --names plugin,serve \"pnpm run build:plugin:watch\" \"pnpm run serve\"",
"lint": "eslint .",
"test": "vitest"
}

View File

@@ -1,6 +1,6 @@
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideRouter, withHashLocation } from '@angular/router';
export const appConfig: ApplicationConfig = {
providers: [provideRouter([])],
providers: [provideRouter([], withHashLocation())],
};

View File

@@ -3,7 +3,6 @@
<head>
<meta charset="utf-8" />
<title>contrast-plugin</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>

View File

@@ -1,7 +1,8 @@
{
"name": "Contrast",
"description": "Measure contrast plugin",
"code": "/assets/plugin.js",
"icon": "/assets/icon.png",
"version": 2,
"code": "assets/plugin.js",
"icon": "assets/icon.png",
"permissions": ["content:read"]
}

View File

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

View File

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

View File

@@ -4,12 +4,9 @@
"private": true,
"type": "module",
"scripts": {
"build": "ng build icons-plugin && pnpm run build:plugin",
"build": "ng build icons-plugin",
"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",
"init": "concurrently --kill-others --names plugin,serve \"pnpm run build:plugin:watch\" \"pnpm run serve\"",
"lint": "eslint .",
"test": "vitest"
}

View File

@@ -4,12 +4,9 @@
"private": true,
"type": "module",
"scripts": {
"build": "ng build lorem-ipsum-plugin && pnpm run build:plugin",
"build": "ng build lorem-ipsum-plugin",
"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",
"init": "concurrently --kill-others --names plugin,serve \"pnpm run build:plugin:watch\" \"pnpm run serve\"",
"lint": "eslint .",
"test": "vitest"
}

View File

@@ -4,12 +4,9 @@
"private": true,
"type": "module",
"scripts": {
"build": "ng build poc-state-plugin && pnpm run build:plugin",
"build": "ng build poc-state-plugin",
"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",
"init": "concurrently --kill-others --names plugin,serve \"pnpm run build:plugin:watch\" \"pnpm run serve\"",
"lint": "eslint .",
"test": "vitest"
}

View File

@@ -4,12 +4,9 @@
"private": true,
"type": "module",
"scripts": {
"build": "ng build poc-tokens-plugin && pnpm run build:plugin",
"build": "ng build poc-tokens-plugin",
"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",
"init": "concurrently --kill-others --names plugin,serve \"pnpm run build:plugin:watch\" \"pnpm run serve\"",
"lint": "eslint .",
"test": "exit 0"
}

View File

@@ -4,12 +4,9 @@
"private": true,
"type": "module",
"scripts": {
"build": "ng build rename-layers-plugin && pnpm run build:plugin",
"build": "ng build rename-layers-plugin",
"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",
"init": "concurrently --kill-others --names plugin,serve \"pnpm run build:plugin:watch\" \"pnpm run serve\"",
"lint": "eslint .",
"test": "vitest"
}

View File

@@ -4,12 +4,10 @@
"private": true,
"type": "module",
"scripts": {
"build": "ng build table-plugin && pnpm run build:plugin",
"build": "ng build table-plugin",
"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",
"watch": "ng build table-plugin --configuration development --watch",
"serve": "ng serve table-plugin",
"init": "concurrently --kill-others --names plugin,serve \"pnpm run build:plugin:watch\" \"pnpm run serve\"",
"lint": "eslint .",
"test": "vitest"
}

View File

@@ -1,7 +1,7 @@
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideRouter, withHashLocation } from '@angular/router';
import { appRoutes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [provideRouter(appRoutes)],
providers: [provideRouter(appRoutes, withHashLocation())],
};

View File

@@ -3,7 +3,6 @@
<head>
<meta charset="utf-8" />
<title>table-plugin</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>

View File

@@ -1,7 +1,8 @@
{
"name": "Table plugin",
"description": "Table plugin to import or create tables",
"code": "/assets/plugin.js",
"icon": "/assets/icon.png",
"version": 2,
"code": "assets/plugin.js",
"icon": "assets/icon.png",
"permissions": ["content:read", "content:write"]
}

View File

@@ -0,0 +1,6 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite';
export default defineConfig({
root: "./"
});

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ export const manifestSchema = z.object({
host: z.string().url(),
code: z.string(),
icon: z.string().optional(),
version: z.number().optional(),
description: z.string().max(200).optional(),
permissions: z.array(
z.enum([

View File

@@ -2,5 +2,5 @@ import { z } from 'zod';
export const openUISchema = z.object({
width: z.number().positive(),
height: z.number().positive(),
height: z.number().positive()
});

View File

@@ -1,8 +1,28 @@
import { Manifest } from './models/manifest.model.js';
import { manifestSchema } from './models/manifest.schema.js';
export function getValidUrl(host: string, path: string): string {
return new URL(path, host).toString();
export function getValidUrl(host: string, path: string): URL {
return new URL(path, host);
}
export function prepareUrl(manifest: Manifest, url: string, params: Object): string {
const result = getValidUrl(manifest.host, url);
for (let [k, v] of Object.entries(params)) {
if (!result.searchParams.has(k)) {
result.searchParams.set(k, v);
}
}
if (manifest.version === undefined || manifest.version === 1) {
return result.toString();
} else if (manifest.version === 2) {
const queryString = result.searchParams.toString();
result.search = "";
result.hash = `/?${queryString}`;
return result.toString();
} else {
throw new Error("invalid manifest version");
}
}
export function loadManifest(url: string): Promise<Manifest> {

View File

@@ -1,6 +1,6 @@
import { describe, it, vi, expect, beforeEach, afterEach } from 'vitest';
import { createPluginManager } from './plugin-manager';
import { loadManifestCode, getValidUrl } from './parse-manifest.js';
import { loadManifestCode, getValidUrl, prepareUrl } from './parse-manifest.js';
import { PluginModalElement } from './modal/plugin-modal.js';
import { openUIApi } from './api/openUI.api.js';
import type { Context, Theme } from '@penpot/plugin-types';
@@ -9,6 +9,7 @@ import type { Manifest } from './models/manifest.model.js';
vi.mock('./parse-manifest.js', () => ({
loadManifestCode: vi.fn(),
getValidUrl: vi.fn(),
prepareUrl: vi.fn(),
}));
vi.mock('./api/openUI.api.js', () => ({
@@ -71,7 +72,8 @@ describe('createPluginManager', () => {
vi.mocked(loadManifestCode).mockResolvedValue(
'console.log("Plugin loaded");',
);
vi.mocked(getValidUrl).mockReturnValue('https://example.com/plugin');
vi.mocked(getValidUrl).mockReturnValue(new URL('https://example.com/plugin'));
vi.mocked(prepareUrl).mockReturnValue('https://example.com/plugin');
});
afterEach(() => {
@@ -110,7 +112,7 @@ describe('createPluginManager', () => {
height: 300,
});
expect(getValidUrl).toHaveBeenCalledWith(manifest.host, '/test-url');
expect(prepareUrl).toHaveBeenCalledWith(manifest, '/test-url', {theme: "light"});
expect(openUIApi).toHaveBeenCalledWith(
'Test Modal',
'https://example.com/plugin',

View File

@@ -1,6 +1,6 @@
import type { Context, Theme } from '@penpot/plugin-types';
import { getValidUrl, loadManifestCode } from './parse-manifest.js';
import { prepareUrl, loadManifestCode } from './parse-manifest.js';
import { Manifest } from './models/manifest.model.js';
import { PluginModalElement } from './modal/plugin-modal.js';
import { openUIApi } from './api/openUI.api.js';
@@ -8,6 +8,7 @@ import { OpenUIOptions } from './models/open-ui-options.model.js';
import { RegisterListener } from './models/plugin.model.js';
import { openUISchema } from './models/open-ui-options.schema.js';
export async function createPluginManager(
context: Context,
manifest: Manifest,
@@ -80,9 +81,8 @@ export async function createPluginManager(
};
const openModal = (name: string, url: string, options?: OpenUIOptions) => {
const theme = context.theme as 'light' | 'dark';
const modalUrl = getValidUrl(manifest.host, url);
const theme = context.theme as Theme;
const modalUrl = prepareUrl(manifest, url, {theme});
if (modal?.getAttribute('iframe-src') === modalUrl) {
return;

View File

@@ -35,7 +35,7 @@ export default defineConfig({
// Configuration for building your library.
// See: https://vitejs.dev/guide/build.html#library-mode
build: {
outDir: '../../dist/plugins-runtime',
outDir: './dist/',
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,

View File

@@ -3,20 +3,20 @@
"version": "0.6.0",
"type": "module",
"license": "MIT",
"packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8",
"packageManager": "pnpm@10.26.2+sha512.0e308ff2005fc7410366f154f625f6631ab2b16b1d2e70238444dd6ae9d630a8482d92a451144debc492416896ed16f7b114a86ec68b8404b2443869e68ffda6",
"scripts": {
"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:styles-example": "pnpm --filter example-styles dev",
"start:plugin:poc-state": "pnpm --filter poc-state-plugin run init",
"start:plugin:contrast": "pnpm --filter contrast-plugin run init",
"start:plugin:icons": "pnpm --filter icons-plugin run init",
"start:plugin:loremipsum": "pnpm --filter lorem-ipsum-plugin run init",
"start:plugin:palette": "pnpm --filter create-palette-plugin run init",
"start:plugin:table": "pnpm --filter table-plugin run init",
"start:plugin:renamelayers": "pnpm --filter rename-layers-plugin run init",
"start:plugin:colors-to-tokens": "pnpm --filter colors-to-tokens-plugin run init",
"start:plugin:poc-tokens": "pnpm --filter poc-tokens-plugin run init",
"start:plugin:poc-state": "pnpm --filter poc-state-plugin serve",
"start:plugin:contrast": "pnpm --filter contrast-plugin serve",
"start:plugin:icons": "pnpm --filter icons-plugin serve",
"start:plugin:loremipsum": "pnpm --filter lorem-ipsum-plugin serve",
"start:plugin:palette": "pnpm --filter create-palette-plugin build:watch & pnpm --filter create-palette-plugin preview",
"start:plugin:table": "pnpm --filter table-plugin serve",
"start:plugin:renamelayers": "pnpm --filter rename-layers-plugin serve",
"start:plugin:colors-to-tokens": "pnpm --filter colors-to-tokens-plugin serve",
"start:plugin:poc-tokens": "pnpm --filter poc-tokens-plugin serve",
"build:runtime": "pnpm --filter @penpot/plugins-runtime build",
"build:plugins": "pnpm --filter './apps/*-plugin' --filter '!poc-state-plugin' build",
"build:styles-example": "pnpm --filter example-styles build",
@@ -50,7 +50,6 @@
"@vitest/coverage-v8": "4.0.17",
"@vitest/ui": "4.0.17",
"concurrently": "^9.2.1",
"dotenv": "^17.2.4",
"esbuild": "^0.27.2",
"eslint": "9.39.2",
"eslint-config-prettier": "10.1.8",

924
plugins/pnpm-lock.yaml generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,92 +0,0 @@
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

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

View File

@@ -10,7 +10,6 @@ mod shadows;
mod strokes;
mod surfaces;
pub mod text;
pub mod text_editor;
mod ui;
use skia_safe::{self as skia, Matrix, RRect, Rect};
@@ -23,7 +22,7 @@ pub use surfaces::{SurfaceId, Surfaces};
use crate::performance;
use crate::shapes::{
all_with_ancestors, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor, Stroke, Type,
all_with_ancestors, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor, Type,
};
use crate::state::{ShapesPoolMutRef, ShapesPoolRef};
use crate::tiles::{self, PendingTiles, TileRect};
@@ -34,9 +33,8 @@ use crate::wapi;
pub use fonts::*;
pub use images::*;
// This is the extra area used for tile rendering (tiles beyond viewport).
// Higher values pre-render more tiles, reducing empty squares during pan but using more memory.
const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3;
// This is the extra are used for tile rendering.
const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 2;
const MAX_BLOCKING_TIME_MS: i32 = 32;
const NODE_BATCH_THRESHOLD: i32 = 3;
@@ -699,17 +697,20 @@ impl RenderState {
canvas.translate(translation);
});
fills::render(self, shape, &shape.fills, antialias, SurfaceId::Current);
for fill in shape.fills().rev() {
fills::render(self, shape, fill, antialias, SurfaceId::Current);
}
// Pass strokes in natural order; stroke merging handles top-most ordering internally.
let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect();
strokes::render(
self,
shape,
&visible_strokes,
Some(SurfaceId::Current),
antialias,
);
for stroke in shape.visible_strokes().rev() {
strokes::render(
self,
shape,
stroke,
Some(SurfaceId::Current),
None,
antialias,
);
}
self.surfaces.apply_mut(SurfaceId::Current as u32, |s| {
s.canvas().restore();
@@ -1013,35 +1014,33 @@ impl RenderState {
{
if let Some(fills_to_render) = self.nested_fills.last() {
let fills_to_render = fills_to_render.clone();
fills::render(self, shape, &fills_to_render, antialias, fills_surface_id);
for fill in fills_to_render.iter() {
fills::render(self, shape, fill, antialias, fills_surface_id);
}
}
} else {
fills::render(self, shape, &shape.fills, antialias, fills_surface_id);
for fill in shape.fills().rev() {
fills::render(self, shape, fill, antialias, fills_surface_id);
}
}
// 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();
for stroke in shape.visible_strokes().rev() {
strokes::render(
self,
shape,
&visible_strokes,
stroke,
Some(strokes_surface_id),
None,
antialias,
);
if !fast_mode {
for stroke in &visible_strokes {
shadows::render_stroke_inner_shadows(
self,
shape,
stroke,
antialias,
innershadows_surface_id,
);
}
shadows::render_stroke_inner_shadows(
self,
shape,
stroke,
antialias,
innershadows_surface_id,
);
}
}
@@ -1241,6 +1240,8 @@ impl RenderState {
if self.render_in_progress {
if tree.len() != 0 {
self.render_shape_tree_partial(base_object, tree, timestamp, true)?;
} else {
println!("Empty tree");
}
self.flush_and_submit();
@@ -1263,6 +1264,8 @@ impl RenderState {
) -> Result<(), String> {
if tree.len() != 0 {
self.render_shape_tree_partial(base_object, tree, timestamp, false)?;
} else {
println!("Empty tree");
}
self.flush_and_submit();
@@ -1399,10 +1402,6 @@ impl RenderState {
element_strokes.to_mut().clear_fills();
element_strokes.to_mut().clear_shadows();
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(
&element_strokes,
clip_bounds,
@@ -1552,11 +1551,6 @@ impl RenderState {
plain_shape_mut.clear_shadows();
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 {
return;
};
@@ -1666,158 +1660,6 @@ 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(
&mut self,
tree: ShapesPoolRef,
@@ -1900,33 +1742,6 @@ impl RenderState {
// If a container was flattened, it doesn't affect children visually, so we skip
// the expensive enter/exit operations and process children directly
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);
}
@@ -1938,25 +1753,180 @@ impl RenderState {
// Skip expensive drop shadow rendering in fast mode (during pan/zoom)
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
if !skip_shadows
&& !shadows_already_rendered
&& !matches!(element.shape_type, Type::Text(_))
{
self.render_element_drop_shadows_and_composite(
element,
tree,
&mut extrect,
clip_bounds.clone(),
scale,
translation,
&node_render_state,
);
if !skip_shadows && !matches!(element.shape_type, Type::Text(_)) {
// Shadow rendering technique: Two-pass approach for proper opacity handling
//
// The shadow rendering uses a two-pass technique to ensure that overlapping
// shadow areas maintain correct opacity without unwanted darkening:
//
// 1. First pass: Render shadow shape in pure black (alpha channel preserved)
// - This creates the shadow silhouette with proper alpha gradients
// - The black color acts as a mask for the final shadow color
//
// 2. Second pass: Apply actual shadow color using SrcIn blend mode
// - SrcIn preserves the alpha channel from the black shadow
// - 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(
element,
clip_bounds.clone(),
@@ -2093,13 +2063,8 @@ impl RenderState {
}
} else {
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) =
self.render_shape_tree_partial_uncached(tree, timestamp, can_stop)?;
self.render_shape_tree_partial_uncached(tree, timestamp, allow_stop)?;
if early_return {
return Ok(());
@@ -2224,20 +2189,17 @@ impl RenderState {
* Given a shape, check the indexes and update it's location in the tile set
* returns the tiles that have changed in the process.
*/
pub fn update_shape_tiles(
&mut self,
shape: &Shape,
tree: ShapesPoolRef,
) -> HashSet<tiles::Tile> {
pub fn update_shape_tiles(&mut self, shape: &Shape, tree: ShapesPoolRef) -> Vec<tiles::Tile> {
let TileRect(rsx, rsy, rex, rey) = self.get_tiles_for_shape(shape, tree);
// Collect old tiles to avoid borrow conflict with remove_shape_at
let old_tiles: Vec<_> = self
let old_tiles = self
.tiles
.get_tiles_of(shape.id)
.map_or(Vec::new(), |t| t.iter().copied().collect());
.map_or(Vec::new(), |tiles| tiles.iter().copied().collect());
let mut result = HashSet::<tiles::Tile>::with_capacity(old_tiles.len());
let new_tiles = (rsx..=rex).flat_map(|x| (rsy..=rey).map(move |y| tiles::Tile::from(x, y)));
let mut result = HashSet::<tiles::Tile>::new();
// First, remove the shape from all tiles where it was previously located
for tile in old_tiles {
@@ -2246,66 +2208,12 @@ impl RenderState {
}
// Then, add the shape to the new tiles
for tile in (rsx..=rex).flat_map(|x| (rsy..=rey).map(move |y| tiles::Tile::from(x, y))) {
for tile in new_tiles {
self.tiles.add_shape_at(tile, shape.id);
result.insert(tile);
}
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()
result.iter().copied().collect()
}
/*
@@ -2331,22 +2239,12 @@ impl RenderState {
pub fn rebuild_tiles_shallow(&mut self, tree: ShapesPoolRef) {
performance::begin_measure!("rebuild_tiles_shallow");
// 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 all_tiles = HashSet::<tiles::Tile>::new();
let mut nodes = vec![Uuid::nil()];
while let Some(shape_id) = nodes.pop() {
if let Some(shape) = tree.get(&shape_id) {
if shape_id != Uuid::nil() {
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);
}
all_tiles.extend(self.update_shape_tiles(shape, tree));
} else {
// We only need to rebuild tiles from the first level.
for child_id in shape.children_ids_iter(false) {
@@ -2358,6 +2256,9 @@ impl RenderState {
// Invalidate changed tiles - old content stays visible until new tiles render
self.surfaces.remove_cached_tiles(self.background_color);
for tile in all_tiles {
self.remove_cached_tile(tile);
}
performance::end_measure!("rebuild_tiles_shallow");
}
@@ -2406,7 +2307,7 @@ impl RenderState {
let mut all_tiles = HashSet::<tiles::Tile>::new();
let ids = std::mem::take(&mut self.touched_ids);
let ids = self.touched_ids.clone();
for shape_id in ids.iter() {
if let Some(shape) = tree.get(shape_id) {
@@ -2421,6 +2322,8 @@ impl RenderState {
self.remove_cached_tile(tile);
}
self.clean_touched();
performance::end_measure!("rebuild_touched_tiles");
}
@@ -2477,7 +2380,6 @@ impl RenderState {
self.touched_ids.insert(uuid);
}
#[allow(dead_code)]
pub fn clean_touched(&mut self) {
self.touched_ids.clear();
}

View File

@@ -2,7 +2,7 @@ use skia_safe::{self as skia, Paint, RRect};
use super::{filters, RenderState, SurfaceId};
use crate::render::get_source_rect;
use crate::shapes::{merge_fills, Fill, Frame, ImageFill, Rect, Shape, Type};
use crate::shapes::{Fill, Frame, ImageFill, Rect, Shape, Type};
fn draw_image_fill(
render_state: &mut RenderState,
@@ -92,76 +92,6 @@ fn draw_image_fill(
* This SHOULD be the only public function in this module.
*/
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,
shape: &Shape,
fill: &Fill,
@@ -178,14 +108,7 @@ fn render_single_fill(
|state, temp_surface| {
let mut filtered_paint = paint.clone();
filtered_paint.set_image_filter(image_filter.clone());
draw_single_fill_to_surface(
state,
shape,
fill,
antialias,
temp_surface,
&filtered_paint,
);
draw_fill_to_surface(state, shape, fill, antialias, temp_surface, &filtered_paint);
},
) {
return;
@@ -194,10 +117,10 @@ fn render_single_fill(
}
}
draw_single_fill_to_surface(render_state, shape, fill, antialias, surface_id, &paint);
draw_fill_to_surface(render_state, shape, fill, antialias, surface_id, &paint);
}
fn draw_single_fill_to_surface(
fn draw_fill_to_surface(
render_state: &mut RenderState,
shape: &Shape,
fill: &Fill,
@@ -230,6 +153,8 @@ fn draw_single_fill_to_surface(
(_, Type::Group(_)) => {
// 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() {
for shadow in shape.inner_shadows_visible() {
let filter = shadow.get_inner_shadow_filter();
strokes::render_single(
strokes::render(
render_state,
shape,
stroke,

View File

@@ -1,7 +1,7 @@
use crate::math::{Matrix, Point, Rect};
use crate::shapes::{
merge_fills, Corners, Fill, ImageFill, Path, Shape, Stroke, StrokeCap, StrokeKind, Type,
Corners, Fill, ImageFill, Path, Shape, Stroke, StrokeCap, StrokeKind, SvgAttrs, Type,
};
use skia_safe::{self as skia, ImageFilter, RRect};
@@ -9,28 +9,32 @@ use super::{filters, RenderState, SurfaceId};
use crate::render::filters::compose_filters;
use crate::render::{get_dest_rect, get_source_rect};
// FIXME: See if we can simplify these arguments
#[allow(clippy::too_many_arguments)]
fn draw_stroke_on_rect(
canvas: &skia::Canvas,
stroke: &Stroke,
rect: &Rect,
selrect: &Rect,
corners: &Option<Corners>,
paint: &skia::Paint,
svg_attrs: Option<&SvgAttrs>,
scale: f32,
shadow: Option<&ImageFilter>,
blur: Option<&ImageFilter>,
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 mut paint = paint.clone();
let mut paint = stroke.to_paint(selrect, svg_attrs, antialias);
// Apply both blur and shadow filters if present, composing them if necessary.
let filter = compose_filters(blur, shadow);
paint.set_image_filter(filter);
// 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 {
match corners {
Some(radii) => {
let radii = stroke.outer_corners(radii);
let rrect = RRect::new_rect_radii(stroke_rect, &radii);
@@ -39,58 +43,34 @@ fn draw_stroke_on_rect(
None => {
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)]
fn draw_stroke_on_circle(
canvas: &skia::Canvas,
stroke: &Stroke,
rect: &Rect,
paint: &skia::Paint,
selrect: &Rect,
svg_attrs: Option<&SvgAttrs>,
scale: f32,
shadow: Option<&ImageFilter>,
blur: Option<&ImageFilter>,
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 mut paint = paint.clone();
let mut paint = stroke.to_paint(selrect, svg_attrs, antialias);
// Apply both blur and shadow filters if present, composing them if necessary.
let filter = compose_filters(blur, shadow);
paint.set_image_filter(filter);
// 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);
}
canvas.draw_oval(stroke_rect, &paint);
}
fn draw_outer_stroke_path(
@@ -142,13 +122,15 @@ 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
// FIXME: See if we can simplify these arguments
#[allow(clippy::too_many_arguments)]
fn draw_stroke_on_path(
pub fn draw_stroke_on_path(
canvas: &skia::Canvas,
stroke: &Stroke,
path: &Path,
paint: &skia::Paint,
selrect: &Rect,
path_transform: Option<&Matrix>,
svg_attrs: Option<&SvgAttrs>,
shadow: Option<&ImageFilter>,
blur: Option<&ImageFilter>,
antialias: bool,
@@ -158,28 +140,31 @@ fn draw_stroke_on_path(
let is_open = path.is_open();
let mut draw_paint = paint.clone();
let mut paint: skia_safe::Handle<_> =
stroke.to_stroked_paint(is_open, selrect, svg_attrs, antialias);
let filter = compose_filters(blur, shadow);
draw_paint.set_image_filter(filter);
paint.set_image_filter(filter);
match stroke.render_kind(is_open) {
StrokeKind::Inner => {
draw_inner_stroke_path(canvas, &skia_path, &draw_paint, blur, antialias);
draw_inner_stroke_path(canvas, &skia_path, &paint, blur, antialias);
}
StrokeKind::Center => {
canvas.draw_path(&skia_path, &draw_paint);
canvas.draw_path(&skia_path, &paint);
}
StrokeKind::Outer => {
draw_outer_stroke_path(canvas, &skia_path, &draw_paint, blur, antialias);
draw_outer_stroke_path(canvas, &skia_path, &paint, blur, antialias);
}
}
handle_stroke_caps(
&mut skia_path,
stroke,
selrect,
canvas,
is_open,
paint,
svg_attrs,
blur,
antialias,
);
@@ -222,15 +207,17 @@ fn handle_stroke_cap(
}
}
// FIXME: See if we can simplify these arguments
#[allow(clippy::too_many_arguments)]
fn handle_stroke_caps(
path: &mut skia::Path,
stroke: &Stroke,
selrect: &Rect,
canvas: &skia::Canvas,
is_open: bool,
paint: &skia::Paint,
svg_attrs: Option<&SvgAttrs>,
blur: Option<&ImageFilter>,
_antialias: bool,
antialias: bool,
) {
let mut points = vec![Point::default(); path.count_points()];
path.get_points(&mut points);
@@ -243,7 +230,7 @@ fn handle_stroke_caps(
let first_point = points.first().unwrap();
let last_point = points.last().unwrap();
let mut paint_stroke = paint.clone();
let mut paint_stroke = stroke.to_stroked_paint(is_open, selrect, svg_attrs, antialias);
if let Some(filter) = blur {
paint_stroke.set_image_filter(filter.clone());
@@ -418,25 +405,30 @@ fn draw_image_stroke_in_container(
match &shape.shape_type {
shape_type @ (Type::Rect(_) | Type::Frame(_)) => {
let paint = stroke.to_paint(&outer_rect, svg_attrs, antialias);
draw_stroke_on_rect(
canvas,
stroke,
container,
&outer_rect,
&shape_type.corners(),
&paint,
svg_attrs,
scale,
None,
None,
antialias,
);
}
Type::Circle => {
let paint = stroke.to_paint(&outer_rect, svg_attrs, antialias);
draw_stroke_on_circle(
canvas, stroke, container, &paint, scale, None, None, antialias,
);
}
Type::Circle => draw_stroke_on_circle(
canvas,
stroke,
container,
&outer_rect,
svg_attrs,
scale,
None,
None,
antialias,
),
shape_type @ (Type::Path(_) | Type::Bool(_)) => {
if let Some(p) = shape_type.path() {
@@ -454,21 +446,21 @@ fn draw_image_stroke_in_container(
}
}
let is_open = p.is_open();
let paint = stroke.to_stroked_paint(is_open, &outer_rect, svg_attrs, antialias);
let mut paint = stroke.to_stroked_paint(is_open, &outer_rect, svg_attrs, antialias);
canvas.draw_path(&path, &paint);
if stroke.render_kind(is_open) == StrokeKind::Outer {
// Small extra inner stroke to overlap with the fill
// and avoid unnecesary artifacts.
let mut thin_paint = paint.clone();
thin_paint.set_stroke_width(1. / scale);
canvas.draw_path(&path, &thin_paint);
paint.set_stroke_width(1. / scale);
canvas.draw_path(&path, &paint);
}
handle_stroke_caps(
&mut path,
stroke,
&outer_rect,
canvas,
is_open,
&paint,
svg_attrs,
shape.image_filter(1.).as_ref(),
antialias,
);
@@ -517,230 +509,8 @@ fn draw_image_stroke_in_container(
canvas.restore();
}
/// 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(
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(
pub fn render(
render_state: &mut RenderState,
shape: &Shape,
stroke: &Stroke,
@@ -748,7 +518,7 @@ pub fn render_single(
shadow: Option<&ImageFilter>,
antialias: bool,
) {
render_single_internal(
render_internal(
render_state,
shape,
stroke,
@@ -756,12 +526,34 @@ pub fn render_single(
shadow,
antialias,
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)]
fn render_single_internal(
fn render_internal(
render_state: &mut RenderState,
shape: &Shape,
stroke: &Stroke,
@@ -769,10 +561,10 @@ fn render_single_internal(
shadow: Option<&ImageFilter>,
antialias: bool,
bypass_filter: bool,
skip_blur: bool,
) {
if !bypass_filter {
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 stroke_margin = stroke.bounds_width(shape.is_open());
if stroke_margin > 0.0 {
@@ -790,7 +582,7 @@ fn render_single_internal(
bounds,
target,
|state, temp_surface| {
render_single_internal(
render_internal(
state,
shape,
stroke,
@@ -798,7 +590,6 @@ fn render_single_internal(
shadow,
antialias,
true,
true,
);
},
) {
@@ -814,12 +605,6 @@ fn render_single_internal(
let path_transform = shape.to_path_transform();
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(_))
&& shadow.is_none()
&& matches!(stroke.fill, Fill::Image(_))
@@ -837,45 +622,42 @@ fn render_single_internal(
} else {
match &shape.shape_type {
shape_type @ (Type::Rect(_) | Type::Frame(_)) => {
let paint = stroke.to_paint(&selrect, svg_attrs, antialias);
draw_stroke_on_rect(
canvas,
stroke,
&selrect,
&shape_type.corners(),
&paint,
scale,
shadow,
blur.as_ref(),
antialias,
);
}
Type::Circle => {
let paint = stroke.to_paint(&selrect, svg_attrs, antialias);
draw_stroke_on_circle(
canvas,
stroke,
&selrect,
&paint,
&shape_type.corners(),
svg_attrs,
scale,
shadow,
blur.as_ref(),
shape.image_filter(1.).as_ref(),
antialias,
);
}
Type::Circle => draw_stroke_on_circle(
canvas,
stroke,
&selrect,
&selrect,
svg_attrs,
scale,
shadow,
shape.image_filter(1.).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 paint = stroke.to_stroked_paint(is_open, &selrect, svg_attrs, antialias);
draw_stroke_on_path(
canvas,
stroke,
path,
&paint,
&selrect,
path_transform.as_ref(),
svg_attrs,
shadow,
blur.as_ref(),
shape.image_filter(1.).as_ref(),
antialias,
);
}

View File

@@ -1,240 +0,0 @@
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,7 +620,6 @@ impl Shape {
(added, removed)
}
#[allow(dead_code)]
pub fn fills(&self) -> std::slice::Iter<'_, Fill> {
self.fills.iter()
}
@@ -1120,28 +1119,6 @@ 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(
&self,
shapes: ShapesPoolRef,

View File

@@ -241,14 +241,10 @@ pub fn merge_fills(fills: &[Fill], bounding_box: Rect) -> skia::Paint {
if let Some(shader) = 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(
skia::Blender::mode(skia::BlendMode::SrcOver),
shader,
skia::Blender::mode(skia::BlendMode::DstOver),
existing_shader,
shader,
)),
None => Some(shader),
};

View File

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

View File

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

View File

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

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