Compare commits

..

49 Commits

Author SHA1 Message Date
alonso.torres
fd3d549f9c Batch text layout updates 2026-02-05 17:29:43 +01:00
alonso.torres
53c2acb3e6 🐛 Fix several problems with layouts and texts 2026-02-05 17:29:43 +01:00
Belén Albeza
8a72eb64c3 Add integration test for 13267 2026-02-05 16:37:21 +01:00
alonso.torres
1d45ca7019 🐛 Fix problem propagating geometry changes to instances 2026-02-05 16:37:21 +01:00
Andrey Antukh
7f318bb110 Merge remote-tracking branch 'origin/staging' into staging-render 2026-02-04 16:22:13 +01:00
Andrey Antukh
44c7d3fbd6 Backport .github workflows from develop 2026-02-04 16:21:19 +01:00
Andrey Antukh
3d50aa6cb2 ⬆️ Update imagemagick version 2026-02-04 16:21:19 +01:00
Andrey Antukh
06afd94a74 ⬆️ Update backend dependencies (mainly bugfixes) 2026-02-04 16:21:19 +01:00
Andrey Antukh
e7d9dca55e ⬆️ Update jdk and node on devenv and other images 2026-02-04 16:21:19 +01:00
Andrey Antukh
c14ccc18b8 Import mcp from develop 2026-02-04 16:21:19 +01:00
Alejandro Alonso
24c8fc484f 🐛 Fix Internal Error when adding a new text layer and trying to go back to Dashboard without saving 2026-02-04 10:01:10 +01:00
Alejandro Alonso
bc16b8ddc3 Merge pull request #8198 from penpot/ladybenko-13176-playwright-wasm
🔧 Migrate workspace tests to user the wasm viewport
2026-02-03 13:00:10 +01:00
Alejandro Alonso
b07c98faa5 Merge pull request #8259 from penpot/superalex-improve-shadow-rendering
🎉 Improving shadow rendering performance
2026-02-03 12:59:49 +01:00
Alejandro Alonso
25aff100cf 🎉 Add shadows playground for render wasm 2026-02-03 12:44:43 +01:00
Alejandro Alonso
5be887f10b 🎉 Improve plain shape calculation 2026-02-03 12:44:43 +01:00
Alejandro Alonso
f7403935c8 🎉 Improve shadows rendering performance 2026-02-03 12:33:05 +01:00
Andrey Antukh
7d09d930fe 📚 Update changelog 2026-02-03 11:13:46 +01:00
Belén Albeza
79be3ab7df 🔧 Fix text editor flaky tests 2026-02-03 10:39:38 +01:00
Andrey Antukh
1325584e1a Merge remote-tracking branch 'origin/staging' into staging-render 2026-02-03 08:24:04 +01:00
Andrey Antukh
0d9b7ca696 Merge tag '2.13.0-RC11' 2026-02-03 08:23:27 +01:00
Andrey Antukh
d215a5c402 Merge tag '2.13.0-RC10' 2026-02-03 08:22:50 +01:00
Belén Albeza
629649aca6 🔧 Fix config playwright syntax 2026-02-02 16:25:16 +01:00
Belén Albeza
cc326f23cf 🔧 Adjust timeout of websocket readiness (playwright) 2026-02-02 16:16:59 +01:00
Belén Albeza
2c4efc6b53 🔧 Fix onboarding test 2026-02-02 16:16:58 +01:00
Belén Albeza
4d5c874b91 🔧 Fix typography token test 2026-02-02 16:16:58 +01:00
Belén Albeza
e3b97638b4 🔧 Fix broken / flaky tests 2026-02-02 16:16:58 +01:00
Belén Albeza
daedc660b9 🔧 Migrate workspace tests to user the wasm viewport 2026-02-02 16:16:58 +01:00
Elena Torró
7681231d8f Merge pull request #8246 from penpot/azazeln28-test-more-text-editor
🔧 Add more Text Editor v2 tests
2026-02-02 15:09:18 +01:00
Aitor Moreno
07b9ef0fd6 🔧 Add more v2 text editor tests 2026-02-02 09:35:28 +01:00
Andrey Antukh
f65292a13c 📎 Mark as skip two text editor v2 tests (flaky) 2026-01-29 10:43:29 +01:00
Andrey Antukh
94722fdec2 Ensure .hidePopover fn exist before call 2026-01-29 10:43:29 +01:00
Andrey Antukh
28509e0418 Ensure .stopPropagation fn exists before calling
Also for .stopImmediatePropagation and .preventDefault on
the event instances.
2026-01-29 10:43:29 +01:00
Eva Marco
9569fa2bcb 🐛 Fix error when creating a token with an invalid name (#8216) 2026-01-29 10:41:52 +01:00
Eva Marco
852b31c3a0 🐛 Fix allow spaces on token description (#8234) 2026-01-29 10:40:32 +01:00
Andrés Moya
84b3f5d7c6 🐛 Fix import of shadow tokens 2026-01-29 10:25:22 +01:00
David Barragán Merino
76bd31fe7d 🔧 Fix CORS error 2026-01-28 13:40:45 +01:00
David Barragán Merino
77bbf30ae4 🔧 Fix file name 2026-01-27 21:16:44 +01:00
David Barragán Merino
693b52bf45 📚 Fix links related to penpot plugins 2026-01-27 21:16:44 +01:00
David Barragán Merino
0f51b23ce7 🔧 Deploy plugin styles documentation 2026-01-27 21:16:44 +01:00
David Barragán Merino
ec61aa6b6d 🔧 Add custom domain 2026-01-27 21:16:41 +01:00
Andrey Antukh
abc1773f65 Merge tag '2.13.0-RC9' 2026-01-26 18:12:16 +01:00
David Barragán Merino
93f5e74bb0 🔧 Run all the jobs if the workflow is launched manually 2026-01-26 17:14:15 +01:00
David Barragán Merino
38179ba11e 🔧 Enable secret inheritance 2026-01-26 14:01:22 +01:00
David Barragán Merino
719a95246a 🔧 Define deploy plugin packages workflows 2026-01-26 13:48:33 +01:00
David Barragán Merino
e590cd852d 🔧 Rename wrangle to wrangler 2026-01-26 13:48:33 +01:00
David Barragán Merino
a9741073e5 🔧 Add deploy plugin packages workflow placeholder and wrangle config files 2026-01-26 13:48:33 +01:00
David Barragán Merino
599656c31e 🔧 Fix a typo in an interpolation 2026-01-23 19:52:47 +01:00
David Barragán Merino
16f22a7b5c 🔧 Fixes to the API documentation deployer 2026-01-22 12:10:27 +01:00
David Barragán Merino
a1460115e8 🔧 Deploy penpot api documentation 2026-01-22 12:10:27 +01:00
159 changed files with 31708 additions and 3493 deletions

View File

@@ -0,0 +1,38 @@
---
name: New Render Bug Report
about: Create a report about the bugs you have found in the new render
title: ''
labels: new render
assignees: claragvinola
---
**Describe the bug**
A clear and concise description of what the bug is.
**Steps to Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots or screen recordings**
If applicable, add screenshots or screen recording to help illustrate your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@@ -40,7 +40,7 @@ on:
jobs:
build-bundle:
name: Build and Upload Penpot Bundle
runs-on: ubuntu-24.04
runs-on: penpot-runner-01
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

View File

@@ -7,9 +7,14 @@ jobs:
build-and-push:
name: Build and push DevEnv Docker image
environment: release-admins
runs-on: ubuntu-24.04
runs-on: penpot-runner-02
steps:
- name: Set common environment variables
run: |
# Each job execution will use its own docker configuration.
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
- name: Checkout code
uses: actions/checkout@v4

View File

@@ -19,9 +19,14 @@ on:
jobs:
build-and-push:
name: Build and Push Penpot Docker Images
runs-on: ubuntu-24.04-arm
runs-on: penpot-runner-02
steps:
- name: Set common environment variables
run: |
# Each job execution will use its own docker configuration.
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
- name: Checkout code
uses: actions/checkout@v4
with:
@@ -66,6 +71,15 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
# To avoid the “429 Too Many Requests” error when downloading
# images from DockerHub for unregistered users.
# https://docs.docker.com/docker-hub/usage/
- name: Login to DockerHub Registry
uses: docker/login-action@v3
with:
username: ${{ secrets.PUB_DOCKER_USERNAME }}
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5

View File

@@ -26,7 +26,7 @@ jobs:
- name: Check Commit Type
uses: gsactions/commit-message-checker@v2
with:
pattern: '^(((:(lipstick|globe_with_meridians|wrench|books|arrow_up|arrow_down|zap|ambulance|construction|boom|fire|whale|bug|sparkles|paperclip|tada|recycle|rewind|construction_worker):)\s[A-Z].*[^.])|(Merge|Revert).+[^.])$'
pattern: '^(((:(lipstick|globe_with_meridians|wrench|books|arrow_up|arrow_down|zap|ambulance|construction|boom|fire|whale|bug|sparkles|paperclip|tada|recycle|rewind|construction_worker):)\s[A-Z].*[^.])|(Merge|Revert|Reapply).+[^.])$'
flags: 'gm'
error: 'Commit should match CONTRIBUTING.md guideline'
checkAllCommitMessages: 'true' # optional: this checks all commits associated with a pull request

45
.github/workflows/tests-mcp.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
name: "MCP CI"
on:
pull_request:
branches:
- develop
- staging
- main
types:
- opened
- synchronize
paths:
- 'mcp/**'
push:
branches:
- develop
- staging
- main
paths:
- 'mcp/**'
jobs:
test:
name: "Test"
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup
working-directory: ./mcp
run: ./scripts/setup
- name: Check
working-directory: ./mcp
run: |
pnpm run fmt:check;
pnpm -r run build;
pnpm -r run types:check;

2
.nvmrc
View File

@@ -1 +1 @@
v22.21.1
v22.22.0

View File

@@ -1,10 +1,6 @@
# CHANGELOG
## 2.13.0 (Unreleased)
### :boom: Breaking changes & Deprecations
### :rocket: Epics and highlights
## 2.13.0
### :heart: Community contributions (Thank you!)
@@ -39,6 +35,9 @@
- Fix incorrect handling of input values on layout gap and padding inputs [Github #8113](https://github.com/penpot/penpot/issues/8113)
- Fix several race conditions on path editor [Github #8187](https://github.com/penpot/penpot/pull/8187)
- Fix app freeze when introducing an error on a very long token name [Taiga #13214](https://tree.taiga.io/project/penpot/issue/13214)
- Fix import a file with shadow tokens [Taiga #13229](https://tree.taiga.io/project/penpot/issue/13229)
- Fix allow spaces on token description [Taiga #13184](https://tree.taiga.io/project/penpot/issue/13184)
- Fix error when creating a token with an invalid name [Taiga #13219](https://tree.taiga.io/project/penpot/issue/13219)
## 2.12.1

View File

@@ -3,7 +3,7 @@
:deps
{penpot/common {:local/root "../common"}
org.clojure/clojure {:mvn/version "1.12.2"}
org.clojure/clojure {:mvn/version "1.12.4"}
org.clojure/tools.namespace {:mvn/version "1.5.0"}
com.github.luben/zstd-jni {:mvn/version "1.5.7-4"}
@@ -28,8 +28,8 @@
com.google.guava/guava {:mvn/version "33.4.8-jre"}
funcool/yetti
{:git/tag "v11.8"
:git/sha "1d1b33f"
{:git/tag "v11.9"
:git/sha "5fad7a9"
:git/url "https://github.com/funcool/yetti.git"
:exclusions [org.slf4j/slf4j-api]}
@@ -39,7 +39,7 @@
metosin/reitit-core {:mvn/version "0.9.1"}
nrepl/nrepl {:mvn/version "1.4.0"}
org.postgresql/postgresql {:mvn/version "42.7.7"}
org.postgresql/postgresql {:mvn/version "42.7.9"}
org.xerial/sqlite-jdbc {:mvn/version "3.50.3.0"}
com.zaxxer/HikariCP {:mvn/version "7.0.2"}
@@ -49,7 +49,7 @@
buddy/buddy-hashers {:mvn/version "2.0.167"}
buddy/buddy-sign {:mvn/version "3.6.1-359"}
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.2"}
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.3"}
org.jsoup/jsoup {:mvn/version "1.21.2"}
org.im4java/im4java
@@ -66,7 +66,7 @@
;; Pretty Print specs
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
software.amazon.awssdk/s3 {:mvn/version "2.33.10"}}
software.amazon.awssdk/s3 {:mvn/version "2.41.21"}}
:paths ["src" "resources" "target/classes"]
:aliases

View File

@@ -1,5 +1,5 @@
{:deps
{org.clojure/clojure {:mvn/version "1.12.2"}
{org.clojure/clojure {:mvn/version "1.12.4"}
org.clojure/data.json {:mvn/version "2.5.1"}
org.clojure/tools.cli {:mvn/version "1.1.230"}
org.clojure/test.check {:mvn/version "1.1.1"}
@@ -9,15 +9,15 @@
org.apache.commons/commons-pool2 {:mvn/version "2.12.1"}
;; Logging
org.apache.logging.log4j/log4j-api {:mvn/version "2.25.1"}
org.apache.logging.log4j/log4j-core {:mvn/version "2.25.1"}
org.apache.logging.log4j/log4j-web {:mvn/version "2.25.1"}
org.apache.logging.log4j/log4j-jul {:mvn/version "2.25.1"}
org.apache.logging.log4j/log4j-slf4j2-impl {:mvn/version "2.25.1"}
org.apache.logging.log4j/log4j-api {:mvn/version "2.25.3"}
org.apache.logging.log4j/log4j-core {:mvn/version "2.25.3"}
org.apache.logging.log4j/log4j-web {:mvn/version "2.25.3"}
org.apache.logging.log4j/log4j-jul {:mvn/version "2.25.3"}
org.apache.logging.log4j/log4j-slf4j2-impl {:mvn/version "2.25.3"}
org.slf4j/slf4j-api {:mvn/version "2.0.17"}
pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.40"}
pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.41"}
selmer/selmer {:mvn/version "1.12.69"}
selmer/selmer {:mvn/version "1.12.70"}
criterium/criterium {:mvn/version "0.4.6"}
metosin/jsonista {:mvn/version "0.3.13"}
@@ -27,7 +27,7 @@
com.cognitect/transit-clj {:mvn/version "1.0.333"}
com.cognitect/transit-cljs {:mvn/version "0.8.280"}
java-http-clj/java-http-clj {:mvn/version "0.4.3"}
integrant/integrant {:mvn/version "1.0.0"}
integrant/integrant {:mvn/version "1.0.1"}
funcool/cuerdas {:mvn/version "2026.415"}
funcool/promesa

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

@@ -407,17 +407,19 @@
(defn change-text
"Changes the content of the text shape to use the text as argument. Will use the styles of the
first paragraph and text that is present in the shape (and override the rest)"
[content text]
[content text & {:as styles}]
(let [root-styles (select-keys content root-attrs)
paragraph-style
(merge
default-text-attrs
styles
(select-keys (->> content (node-seq is-paragraph-node?) first) text-all-attrs))
text-style
(merge
default-text-attrs
styles
(select-keys (->> content (node-seq is-text-node?) first) text-all-attrs))
paragraph-texts

View File

@@ -97,9 +97,12 @@
(def token-types
(into #{} (keys token-type->dtcg-token-type)))
(def token-name-validation-regex
#"^[a-zA-Z0-9_-][a-zA-Z0-9$_-]*(\.[a-zA-Z0-9$_-]+)*$")
(def token-name-ref
[:re {:title "TokenNameRef" :gen/gen sg/text}
#"^[a-zA-Z0-9_-][a-zA-Z0-9$_-]*(\.[a-zA-Z0-9$_-]+)*$"])
token-name-validation-regex])
(def ^:private schema:color
[:map

View File

@@ -1462,11 +1462,12 @@ Will return a value that matches this schema:
(def ^:private schema:dtcg-node
[:schema {:registry
{::simple-value
[:or :string :int :double]
[:or :string :int :double ::sm/boolean]
::value
[:or
[:ref ::simple-value]
[:vector ::simple-value]
[:vector [:map-of :string ::simple-value]]
[:map-of :string [:or
[:ref ::simple-value]
[:vector ::simple-value]]]]}}

View File

@@ -31,7 +31,7 @@ RUN set -ex; \
FROM base AS setup-node
ENV NODE_VERSION=v22.21.1 \
ENV NODE_VERSION=v22.22.0 \
PATH=/opt/node/bin:$PATH
RUN set -eux; \
@@ -97,18 +97,19 @@ RUN set -eux; \
FROM base AS setup-jvm
ENV CLOJURE_VERSION=1.12.3.1577
# https://clojure.org/releases/tools
ENV CLOJURE_VERSION=1.12.4.1602
RUN set -eux; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
aarch64|arm64) \
ESUM='8c5321f16d9f1d8149f83e4e9ff8ca5d9e94320b92d205e6db42a604de3d1140'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.30.17-ca-jdk25.0.1-linux_aarch64.tar.gz'; \
ESUM='9903c6b19183a33725ca1dfdae5b72400c9d00995c76fafc4a0d31c5152f33f7'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_aarch64.tar.gz'; \
;; \
amd64|x86_64) \
ESUM='471b3e62bdffaed27e37005d842d8639f10d244ccce1c7cdebf7abce06c8313e'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.30.17-ca-jdk25.0.1-linux_x64.tar.gz'; \
ESUM='946ad9766d98fc6ab495a1a120072197db54997f6925fb96680f1ecd5591db4e'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_x64.tar.gz'; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; \
@@ -179,9 +180,10 @@ RUN set -eux; \
FROM base AS setup-utils
ENV CLJKONDO_VERSION=2025.07.28 \
ENV CLJKONDO_VERSION=2026.01.19 \
BABASHKA_VERSION=1.12.208 \
CLJFMT_VERSION=0.13.1
CLJFMT_VERSION=0.15.6 \
PIXI_VERSION=0.63.2
RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \
@@ -224,6 +226,26 @@ RUN set -ex; \
tar -xf /tmp/babashka.tar.gz; \
rm -rf /tmp/babashka.tar.gz;
RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
aarch64|arm64) \
BINARY_URL="https://github.com/prefix-dev/pixi/releases/download/v$PIXI_VERSION/pixi-aarch64-unknown-linux-musl.tar.gz"; \
;; \
amd64|x86_64) \
BINARY_URL="https://github.com/prefix-dev/pixi/releases/download/v$PIXI_VERSION/pixi-x86_64-unknown-linux-musl.tar.gz"; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; \
exit 1; \
;; \
esac; \
cd /tmp; \
curl -LfsSo /tmp/pixi.tar.gz ${BINARY_URL}; \
cd /opt/utils/bin; \
tar -xf /tmp/pixi.tar.gz; \
rm -rf /tmp/pixi.tar.gz;
RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
@@ -375,7 +397,7 @@ ENV LANG='C.UTF-8' \
RUSTUP_HOME="/opt/rustup" \
PATH="/opt/jdk/bin:/opt/utils/bin:/opt/clojure/bin:/opt/node/bin:/opt/imagick/bin:/opt/cargo/bin:$PATH"
COPY --from=penpotapp/imagemagick:7.1.2-0 /opt/imagick /opt/imagick
COPY --from=penpotapp/imagemagick:7.1.2-13 /opt/imagick /opt/imagick
COPY --from=setup-jvm /opt/jdk /opt/jdk
COPY --from=setup-jvm /opt/clojure /opt/clojure
COPY --from=setup-node /opt/node /opt/node
@@ -398,7 +420,6 @@ COPY files/Caddyfile /home/
COPY files/selfsigned.crt /home/
COPY files/selfsigned.key /home/
COPY files/start-tmux.sh /home/start-tmux.sh
COPY files/start-tmux-back.sh /home/start-tmux-back.sh
COPY files/entrypoint.sh /home/entrypoint.sh
COPY files/init.sh /home/init.sh

View File

@@ -46,6 +46,11 @@ services:
- 9090:9090
- 9091:9091
# MCP
- 4400:4400
- 4401:4401
- 4402:4402
environment:
- EXTERNAL_UID=${CURRENT_USER_ID}
# SMTP setup

View File

@@ -1,16 +1,17 @@
{
auto_https off
auto_https off
}
localhost:3449 {
reverse_proxy localhost:4449
tls /home/selfsigned.crt /home/selfsigned.key
reverse_proxy localhost:4449
tls /home/selfsigned.crt /home/selfsigned.key
header -Strict-Transport-Security
}
http://localhost:3450 {
reverse_proxy localhost:4449
reverse_proxy localhost:4449
}
http://penpot-devenv-main:3450 {
reverse_proxy localhost:4449
reverse_proxy localhost:4449
}

View File

@@ -6,7 +6,8 @@ export PATH="/home/penpot/.cargo/bin:/opt/jdk/bin:/opt/utils/bin:/opt/clojure/bi
export CARGO_HOME="/home/penpot/.cargo"
alias l='ls --color -GFlh'
alias rm='rm -r'
alias ll='ls --color -GFlh'
alias rm='rm -rf'
alias ls='ls --color -F'
alias lsd='ls -d *(/)'
alias lsf='ls -h *(.)'

View File

@@ -121,6 +121,28 @@ http {
proxy_http_version 1.1;
}
location /mcp {
alias /home/penpot/penpot/mcp/packages/plugin/dist;
proxy_http_version 1.1;
}
location /mcp/ws {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_pass http://127.0.0.1:4402;
proxy_http_version 1.1;
}
location /mcp/stream {
proxy_pass http://127.0.0.1:4401/mcp;
proxy_http_version 1.1;
}
location /mcp/sse {
proxy_pass http://127.0.0.1:4401/sse;
proxy_http_version 1.1;
}
location /admin {
proxy_pass http://127.0.0.1:6063/admin;
}
@@ -141,8 +163,14 @@ http {
proxy_pass http://127.0.0.1:5000;
}
location /nitrate/ {
proxy_pass http://127.0.0.1:3000/;
location /control-center {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /wasm-playground {

View File

@@ -1,3 +1,4 @@
set -g default-command "${SHELL}"
set -g mouse off
set -g history-limit 50000
setw -g mode-keys emacs

View File

@@ -6,7 +6,7 @@ ENV LANG='C.UTF-8' \
DEBIAN_FRONTEND=noninteractive \
TZ=Etc/UTC
ARG IMAGEMAGICK_VERSION=7.1.1-47
ARG IMAGEMAGICK_VERSION=7.1.2-13
RUN set -e; \
apt-get -qq update; \

View File

@@ -5,7 +5,7 @@ ENV LANG='C.UTF-8' \
LC_ALL='C.UTF-8' \
JAVA_HOME="/opt/jdk" \
DEBIAN_FRONTEND=noninteractive \
NODE_VERSION=v22.21.1 \
NODE_VERSION=v22.22.0 \
TZ=Etc/UTC
RUN set -ex; \
@@ -46,12 +46,12 @@ RUN set -eux; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
aarch64|arm64) \
ESUM='8c5321f16d9f1d8149f83e4e9ff8ca5d9e94320b92d205e6db42a604de3d1140'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.30.17-ca-jdk25.0.1-linux_aarch64.tar.gz'; \
ESUM='9903c6b19183a33725ca1dfdae5b72400c9d00995c76fafc4a0d31c5152f33f7'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_aarch64.tar.gz'; \
;; \
amd64|x86_64) \
ESUM='471b3e62bdffaed27e37005d842d8639f10d244ccce1c7cdebf7abce06c8313e'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.30.17-ca-jdk25.0.1-linux_x64.tar.gz'; \
ESUM='946ad9766d98fc6ab495a1a120072197db54997f6925fb96680f1ecd5591db4e'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_x64.tar.gz'; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; \

View File

@@ -3,7 +3,7 @@ LABEL maintainer="Penpot <docker@penpot.app>"
ENV LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
NODE_VERSION=v22.21.1 \
NODE_VERSION=v22.22.0 \
DEBIAN_FRONTEND=noninteractive \
PATH=/opt/node/bin:/opt/imagick/bin:$PATH

View File

@@ -1,4 +1,5 @@
import { defineConfig, devices } from "@playwright/test";
import { platform } from "os";
/**
* Read environment variables from file.
@@ -6,6 +7,10 @@ import { defineConfig, devices } from "@playwright/test";
*/
// require('dotenv').config();
const userAgent = platform === 'darwin' ?
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" :
undefined;
/**
* @see https://playwright.dev/docs/test-configuration
*/
@@ -43,12 +48,20 @@ export default defineConfig({
projects: [
{
name: "default",
use: { ...devices["Desktop Chrome"] },
testDir: "./playwright/ui/specs",
use: {
...devices["Desktop Chrome"],
viewport: { width: 1920, height: 1080 }, // Add custom viewport size
video: 'retain-on-failure',
trace: 'retain-on-failure',
}
userAgent,
},
snapshotPathTemplate: "{testDir}/{testFilePath}-snapshots/{arg}.png",
expect: {
toHaveScreenshot: {
maxDiffPixelRatio: 0.001,
},
},
},
{
name: "ds",

View File

@@ -0,0 +1,146 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"plugins/runtime",
"design-tokens/v1",
"variants/v1",
"layout/grid",
"styles/v2",
"fdata/objects-map",
"render-wasm/v1",
"components/v2",
"fdata/shape-data-type"
]
},
"~:team-id": "~u99e49e93-362f-80ef-8007-3450ea52c9a4",
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "BUG 13267",
"~:revn": 3,
"~:modified-at": "~m1770302832804",
"~:vern": 0,
"~:id": "~ue9c84e12-dd29-80fc-8007-86d559dced7f",
"~: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": "~ufc576d2f-8d02-8101-8007-70ec5793bd81",
"~:created-at": "~m1770302800755",
"~:backend": "legacy-db",
"~:data": {
"~:pages": [
"~ue9c84e12-dd29-80fc-8007-86d559dced80"
],
"~:pages-index": {
"~ue9c84e12-dd29-80fc-8007-86d559dced80": {
"~: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\",[\"~udc075bef-4a1f-8056-8007-86d562cf43b7\"]]]",
"~udc075bef-4a1f-8056-8007-86d55e028ccb": "[\"~#shape\",[\"^ \",\"~:y\",234,\"~: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\",117,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",574,\"~:y\",234]],[\"^<\",[\"^ \",\"~:x\",691,\"~:y\",234]],[\"^<\",[\"^ \",\"~:x\",691,\"~:y\",316]],[\"^<\",[\"^ \",\"~:x\",574,\"~:y\",316]]],\"~: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,\"~:constraints-v\",\"~:scale\",\"~:constraints-h\",\"^B\",\"~:r1\",0,\"~:id\",\"~udc075bef-4a1f-8056-8007-86d55e028ccb\",\"~:parent-id\",\"~udc075bef-4a1f-8056-8007-86d562cf43b7\",\"~:frame-id\",\"~udc075bef-4a1f-8056-8007-86d562cf43b7\",\"~:strokes\",[],\"~:x\",574,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",574,\"~:y\",234,\"^8\",117,\"~:height\",82,\"~:x1\",574,\"~:y1\",234,\"~:x2\",691,\"~:y2\",316]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^M\",82,\"~:flip-y\",null]]",
"~udc075bef-4a1f-8056-8007-86d562cf43b7": "[\"~#shape\",[\"^ \",\"~:y\",234,\"~: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,\"~:hide-in-viewer\",true,\"~:name\",\"A Component\",\"~:width\",117,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",574,\"~:y\",234]],[\"^;\",[\"^ \",\"~:x\",691,\"~:y\",234]],[\"^;\",[\"^ \",\"~:x\",691,\"~:y\",316]],[\"^;\",[\"^ \",\"~:x\",574,\"~:y\",316]]],\"~:r2\",0,\"~:component-root\",true,\"~:show-content\",true,\"~: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\",\"~udc075bef-4a1f-8056-8007-86d562cf43b7\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:component-id\",\"~udc075bef-4a1f-8056-8007-86d562d06904\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",574,\"~:main-instance\",true,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",574,\"~:y\",234,\"^7\",117,\"~:height\",82,\"~:x1\",574,\"~:y1\",234,\"~:x2\",691,\"~:y2\",316]],\"~:fills\",[],\"~:flip-x\",null,\"^M\",82,\"~:component-file\",\"~ue9c84e12-dd29-80fc-8007-86d559dced7f\",\"~:flip-y\",null,\"~:shapes\",[\"~udc075bef-4a1f-8056-8007-86d55e028ccb\"]]]"
}
},
"~:id": "~ue9c84e12-dd29-80fc-8007-86d559dced80",
"~:name": "Page 1"
}
},
"~:id": "~ue9c84e12-dd29-80fc-8007-86d559dced7f",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
},
"~:components": {
"~udc075bef-4a1f-8056-8007-86d562d06904": {
"~:id": "~udc075bef-4a1f-8056-8007-86d562d06904",
"~:name": "A Component",
"~:path": "",
"~:modified-at": "~m1770302824566",
"~:main-instance-id": "~udc075bef-4a1f-8056-8007-86d562cf43b7",
"~:main-instance-page": "~ue9c84e12-dd29-80fc-8007-86d559dced80"
}
}
}
}

View File

@@ -0,0 +1,7 @@
{
"~:file-id": "~u8d38942d-b01f-800e-8007-79ee6a9bac45",
"~:tag": "component",
"~:object-id": "8d38942d-b01f-800e-8007-79ee6a9bac45/8d38942d-b01f-800e-8007-79ee6a9bac46/6b68aedd-4c5b-80b9-8007-7b38c1d34ce4/component",
"~:media-id": "~ube2dc82e-615b-486b-a193-8768bdb51d7a",
"~:created-at": "~m1769523563389"
}

View File

@@ -253,7 +253,7 @@ export class WorkspacePage extends BaseWebSocketPage {
async #waitForWebSocketReadiness() {
// TODO: find a better event to settle whether the app is ready to receive notifications via ws
await expect(this.pageName).toHaveText("Page 1");
await expect(this.pageName).toHaveText("Page 1", { timeout: 30000 })
}
async sendPresenceMessage(fixture) {
@@ -383,19 +383,46 @@ export class WorkspacePage extends BaseWebSocketPage {
await this.page.keyboard.press("T");
await this.page.waitForTimeout(timeToWait);
await this.clickAndMove(x1, y1, x2, y2);
await this.page.waitForTimeout(timeToWait);
await expect(this.page.getByTestId("text-editor")).toBeVisible();
if (initialText) {
await this.page.keyboard.type(initialText);
}
}
/**
* Copies the selected element into the clipboard.
* Copies the selected element into the clipboard, or copy the
* content of the locator into the clipboard.
*
* @returns {Promise<void>}
*/
async copy() {
return this.page.keyboard.press("Control+C");
async copy(kind = "keyboard", locator = undefined) {
if (kind === "context-menu" && locator) {
await locator.click({ button: "right" });
await this.page.getByText("Copy", { exact: true }).click();
} else {
await this.page.keyboard.press("ControlOrMeta+C");
}
// wait for the clipboard to be updated
await this.page.waitForFunction(async () => {
const content = await navigator.clipboard.readText()
return content !== "";
}, { timeout: 1000 });
}
async cut(kind = "keyboard", locator = undefined) {
if (kind === "context-menu" && locator) {
await locator.click({ button: "right" });
await this.page.getByText("Cut", { exact: true }).click();
} else {
await this.page.keyboard.press("ControlOrMeta+X");
}
// wait for the clipboard to be updated
await this.page.waitForFunction(async () => {
const content = await navigator.clipboard.readText()
return content !== "";
}, { timeout: 1000 });
}
/**
@@ -407,9 +434,9 @@ export class WorkspacePage extends BaseWebSocketPage {
async paste(kind = "keyboard") {
if (kind === "context-menu") {
await this.viewport.click({ button: "right" });
return this.page.getByText("PasteCtrlV").click();
return this.page.getByText("Paste", { exact: true }).click();
}
return this.page.keyboard.press("Control+V");
return this.page.keyboard.press("ControlOrMeta+V");
}
async panOnViewportAt(x, y, width, height) {
@@ -432,8 +459,8 @@ export class WorkspacePage extends BaseWebSocketPage {
await this.page.mouse.up();
}
async clickLeafLayer(name, clickOptions = {}) {
const layer = this.layers.getByText(name).first();
async clickLeafLayer(name, clickOptions = {}, index = 0) {
const layer = this.layers.getByText(name).nth(index);
await layer.waitFor();
await layer.click(clickOptions);
await this.page.waitForTimeout(500);
@@ -444,15 +471,16 @@ export class WorkspacePage extends BaseWebSocketPage {
await this.clickLeafLayer(name, clickOptions);
}
async clickToggableLayer(name, clickOptions = {}) {
async clickToggableLayer(name, clickOptions = {}, index = 0) {
const layer = this.layers
.getByTestId("layer-row")
.filter({ hasText: name });
const button = layer.getByRole("button");
.filter({ hasText: name })
.nth(index);
const button = layer.getByTestId("toggle-content");
await button.waitFor();
await expect(button).toBeVisible();
await button.click(clickOptions);
await this.page.waitForTimeout(500);
await button.waitFor({ ariaExpanded: true });
}
async expectSelectedLayer(name) {
@@ -495,13 +523,7 @@ export class WorkspacePage extends BaseWebSocketPage {
async clickColorPalette(clickOptions = {}) {
await this.palette
.getByRole("button", { name: "Color Palette (Alt+P)" })
.click(clickOptions);
}
async clickColorPalette(clickOptions = {}) {
await this.palette
.getByRole("button", { name: "Color Palette (Alt+P)" })
.getByRole("button", { name: /Color Palette/ })
.click(clickOptions);
}

View File

@@ -0,0 +1,33 @@
import { test, expect } from "@playwright/test";
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
test.beforeEach(async ({ page }) => {
await WasmWorkspacePage.init(page);
});
test("BUG 13267 - Component instance is not synced with parent for geometry changes", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockGetFile("components/get-file-13267.json");
await workspacePage.goToWorkspace({
fileId: "e9c84e12-dd29-80fc-8007-86d559dced7f",
pageId: "e9c84e12-dd29-80fc-8007-86d559dced80",
});
// Create a component instance
await workspacePage.clickLeafLayer("A Component");
await workspacePage.page.keyboard.press("ControlOrMeta+d");
// Select the main component
await workspacePage.clickLeafLayer("A Component", {}, 1);
const rotationInput = workspacePage.rightSidebar.getByTestId("rotation").getByRole("textbox");
await rotationInput.fill("45");
await rotationInput.press("Enter");
// Select the instance rect
await workspacePage.clickToggableLayer("A Component", {}, 0);
await workspacePage.clickLeafLayer("Rectangle");
await expect(rotationInput).toHaveValue("45");
});

View File

@@ -15,6 +15,8 @@ test("User can complete the onboarding", async ({ page }) => {
const dashboardPage = new DashboardPage(page);
const onboardingPage = new OnboardingPage(page);
await dashboardPage.mockConfigFlags(["enable-onboarding"]);
await dashboardPage.goToDashboard();
await expect(
page.getByRole("heading", { name: "Help us get to know you" }),

View File

@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test";
import { WasmWorkspacePage, WASM_FLAGS } from "../pages/WasmWorkspacePage";
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
test.beforeEach(async ({ page }) => {
await WasmWorkspacePage.init(page);

View File

@@ -5,7 +5,7 @@ import { WorkspacePage } from "../pages/WorkspacePage";
const timeToWait = 100;
test.beforeEach(async ({ page, context }) => {
await Clipboard.enable(context, Clipboard.Permission.ONLY_WRITE);
await Clipboard.enable(context, Clipboard.Permission.ALL);
await WorkspacePage.init(page);
await WorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-v2"]);
@@ -110,7 +110,7 @@ test("Update an already created text shape by prepending text", async ({
await workspace.textEditor.stopEditing();
});
test("Update an already created text shape by inserting text in between", async ({
test.skip("Update an already created text shape by inserting text in between", async ({
page,
}) => {
const workspace = new WorkspacePage(page, {
@@ -151,7 +151,7 @@ test("Update a new text shape appending text by pasting text", async ({
await workspace.textEditor.stopEditing();
});
test("Update a new text shape prepending text by pasting text", async ({
test.skip("Update a new text shape prepending text by pasting text", async ({
page,
context,
}) => {

View File

@@ -1,12 +1,19 @@
import { test, expect } from "@playwright/test";
import { WorkspacePage } from "../pages/WorkspacePage";
import { WorkspacePage } from "../pages/WorkspacePage";
import { BaseWebSocketPage } from "../pages/BaseWebSocketPage";
import { Clipboard } from "../../helpers/Clipboard";
test.beforeEach(async ({ page, context }) => {
await Clipboard.enable(context, Clipboard.Permission.ALL);
test.beforeEach(async ({ page }) => {
await WorkspacePage.init(page);
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-variants.json");
});
test.afterEach(async ({ context }) => {
context.clearPermissions();
});
const setupVariantsFile = async (workspacePage) => {
await workspacePage.setupEmptyFile();
await workspacePage.mockRPC(
@@ -34,9 +41,9 @@ const setupVariantsFileWithVariant = async (workspacePage) => {
await setupVariantsFile(workspacePage);
await workspacePage.clickLeafLayer("Rectangle");
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
await workspacePage.page.waitForTimeout(500);
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
await workspacePage.page.waitForTimeout(500);
// We wait until layer-row starts looking like it an component
@@ -156,7 +163,7 @@ test("User duplicates a variant container", async ({ page }) => {
await variant.container.click();
//Duplicate the variant container
await workspacePage.page.keyboard.press("Control+d");
await workspacePage.page.keyboard.press("ControlOrMeta+d");
const variant_original = await findVariant(workspacePage, 1); // On duplicate, the new item is the first
const variant_duplicate = await findVariant(workspacePage, 0);
@@ -169,25 +176,27 @@ test("User duplicates a variant container", async ({ page }) => {
await validateVariant(variant_duplicate);
});
test("User copy paste a variant container", async ({ page }) => {
test("User copy paste a variant container", async ({ page, context }) => {
const workspacePage = new WorkspacePage(page);
// Access to the read/write clipboard necesary for this functionality
await setupVariantsFileWithVariant(workspacePage);
await workspacePage.mockRPC(
/create-file-object-thumbnail.*/,
"workspace/create-file-object-thumbnail.json",
);
const variant = findVariantNoWait(workspacePage, 0);
// await variant.container.waitFor();
// Select the variant container
await variant.container.click();
await workspacePage.page.waitForTimeout(1000);
// Copy the variant container
await workspacePage.page.keyboard.press("Control+c");
await workspacePage.clickLeafLayer("Rectangle");
await workspacePage.copy("keyboard");
// Paste the variant container
await workspacePage.clickAt(400, 400);
await workspacePage.page.keyboard.press("Control+v");
await workspacePage.paste("keyboard");
const variants = workspacePage.layers.getByText("Rectangle");
await expect(variants).toHaveCount(2);
const variantDuplicate = findVariantNoWait(workspacePage, 0);
const variantOriginal = findVariantNoWait(workspacePage, 1);
@@ -212,18 +221,17 @@ test("User cut paste a variant container", async ({ page }) => {
await variant.container.click();
//Cut the variant container
await workspacePage.page.keyboard.press("Control+x");
await workspacePage.page.waitForTimeout(500);
await workspacePage.cut("keyboard");
//Paste the variant container
await workspacePage.clickAt(500, 500);
await workspacePage.page.keyboard.press("Control+v");
await workspacePage.paste("keyboard");
await workspacePage.page.waitForTimeout(500);
const variantPasted = await findVariant(workspacePage, 0);
// Expand the layers
await variantPasted.container.locator("button").first().click();
await workspacePage.clickToggableLayer("Rectangle");
// The variants are valid
await validateVariant(variantPasted);
@@ -239,27 +247,34 @@ test("User cut paste a variant container into a board, and undo twice", async ({
//Create a board
await workspacePage.boardButton.click();
await workspacePage.clickWithDragViewportAt(500, 500, 100, 100);
// NOTE: this board should not intersect the existing variants, otherwise
// this test is flaky
await workspacePage.clickWithDragViewportAt(200, 200, 100, 100);
await workspacePage.clickAt(495, 495);
const board = await workspacePage.rootShape.locator("Board");
// Select the variant container
await variant.container.click();
// await variant.container.click();
await workspacePage.clickLeafLayer("Rectangle");
//Cut the variant container
await workspacePage.page.keyboard.press("Control+x");
await workspacePage.page.waitForTimeout(500);
await workspacePage.cut("keyboard");
await expect(variant.container).not.toBeVisible();
//Select the board
await workspacePage.clickLeafLayer("Board");
//Paste the variant container inside the board
await workspacePage.page.keyboard.press("Control+v");
await workspacePage.paste("keyboard");
await expect(variant.container).toBeVisible();
//Undo twice
await workspacePage.page.keyboard.press("Control+z");
await workspacePage.page.keyboard.press("Control+z");
await workspacePage.page.waitForTimeout(500);
await workspacePage.page.keyboard.press("ControlOrMeta+z");
await expect(variant.container).not.toBeVisible();
await workspacePage.page.keyboard.press("ControlOrMeta+z");
await expect(variant.container).toBeVisible();
const variantAfterUndo = await findVariant(workspacePage, 0);
@@ -276,12 +291,12 @@ test("User copy paste a variant", async ({ page }) => {
// Select the variant1
await variant.variant1.click();
//Cut the variant
await workspacePage.page.keyboard.press("Control+c");
// Copy the variant
await workspacePage.copy("keyboard");
//Paste the variant
// Paste the variant
await workspacePage.clickAt(500, 500);
await workspacePage.page.keyboard.press("Control+v");
await workspacePage.paste("keyboard");
const copy = await workspacePage.layers
.getByTestId("layer-row")
@@ -302,11 +317,11 @@ test("User cut paste a variant outside the container", async ({ page }) => {
await variant.variant1.click();
//Cut the variant
await workspacePage.page.keyboard.press("Control+x");
await workspacePage.cut("keyboard");
//Paste the variant
await workspacePage.clickAt(500, 500);
await workspacePage.page.keyboard.press("Control+v");
await workspacePage.paste("keyboard");
const component = await workspacePage.layers
.getByTestId("layer-row")
@@ -324,15 +339,11 @@ test("User drag and drop a variant outside the container", async ({ page }) => {
const variant = await findVariant(workspacePage, 0);
// Drag and drop the variant
await workspacePage.clickWithDragViewportAt(350, 400, 0, 200);
// FIXME: to make this test more resilient, we should get the bounding box of the Value 1 variant
// and use it to calculate the target position
await workspacePage.clickWithDragViewportAt(600, 500, 0, 300);
const component = await workspacePage.layers
.getByTestId("layer-row")
.filter({ has: workspacePage.page.getByText("Rectangle / Value 1") })
.filter({ has: workspacePage.page.getByTestId("icon-component") });
//The component exists and is visible
await expect(component).toBeVisible();
await expect(workspacePage.layers.getByText("Rectangle / Value 1")).toBeVisible();
});
test("User cut paste a component inside a variant", async ({ page }) => {
@@ -345,14 +356,14 @@ test("User cut paste a component inside a variant", async ({ page }) => {
await workspacePage.ellipseShapeButton.click();
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
await workspacePage.clickLeafLayer("Ellipse");
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
//Cut the component
await workspacePage.page.keyboard.press("Control+x");
await workspacePage.cut("keyboard");
//Paste the component inside the variant
await variant.container.click();
await workspacePage.page.keyboard.press("Control+v");
await workspacePage.paste("keyboard");
const variant3 = await workspacePage.layers
.getByTestId("layer-row")
@@ -376,7 +387,7 @@ test("User cut paste a component with path inside a variant", async ({
await workspacePage.ellipseShapeButton.click();
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
await workspacePage.clickLeafLayer("Ellipse");
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
//Rename the component
await workspacePage.layers.getByText("Ellipse").dblclick();
@@ -387,11 +398,11 @@ test("User cut paste a component with path inside a variant", async ({
await workspacePage.page.keyboard.press("Enter");
//Cut the component
await workspacePage.page.keyboard.press("Control+x");
await workspacePage.cut("keyboard");
//Paste the component inside the variant
await variant.container.click();
await workspacePage.page.keyboard.press("Control+v");
await workspacePage.paste("keyboard");
const variant3 = await workspacePage.layers
.getByTestId("layer-row")
@@ -415,7 +426,7 @@ test("User drag and drop a component with path inside a variant", async ({
await workspacePage.ellipseShapeButton.click();
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
await workspacePage.clickLeafLayer("Ellipse");
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
//Rename the component
await workspacePage.layers.getByText("Ellipse").dblclick();
@@ -426,7 +437,7 @@ test("User drag and drop a component with path inside a variant", async ({
await workspacePage.page.keyboard.press("Enter");
//Drag and drop the component the component
await workspacePage.clickWithDragViewportAt(510, 510, 0, -200);
await workspacePage.clickWithDragViewportAt(510, 510, 200, 0);
const variant3 = await workspacePage.layers
.getByTestId("layer-row")
@@ -446,8 +457,8 @@ test("User cut paste a variant into another container", async ({ page }) => {
await workspacePage.ellipseShapeButton.click();
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
await workspacePage.clickLeafLayer("Ellipse");
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
const variantOrigin = await findVariantNoWait(workspacePage, 1);
@@ -457,11 +468,11 @@ test("User cut paste a variant into another container", async ({ page }) => {
await variantOrigin.variant1.click();
//Cut the variant
await workspacePage.page.keyboard.press("Control+x");
await workspacePage.cut("keyboard");
//Paste the variant
await workspacePage.layers.getByText("Ellipse").first().click();
await workspacePage.page.keyboard.press("Control+v");
await workspacePage.paste("keyboard");
const variant3 = workspacePage.layers
.getByTestId("layer-row")

View File

@@ -1,13 +1,13 @@
import { test, expect } from "@playwright/test";
import { WorkspacePage } from "../pages/WorkspacePage";
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
import { presenceFixture } from "../../data/workspace/ws-notifications";
test.beforeEach(async ({ page }) => {
await WorkspacePage.init(page);
await WasmWorkspacePage.init(page);
});
test("User loads worskpace with empty file", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.goToWorkspace();
@@ -16,7 +16,7 @@ test("User loads worskpace with empty file", async ({ page }) => {
});
test("User opens a file with a bad page id", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.goToWorkspace({
@@ -29,7 +29,7 @@ test("User opens a file with a bad page id", async ({ page }) => {
test("User receives presence notifications updates in the workspace", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.goToWorkspace();
@@ -41,7 +41,7 @@ test("User receives presence notifications updates in the workspace", async ({
});
test("User draws a rect", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.mockRPC(
"update-file?id=*",
@@ -52,13 +52,12 @@ test("User draws a rect", async ({ page }) => {
await workspacePage.rectShapeButton.click();
await workspacePage.clickWithDragViewportAt(128, 128, 200, 100);
const shape = await workspacePage.rootShape.locator("rect");
await expect(shape).toHaveAttribute("width", "200");
await expect(shape).toHaveAttribute("height", "100");
await workspacePage.hideUI();
await expect(workspacePage.canvas).toHaveScreenshot();
});
test("User makes a group", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.mockRPC(
/get\-file\?/,
@@ -74,14 +73,14 @@ test("User makes a group", async ({ page }) => {
pageId: "6191cd35-bb1f-81f7-8004-7cc63d087375",
});
await workspacePage.clickLeafLayer("Rectangle");
await workspacePage.page.keyboard.press("Control+g");
await workspacePage.page.keyboard.press("ControlOrMeta+g");
await workspacePage.expectSelectedLayer("Group");
});
test("Bug 7654 - Toolbar keeps toggling on and off on spacebar press", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.goToWorkspace();
@@ -91,10 +90,10 @@ test("Bug 7654 - Toolbar keeps toggling on and off on spacebar press", async ({
await workspacePage.expectHiddenToolbarOptions();
});
test("Bug 7525 - User moves a scrollbar and no selciont rectangle appears", async ({
test("Bug 7525 - User moves a scrollbar and no selection rectangle appears", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.mockRPC(
/get\-file\?/,
@@ -110,8 +109,8 @@ test("Bug 7525 - User moves a scrollbar and no selciont rectangle appears", asyn
pageId: "6191cd35-bb1f-81f7-8004-7cc63d087375",
});
// Move created rect to a corner, in orther to get scrollbars
await workspacePage.panOnViewportAt(128, 128, 300, 300);
// Move created rect to a corner, in order to get scrollbars
await workspacePage.panOnViewportAt(128, 128, 600, 600);
// Check scrollbars appear
const horizontalScrollbar = workspacePage.horizontalScrollbar;
@@ -130,7 +129,7 @@ test("Bug 7525 - User moves a scrollbar and no selciont rectangle appears", asyn
test("User adds a library and its automatically selected in the color palette", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.mockRPC(
"link-file-to-library",
@@ -175,7 +174,7 @@ test("User adds a library and its automatically selected in the color palette",
test("Bug 10179 - Drag & drop doesn't add colors to the Recent Colors palette", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.goToWorkspace();
await workspacePage.moveButton.click();
@@ -218,7 +217,7 @@ test("Bug 10179 - Drag & drop doesn't add colors to the Recent Colors palette",
test("Bug 7489 - Workspace-palette items stay hidden when opening with keyboard-shortcut", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.goToWorkspace();
@@ -235,7 +234,7 @@ test("Bug 7489 - Workspace-palette items stay hidden when opening with keyboard-
test("Bug 8784 - Use keyboard arrow to move inside a text input does not change tabs", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.goToWorkspace();
await workspacePage.pageName.click();
@@ -245,7 +244,7 @@ test("Bug 8784 - Use keyboard arrow to move inside a text input does not change
});
test("Bug 9066 - Problem with grid layout", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockRPC(/get\-file\?/, "workspace/get-file-9066.json");
@@ -273,7 +272,7 @@ test("Bug 9066 - Problem with grid layout", async ({ page }) => {
});
test("User have toolbar", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.goToWorkspace();
@@ -282,7 +281,7 @@ test("User have toolbar", async ({ page }) => {
});
test("User have edition menu entries", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.goToWorkspace();
@@ -298,7 +297,7 @@ test("User have edition menu entries", async ({ page }) => {
});
test("Copy/paste properties", async ({ page, context }) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockRPC(
/get\-file\?/,
@@ -355,23 +354,23 @@ test("Copy/paste properties", async ({ page, context }) => {
});
test("[Taiga #9929] Paste text in workspace", async ({ page, context }) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.goToWorkspace();
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await page.evaluate(() => navigator.clipboard.writeText("Lorem ipsum dolor"));
await workspacePage.viewport.click({ button: "right" });
await page.getByText("PasteCtrlV").click();
await page.getByText(/^Paste/i).click();
await workspacePage.viewport
.getByRole("textbox")
.getByText("Lorem ipsum dolor");
});
test("[Taiga #9930] Zoom fit all doesn't fits all", async ({
test("[Taiga #9930] Zoom fit all doesn't fit all shapes", async ({
page,
context,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockRPC(/get\-file\?/, "workspace/get-file-9930.json");
await workspacePage.goToWorkspace({
@@ -379,16 +378,18 @@ test("[Taiga #9930] Zoom fit all doesn't fits all", async ({
pageId: "fb9798e7-a547-80ae-8005-9ffda4a13e2c",
});
const zoom = await page.getByTitle("Zoom");
const zoom = page.getByTitle("Zoom");
await zoom.click();
const zoomIn = await page.getByRole("button", { name: "Zoom in" });
const zoomIn = page.getByRole("button", { name: "Zoom in" });
await zoomIn.click();
await zoomIn.click();
await zoomIn.click();
// Zoom fit all
await page.keyboard.press("Shift+1");
// Select all shapes to display selrect
await workspacePage.page.keyboard.press("ControlOrMeta+a");
const ids = [
"shape-165d1e5a-5873-8010-8005-9ffdbeaeec59",
@@ -410,7 +411,7 @@ test("[Taiga #9930] Zoom fit all doesn't fits all", async ({
const viewportBoundingBox = await workspacePage.viewport.boundingBox();
for (const id of ids) {
const shape = await page.locator(`.ws-shape-wrapper > g#${id}`);
const shape = page.locator(`.viewport-selrect`);
const shapeBoundingBox = await shape.boundingBox();
expect(contains(viewportBoundingBox, shapeBoundingBox)).toBeTruthy();
}
@@ -419,7 +420,7 @@ test("[Taiga #9930] Zoom fit all doesn't fits all", async ({
test("Bug 9877, user navigation to dashboard from header goes to blank page", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.goToWorkspace();
@@ -436,7 +437,7 @@ test("Bug 9877, user navigation to dashboard from header goes to blank page", as
test("Bug 8371 - Flatten option is not visible in context menu", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockGetFile("workspace/get-file-8371.json");
await workspacePage.goToWorkspace({

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<title>WASM + WebGL2 Canvas</title>
<style>
body {
margin: 0;
background: #111;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
overflow: hidden;
}
canvas {
width: 100%;
height: 100%;
position: absolute;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<script type="module">
import initWasmModule from '/js/render-wasm.js';
import {
init, addShapeSolidFill, assignCanvas, hexToU32ARGB, getRandomInt, getRandomColor,
getRandomFloat, useShape, setShapeChildren, setupInteraction, addShapeSolidStrokeFill
} from './js/lib.js';
const canvas = document.getElementById("canvas");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const params = new URLSearchParams(document.location.search);
const shapes = params.get("shapes") || 1000;
initWasmModule().then(Module => {
init(Module);
assignCanvas(canvas);
Module._set_canvas_background(hexToU32ARGB("#FABADA", 1));
Module._init_shapes_pool(shapes + 1);
setupInteraction(canvas);
const children = [];
for (let i = 0; i < shapes; i++) {
const uuid = crypto.randomUUID();
children.push(uuid);
useShape(uuid);
Module._set_parent(0, 0, 0, 0);
Module._set_shape_type(3);
const x1 = getRandomInt(0, canvas.width);
const y1 = getRandomInt(0, canvas.height);
const width = getRandomInt(20, 100);
const height = getRandomInt(20, 100);
Module._set_shape_selrect(x1, y1, x1 + width, y1 + height);
const color = getRandomColor();
const argb = hexToU32ARGB(color, getRandomFloat(0.1, 1.0));
addShapeSolidFill(argb)
Module._add_shape_center_stroke(10, 0, 0, 0);
const argb2 = hexToU32ARGB(color, getRandomFloat(0.1, 1.0));
addShapeSolidStrokeFill(argb2);
// Add shadows
// Shadow 1: drop-shadow, #dedede opacity 0.33, blur 4, spread -2, offsetX 0, offsetY 2
Module._add_shape_shadow(hexToU32ARGB("#dedede", 0.33), 4, -2, 0, 2, 0, false);
// Shadow 2: drop-shadow, #dedede opacity 1, blur 12, spread -8, offsetX 0, offsetY 12
Module._add_shape_shadow(hexToU32ARGB("#dedede", 1), 12, -8, 0, 12, 0, false);
// Shadow 3: inner-shadow, #002046 opacity 0.12, blur 12, spread -8, offsetX 0, offsetY -4
Module._add_shape_shadow(hexToU32ARGB("#002046", 0.12), 12, -8, 0, -4, 1, false);
}
useShape("00000000-0000-0000-0000-000000000000");
setShapeChildren(children);
performance.mark('render:begin');
Module._set_view(1, 0, 0);
Module._render(Date.now());
performance.mark('render:end');
const { duration } = performance.measure('render', 'render:begin', 'render:end');
// alert(`render time: ${duration.toFixed(2)}ms`);
});
</script>
</body>
</html>

View File

@@ -179,6 +179,56 @@
(map #(get objects %))
(reduce get-ignore-tree nil))))
(defn calculate-ignore-tree-wasm
"Retrieves a map with the flag `ignore-geometry?` given a tree of modifiers"
[transforms objects]
(letfn [(get-ignore-tree
([ignore-tree shape]
(let [shape-id (dm/get-prop shape :id)
transformed-shape (gsh/apply-transform shape (get transforms shape-id))
root
(if (:component-root shape)
shape
(ctn/get-component-shape objects shape {:allow-main? true}))
transformed-root
(if (:component-root shape)
transformed-shape
(gsh/apply-transform root (get transforms (:id root))))]
(get-ignore-tree ignore-tree shape transformed-shape root transformed-root)))
([ignore-tree shape root transformed-root]
(let [shape-id (dm/get-prop shape :id)
transformed-shape (gsh/apply-transform shape (get transforms shape-id))]
(get-ignore-tree ignore-tree shape transformed-shape root transformed-root)))
([ignore-tree shape transformed-shape root transformed-root]
(let [shape-id (dm/get-prop shape :id)
ignore-tree
(cond-> ignore-tree
(and (some? root) (ctk/in-component-copy? shape))
(assoc
shape-id
(check-delta shape root transformed-shape transformed-root)))
set-child
(fn [ignore-tree child]
(get-ignore-tree ignore-tree child root transformed-root))]
(->> (:shapes shape)
(map (d/getf objects))
(reduce set-child ignore-tree)))))]
;; we check twice because we want only to search parents of components but once the
;; tree is traversed we only want to process the objects in components
(->> (keys transforms)
(map #(get objects %))
(reduce get-ignore-tree nil))))
(defn assoc-position-data
[shape position-data old-shape]
(let [deltav (gpt/to-vec (gpt/point (:selrect old-shape))
@@ -625,17 +675,6 @@
(let [objects (dsh/lookup-page-objects state)
ignore-tree
(calculate-ignore-tree modif-tree objects)
options
(-> params
(assoc :reg-objects? true)
(assoc :ignore-tree ignore-tree)
;; Attributes that can change in the transform. This
;; way we don't have to check all the attributes
(assoc :attrs transform-attrs))
geometry-entries
(parse-geometry-modifiers modif-tree)
@@ -645,6 +684,17 @@
transforms
(into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?))
ignore-tree
(calculate-ignore-tree-wasm transforms objects)
options
(-> params
(assoc :reg-objects? true)
(assoc :ignore-tree ignore-tree)
;; Attributes that can change in the transform. This
;; way we don't have to check all the attributes
(assoc :attrs transform-attrs))
modif-tree
(propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state))

View File

@@ -104,7 +104,7 @@
(watch [_ state _]
(let [page-id (or page-id (:current-page-id state))
objects (dsh/lookup-page-objects state page-id)
ids (->> ids (filter #(contains? objects %)))]
ids (->> ids (remove uuid/zero?) (filter #(contains? objects %)))]
(if (d/not-empty? ids)
(let [modif-tree (dwm/create-modif-tree ids (ctm/reflow-modifiers))]
(if (features/active-feature? state "render-wasm/v1")

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]
@@ -777,20 +776,7 @@
(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 _]

View File

@@ -10,6 +10,7 @@
This exists to avoid circular deps:
workspace.texts -> workspace.libraries -> workspace.texts"
(:require
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
@@ -17,6 +18,7 @@
[app.main.data.helpers :as dsh]
[app.main.data.workspace.modifiers :as dwm]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.api.fonts :as wasm.fonts]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
@@ -62,6 +64,84 @@
(rx/of (dwm/apply-wasm-modifiers (resize-wasm-text-modifiers shape)))
(rx/empty))))))
(defn resize-wasm-text-debounce-commit
[]
(ptk/reify ::resize-wasm-text-debounce-commit
ptk/WatchEvent
(watch [_ state _]
(let [ids (get state ::resize-wasm-text-debounce-ids)
objects (dsh/lookup-page-objects state)
modifiers
(reduce
(fn [modifiers id]
(let [shape (get objects id)]
(cond-> modifiers
(and (some? shape)
(cfh/text-shape? shape)
(not= :fixed (:grow-type shape)))
(merge (resize-wasm-text-modifiers shape)))))
{}
ids)]
(if (not (empty? modifiers))
(rx/of (dwm/apply-wasm-modifiers modifiers))
(rx/empty))))))
;; This event will debounce the resize events so, if there are many, they
;; are processed at the same time and not one-by-one. This will improve
;; performance because it's better to make only one layout calculation instead
;; of (potentialy) hundreds.
(defn resize-wasm-text-debounce-inner
[id]
(let [cur-event (js/Symbol)]
(ptk/reify ::resize-wasm-text-debounce-inner
ptk/UpdateEvent
(update [_ state]
(-> state
(update ::resize-wasm-text-debounce-ids (fnil conj []) id)
(cond-> (nil? (::resize-wasm-text-debounce-event state))
(assoc ::resize-wasm-text-debounce-event cur-event))))
ptk/WatchEvent
(watch [_ state stream]
(if (= (::resize-wasm-text-debounce-event state) cur-event)
(let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))]
(rx/concat
(rx/merge
(->> stream
(rx/filter (ptk/type? ::resize-wasm-text-debounce-inner))
(rx/debounce 40)
(rx/take 1)
(rx/map #(resize-wasm-text-debounce-commit))
(rx/take-until stopper))
(rx/of (resize-wasm-text-debounce-inner id)))
(rx/of #(dissoc %
::resize-wasm-text-debounce-ids
::resize-wasm-text-debounce-event))))
(rx/empty))))))
(defn resize-wasm-text-debounce
[id]
(ptk/reify ::resize-wasm-text-debounce
ptk/WatchEvent
(watch [_ state _]
(let [page-id (:current-page-id state)
objects (dsh/lookup-page-objects state page-id)
content (dm/get-in objects [id :content])
fonts (wasm.fonts/get-content-fonts content)
fonts-loaded?
(->> fonts
(every?
(fn [font]
(let [font-data (wasm.fonts/make-font-data font)]
(wasm.fonts/font-stored? font-data (:emoji? font-data))))))]
(if (not fonts-loaded?)
(->> (rx/of (resize-wasm-text-debounce id))
(rx/delay 20))
(rx/of (resize-wasm-text-debounce-inner id)))))))
(defn resize-wasm-text-all
"Resize all text shapes (auto-width/auto-height) from a collection of ids."
[ids]

View File

@@ -36,10 +36,12 @@
(defn- hide-popover
[node]
(dom/unset-css-property! node "block-size")
(dom/unset-css-property! node "inset-block-start")
(dom/unset-css-property! node "inset-inline-start")
(.hidePopover ^js node))
(when (and (some? node)
(fn? (.-hidePopover node)))
(dom/unset-css-property! node "block-size")
(dom/unset-css-property! node "inset-block-start")
(dom/unset-css-property! node "inset-inline-start")
(.hidePopover ^js node)))
(defn- calculate-placement-bounding-rect
"Given a placement, calcultates the bounding rect for it taking in

View File

@@ -16,7 +16,7 @@
(def context (mf/create-context nil))
(mf/defc form-input*
[{:keys [name] :rest props}]
[{:keys [name trim] :rest props}]
(let [form (mf/use-ctx context)
input-name name
@@ -33,7 +33,7 @@
(mf/deps input-name)
(fn [event]
(let [value (-> event dom/get-target dom/get-input-value)]
(fm/on-input-change form input-name value true))))
(fm/on-input-change form input-name value trim))))
props
(mf/spread-props props {:on-change on-change

View File

@@ -21,7 +21,7 @@
(def ^:private schema:properties-row
[:map
[:term :string]
[:detail :string]
[:detail {:optional true} [:maybe :string]]
[:property {:optional true} :string] ;; CSS valid property
[:token {:optional true} :any] ;; resolved token object
[:copiable {:optional true} :boolean]])

View File

@@ -78,13 +78,15 @@
(fn []
(close-modals)
;; FIXME: move set-mode to uri?
(st/emit! (dw/set-options-mode :design)
(st/emit! :interrupt
(dw/set-options-mode :design)
(dcm/go-to-dashboard-recent))))
nav-to-project
(mf/use-fn
(mf/deps project-id)
#(st/emit! (dcm/go-to-dashboard-files ::rt/new-window true :project-id project-id)))]
#(st/emit! :interrupt
(dcm/go-to-dashboard-files ::rt/new-window true :project-id project-id)))]
(mf/with-effect [editing?]
(when ^boolean editing?

View File

@@ -345,9 +345,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])
@@ -399,7 +401,8 @@
(dm/fmt "scale(%)" maybe-zoom))}))]
[:g.text-editor {:clip-path (dm/fmt "url(#%)" clip-id)
:transform (dm/str transform)}
:transform (dm/str transform)
:data-testid "text-editor"}
[:defs
[:clipPath {:id clip-id}
[:rect {:x x :y y :width width :height height}]]]

View File

@@ -119,6 +119,9 @@
[:button {:class (stl/css-case
:toggle-content true
:inverse expanded?)
:data-testid "toggle-content"
:aria-expanded expanded?
:aria-labelledby (dm/str "layer-name-" id)
:on-click on-toggle-collapse}
deprecated-icon/arrow])

View File

@@ -108,6 +108,7 @@
:on-blur accept-edit
:on-key-down on-key-down
:auto-focus true
:id (dm/str "layer-name-" shape-id)
:default-value (d/nilv default-value "")}]
[:*
[:span
@@ -118,6 +119,7 @@
:hidden is-hidden
:type-comp type-comp
:type-frame type-frame)
:id (dm/str "layer-name-" shape-id)
:style {"--depth" depth "--parent-size" parent-size}
:ref ref
:on-double-click start-edit}

View File

@@ -590,7 +590,8 @@
:values values}]
[:div {:class (stl/css :rotation)
:title (tr "workspace.options.rotation")}
:title (tr "workspace.options.rotation")
:data-testid "rotation"}
[:span {:class (stl/css :icon)} deprecated-icon/rotation]
[:> deprecated-input/numeric-input*
{:no-validate true

View File

@@ -11,6 +11,7 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.types.color :as cl]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob]
[app.main.data.style-dictionary :as sd]
[app.main.data.tinycolor :as tinycolor]
@@ -51,12 +52,15 @@
;; Both variants provide identical color-picker and text-input behavior, but
;; differ in how they persist the value within the forms nested structure.
(defn- resolve-value
[tokens prev-token token-name value]
(let [token
(let [valid-token-name?
(and (string? token-name)
(re-matches cto/token-name-validation-regex token-name))
token
{:value value
:name (if (str/blank? token-name)
:name (if (or (not valid-token-name?) (str/blank? token-name))
"__PENPOT__TOKEN__NAME__PLACEHOLDER__"
token-name)}

View File

@@ -50,9 +50,13 @@
(defn- resolve-value
[tokens prev-token token-name value]
(let [token
(let [valid-token-name?
(and (string? token-name)
(re-matches cto/token-name-validation-regex token-name))
token
{:value (cto/split-font-family value)
:name (if (str/blank? token-name)
:name (if (or (not valid-token-name?) (str/blank? token-name))
"__PENPOT__TOKEN__NAME__PLACEHOLDER__"
token-name)}

View File

@@ -8,6 +8,7 @@
(:require
[app.common.data :as d]
[app.common.files.tokens :as cft]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob]
[app.main.data.style-dictionary :as sd]
[app.main.data.workspace.tokens.format :as dwtf]
@@ -140,9 +141,13 @@
(defn- resolve-value
[tokens prev-token token-name value]
(let [token
(let [valid-token-name?
(and (string? token-name)
(re-matches cto/token-name-validation-regex token-name))
token
{:value value
:name (if (str/blank? token-name)
:name (if (or (not valid-token-name?) (str/blank? token-name))
"__PENPOT__TOKEN__NAME__PLACEHOLDER__"
token-name)}
tokens

View File

@@ -230,6 +230,7 @@
:placeholder (tr "workspace.tokens.enter-token-name" token-title)
:max-length max-input-length
:variant "comfortable"
:trim true
:auto-focus true}]
(when (and warning-name-change? (= action "edit"))

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,21 +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 [point (dom/get-client-position event)]
;; FIXME
(js/console.log (.-x point) (.-y point) event)
(wasm.api/text-editor-set-cursor-from-point (.-x point) (.-y point))))))
(when (and @z?
(not @space?)
(not edition)
@@ -252,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
@@ -382,9 +346,7 @@
(let [last-position (mf/use-var nil)]
(mf/use-fn
(fn [event]
(let [native-event (unchecked-get event "nativeEvent")
off-pt (dom/get-offset-position native-event)
raw-pt (dom/get-client-position event)
(let [raw-pt (dom/get-client-position event)
pt (uwvv/point->viewport raw-pt)
;; We calculate the delta because Safari's MouseEvent.movementX/Y drop
@@ -393,8 +355,6 @@
(gpt/subtract raw-pt @last-position)
(gpt/point 0 0))]
(wasm.api/text-editor-testing-coords (.-x off-pt) (.-y off-pt))
(rx/push! move-stream pt)
(reset! last-position raw-pt)
(st/emit! (mse/->PointerEvent :delta delta

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

@@ -75,32 +75,31 @@
[{:keys [points] :as shape} zoom grid-edition?]
(let [leftmost (->> points (reduce left?))
topmost (->> points (remove #{leftmost}) (reduce top?))
rightmost (->> points (remove #{leftmost topmost}) (reduce right?))
rightmost (->> points (remove #{leftmost topmost}) (reduce right?))]
(when (and (some? leftmost) (some? topmost) (some? rightmost))
(let [left-top (gpt/to-vec leftmost topmost)
left-top-angle (gpt/angle left-top)
left-top (gpt/to-vec leftmost topmost)
left-top-angle (gpt/angle left-top)
top-right (gpt/to-vec topmost rightmost)
top-right-angle (gpt/angle top-right)
top-right (gpt/to-vec topmost rightmost)
top-right-angle (gpt/angle top-right)
;; Choose the position that creates the less angle between left-side and top-side
[label-pos angle h-pos v-pos]
(if (< (mth/abs left-top-angle) (mth/abs top-right-angle))
[leftmost left-top-angle left-top (gpt/perpendicular left-top)]
[topmost top-right-angle top-right (gpt/perpendicular top-right)])
;; Choose the position that creates the less angle between left-side and top-side
[label-pos angle h-pos v-pos]
(if (< (mth/abs left-top-angle) (mth/abs top-right-angle))
[leftmost left-top-angle left-top (gpt/perpendicular left-top)]
[topmost top-right-angle top-right (gpt/perpendicular top-right)])
delta-x (if grid-edition? 40 0)
delta-y (if grid-edition? 50 10)
delta-x (if grid-edition? 40 0)
delta-y (if grid-edition? 50 10)
label-pos
(-> label-pos
(gpt/subtract (gpt/scale (gpt/unit v-pos) (/ delta-y zoom)))
(gpt/subtract (gpt/scale (gpt/unit h-pos) (/ delta-x zoom))))]
(dm/fmt "rotate(% %,%) scale(%, %) translate(%, %)"
;; rotate
angle (:x label-pos) (:y label-pos)
;; scale
(/ 1 zoom) (/ 1 zoom)
;; translate
(* zoom (:x label-pos)) (* zoom (:y label-pos)))))
label-pos
(-> label-pos
(gpt/subtract (gpt/scale (gpt/unit v-pos) (/ delta-y zoom)))
(gpt/subtract (gpt/scale (gpt/unit h-pos) (/ delta-x zoom))))]
(dm/fmt "rotate(% %,%) scale(%, %) translate(%, %)"
;; rotate
angle (:x label-pos) (:y label-pos)
;; scale
(/ 1 zoom) (/ 1 zoom)
;; translate
(* zoom (:x label-pos)) (* zoom (:y label-pos)))))))

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

@@ -26,6 +26,7 @@
[app.main.data.workspace.groups :as dwg]
[app.main.data.workspace.media :as dwm]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.fonts :refer [fetch-font-css]]
[app.main.router :as rt]
[app.main.store :as st]
@@ -338,9 +339,14 @@
:else
(let [page (dsh/lookup-page @st/state)
shape (-> (cts/setup-shape {:type :text :x 0 :y 0 :grow-type :auto-width})
(update :content txt/change-text text)
(assoc :position-data nil))
shape (-> (cts/setup-shape {:type :text
:x 0 :y 0
:width 1 :height 1
:grow-type :auto-width})
(update :content txt/change-text text
;; Text should be given a color by default
{:fills [{:fill-color "#000000" :fill-opacity 1}]})
(dissoc :position-data))
changes
(-> (cb/empty-changes)
@@ -348,7 +354,9 @@
(cb/with-objects (:objects page))
(cb/add-object shape))]
(st/emit! (ch/commit-changes changes))
(st/emit!
(ch/commit-changes changes)
(dwwt/resize-wasm-text-debounce (:id shape)))
(shape/shape-proxy plugin-id (:id shape)))))
:createShapeFromSvg

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,19 +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-testing-coords text-editor/text-editor-testing-coords)
(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))
@@ -123,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"))))
@@ -226,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?
@@ -236,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 shape-id 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)]
@@ -920,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)))
@@ -1111,7 +1066,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]
@@ -1586,41 +1541,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)))
@@ -1654,4 +1601,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

@@ -97,9 +97,8 @@
;; IMPORTANT: Only TTF fonts can be stored.
(defn- store-font-buffer
[shape-id font-data font-array-buffer emoji? fallback?]
[font-data font-array-buffer emoji? fallback?]
(let [font-id-buffer (:family-id-buffer font-data)
shape-id-buffer (uuid/get-u32 shape-id)
size (.-byteLength font-array-buffer)
ptr (h/call wasm/internal-module "_alloc_bytes" size)
heap (gobj/get ^js wasm/internal-module "HEAPU8")
@@ -107,10 +106,6 @@
(.set mem (js/Uint8Array. font-array-buffer))
(h/call wasm/internal-module "_store_font"
(aget shape-id-buffer 0)
(aget shape-id-buffer 1)
(aget shape-id-buffer 2)
(aget shape-id-buffer 3)
(aget font-id-buffer 0)
(aget font-id-buffer 1)
(aget font-id-buffer 2)
@@ -119,24 +114,31 @@
(:style font-data)
emoji?
fallback?)
(update-text-layout shape-id)
true))
;; This variable will store the fonts that are currently being fetched
;; so we don't fetch more than once the same font
(def fetching (atom #{}))
(defn- fetch-font
[shape-id font-data font-url emoji? fallback?]
{:key font-url
:callback #(->> (http/send! {:method :get
:uri font-url
:response-type :buffer})
(rx/map (fn [{:keys [body]}]
(store-font-buffer shape-id font-data body emoji? fallback?)))
(rx/catch (fn [cause]
(log/error :hint "Could not fetch font"
:font-url font-url
:cause cause)
(rx/empty))))})
[font-data font-url emoji? fallback?]
(when-not (contains? @fetching font-url)
(swap! fetching conj font-url)
{:key font-url
:callback
(fn []
(->> (http/send! {:method :get
:uri font-url
:response-type :buffer})
(rx/map (fn [{:keys [body]}]
(swap! fetching disj font-url)
(store-font-buffer font-data body emoji? fallback?)))
(rx/catch (fn [cause]
(swap! fetching disj font-url)
(log/error :hint "Could not fetch font"
:font-url font-url
:cause cause)
(rx/empty)))))}))
(defn- google-font-ttf-url
[font-id font-variant-id font-weight font-style]
@@ -155,22 +157,31 @@
:builtin
(dm/str (u/join cf/public-uri "fonts/" asset-id))))
(defn font-stored?
[font-data emoji?]
(when-let [id-buffer (uuid/get-u32 (:wasm-id font-data))]
(not= 0 (h/call wasm/internal-module "_is_font_uploaded"
(aget id-buffer 0)
(aget id-buffer 1)
(aget id-buffer 2)
(aget id-buffer 3)
(:weight font-data)
(:style font-data)
emoji?))))
(defn- store-font-id
[shape-id font-data asset-id emoji? fallback?]
[font-data asset-id emoji? fallback?]
(when asset-id
(let [uri (font-id->ttf-url (:font-id font-data) asset-id (:font-variant-id font-data) (:weight font-data) (:style-name font-data))
(let [uri (font-id->ttf-url
(:font-id font-data) asset-id
(:font-variant-id font-data)
(:weight font-data)
(:style-name font-data))
id-buffer (uuid/get-u32 (:wasm-id font-data))
font-data (assoc font-data :family-id-buffer id-buffer)
font-stored? (not= 0 (h/call wasm/internal-module "_is_font_uploaded"
(aget id-buffer 0)
(aget id-buffer 1)
(aget id-buffer 2)
(aget id-buffer 3)
(:weight font-data)
(:style font-data)
emoji?))]
font-stored? (font-stored? font-data emoji?)]
(when-not font-stored?
(fetch-font shape-id font-data uri emoji? fallback?)))))
(fetch-font font-data uri emoji? fallback?)))))
(defn serialize-font-style
[font-style]
@@ -280,8 +291,8 @@
"regular"
font-variant-id))
(defn store-font
[shape-id font]
(defn make-font-data
[font]
(let [font-id (get font :font-id)
font-variant-id (get font :font-variant-id)
normalized-variant-id (when font-variant-id
@@ -301,14 +312,21 @@
(str/includes? raw-weight "italic") "italic"
:else font-style-fallback)
variant-id (or (:id font-data) normalized-variant-id)
asset-id (font-id->asset-id font-id variant-id raw-weight style)
font-data {:wasm-id wasm-id
:font-id font-id
:font-variant-id variant-id
:style (serialize-font-style style)
:style-name style
:weight weight}]
(store-font-id shape-id font-data asset-id emoji? fallback?)))
asset-id (font-id->asset-id font-id variant-id raw-weight style)]
{:wasm-id wasm-id
:font-id font-id
:font-variant-id variant-id
:style (serialize-font-style style)
:style-name style
:weight weight
:emoji? emoji?
:fallbck? fallback?
:asset-id asset-id}))
(defn store-font
[font]
(let [{:keys [asset-id emoji? fallback?] :as font-data} (make-font-data font)]
(store-font-id font-data asset-id emoji? fallback?)))
;; FIXME: This is a temporary function to load the fallback fonts for the editor.
;; Once we render the editor content within wasm, we can remove this function.
@@ -341,8 +359,8 @@
#{}))))
(defn store-fonts
[shape-id fonts]
(keep (fn [font] (store-font shape-id font)) fonts))
[fonts]
(keep (fn [font] (store-font font)) fonts))
(defn add-emoji-font
[fonts]

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,305 +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-testing-coords
[x y]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_testing_coords" x y)))
(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

@@ -106,17 +106,20 @@
(defn stop-propagation
[^js event]
(when event
(when (and (some? event)
(fn? (.-stopPropagation event)))
(.stopPropagation event)))
(defn stop-immediate-propagation
[^js event]
(when event
(when (and (some? event)
(fn? (.-stopImmediatePropagation event)))
(.stopImmediatePropagation event)))
(defn prevent-default
[^js event]
(when event
(when (and (some? event)
(fn? (.-preventDefault event)))
(.preventDefault event)))
(defn get-target

View File

@@ -7,15 +7,16 @@
(ns app.util.keyboard
(:require
[app.config :as cfg]
[app.util.dom :as dom]
[cuerdas.core :as str]))
(defrecord KeyboardEvent [type key shift ctrl alt meta mod editing native-event]
Object
(preventDefault [_]
(.preventDefault native-event))
(dom/prevent-default native-event))
(stopPropagation [_]
(.stopPropagation native-event)))
(dom/stop-propagation native-event)))
(defn keyboard-event?
[o]

View File

@@ -405,12 +405,8 @@ export class TextEditor extends EventTarget {
if (e.inputType in commands) {
const command = commands[e.inputType];
if (!this.#selectionController.startMutation()) {
return;
}
command(e, this, this.#selectionController);
const mutations = this.#selectionController.endMutation();
this.#notifyLayout(LayoutType.FULL, mutations);
this.#notifyLayout(LayoutType.FULL);
}
};
@@ -456,19 +452,12 @@ export class TextEditor extends EventTarget {
if ((e.ctrlKey || e.metaKey) && e.key === "Backspace") {
e.preventDefault();
if (!this.#selectionController.startMutation()) {
return;
}
if (this.#selectionController.isCollapsed) {
this.#selectionController.removeWordBackward();
} else {
this.#selectionController.removeSelected();
}
const mutations = this.#selectionController.endMutation();
this.#notifyLayout(LayoutType.FULL, mutations);
this.#notifyLayout(LayoutType.FULL);
}
};
@@ -476,14 +465,12 @@ export class TextEditor extends EventTarget {
* Notifies that the edited texts needs layout.
*
* @param {'full'|'partial'} type
* @param {CommandMutations} mutations
*/
#notifyLayout(type = LayoutType.FULL, mutations) {
#notifyLayout(type = LayoutType.FULL) {
this.dispatchEvent(
new CustomEvent("needslayout", {
detail: {
type: type,
mutations: mutations,
},
}),
);
@@ -630,10 +617,8 @@ export class TextEditor extends EventTarget {
* @returns {TextEditor}
*/
applyStylesToSelection(styles) {
this.#selectionController.startMutation();
this.#selectionController.applyStyles(styles);
const mutations = this.#selectionController.endMutation();
this.#notifyLayout(LayoutType.FULL, mutations);
this.#notifyLayout(LayoutType.FULL);
this.#changeController.notifyImmediately();
return this;
}

View File

@@ -1,66 +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
*/
/**
* Command mutations
*/
export class CommandMutations {
#added = new Set();
#removed = new Set();
#updated = new Set();
constructor(added, updated, removed) {
if (added && Array.isArray(added)) this.#added = new Set(added);
if (updated && Array.isArray(updated)) this.#updated = new Set(updated);
if (removed && Array.isArray(removed)) this.#removed = new Set(removed);
}
get added() {
return this.#added;
}
get removed() {
return this.#removed;
}
get updated() {
return this.#updated;
}
clear() {
this.#added.clear();
this.#removed.clear();
this.#updated.clear();
}
dispose() {
this.#added.clear();
this.#added = null;
this.#removed.clear();
this.#removed = null;
this.#updated.clear();
this.#updated = null;
}
add(node) {
this.#added.add(node);
return this;
}
remove(node) {
this.#removed.add(node);
return this;
}
update(node) {
this.#updated.add(node);
return this;
}
}
export default CommandMutations;

View File

@@ -1,71 +0,0 @@
import { describe, test, expect } from "vitest";
import CommandMutations from "./CommandMutations.js";
describe("CommandMutations", () => {
test("should create a new CommandMutations", () => {
const mutations = new CommandMutations();
expect(mutations).toHaveProperty("added");
expect(mutations).toHaveProperty("updated");
expect(mutations).toHaveProperty("removed");
});
test("should create an initialized new CommandMutations", () => {
const mutations = new CommandMutations([1], [2], [3]);
expect(mutations.added.size).toBe(1);
expect(mutations.updated.size).toBe(1);
expect(mutations.removed.size).toBe(1);
expect(mutations.added.has(1)).toBe(true);
expect(mutations.updated.has(2)).toBe(true);
expect(mutations.removed.has(3)).toBe(true);
});
test("should add an added node to a CommandMutations", () => {
const mutations = new CommandMutations();
mutations.add(1);
expect(mutations.added.has(1)).toBe(true);
});
test("should add an updated node to a CommandMutations", () => {
const mutations = new CommandMutations();
mutations.update(1);
expect(mutations.updated.has(1)).toBe(true);
});
test("should add an removed node to a CommandMutations", () => {
const mutations = new CommandMutations();
mutations.remove(1);
expect(mutations.removed.has(1)).toBe(true);
});
test("should clear a CommandMutations", () => {
const mutations = new CommandMutations();
mutations.add(1);
mutations.update(2);
mutations.remove(3);
expect(mutations.added.has(1)).toBe(true);
expect(mutations.added.size).toBe(1);
expect(mutations.updated.has(2)).toBe(true);
expect(mutations.updated.size).toBe(1);
expect(mutations.removed.has(3)).toBe(true);
expect(mutations.removed.size).toBe(1);
mutations.clear();
expect(mutations.added.size).toBe(0);
expect(mutations.added.has(1)).toBe(false);
expect(mutations.updated.size).toBe(0);
expect(mutations.updated.has(1)).toBe(false);
expect(mutations.removed.size).toBe(0);
expect(mutations.removed.has(1)).toBe(false);
});
test("should dispose a CommandMutations", () => {
const mutations = new CommandMutations();
mutations.add(1);
mutations.update(2);
mutations.remove(3);
mutations.dispose();
expect(mutations.added).toBe(null);
expect(mutations.updated).toBe(null);
expect(mutations.removed).toBe(null);
});
});

View File

@@ -1,5 +1,5 @@
import { describe, test, expect } from "vitest";
import { insertInto, removeBackward, removeForward, replaceWith } from "./Text";
import { insertInto, removeSlice, removeBackward, removeForward, removeWordBackward, replaceWith, findPreviousWordBoundary } from "./Text";
describe("Text", () => {
test("* should throw when passed wrong parameters", () => {
@@ -51,4 +51,23 @@ describe("Text", () => {
test("`removeForward` should remove string forward from offset 6", () => {
expect(removeForward("Hello, World!", 6)).toBe("Hello,World!");
});
test("`removeSlice` should remove a part of a text", () => {
expect(removeSlice("Hello, World!", 7, 12)).toBe("Hello, !");
});
test("`findPreviousWordBoundary` edge cases", () => {
expect(findPreviousWordBoundary(null)).toBe(0);
expect(findPreviousWordBoundary("Hello, World!", 0)).toBe(0);
expect(findPreviousWordBoundary(" Hello, World!", 3)).toBe(0);
})
test("`removeWordBackward` with no text should return an empty string", () => {
expect(removeWordBackward(null, 0)).toBe("");
});
test("`removeWordBackward` should remove a word backward", () => {
expect(removeWordBackward("Hello, World!", 13)).toBe("Hello, World");
expect(removeWordBackward("Hello, World", 12)).toBe("Hello, ");
});
});

View File

@@ -2,7 +2,7 @@ import { describe, test, expect } from "vitest";
import { getFills } from "./Color.js";
/* @vitest-environment jsdom */
describe("Color", () => {
describe.skip("Color", () => {
test("getFills", () => {
expect(getFills("#aa0000")).toBe(
'[["^ ","~:fill-color","#aa0000","~:fill-opacity",1]]',

View File

@@ -49,7 +49,6 @@ import {
} from "../content/dom/TextNode.js";
import TextNodeIterator from "../content/dom/TextNodeIterator.js";
import TextEditor from "../TextEditor.js";
import CommandMutations from "../commands/CommandMutations.js";
import { isRoot, setRootStyles } from "../content/dom/Root.js";
import { SelectionDirection } from "./SelectionDirection.js";
import { SafeGuard } from "./SafeGuard.js";
@@ -145,13 +144,6 @@ export class SelectionController extends EventTarget {
*/
#debug = null;
/**
* Command Mutations.
*
* @type {CommandMutations}
*/
#mutations = new CommandMutations();
/**
* Style defaults.
*
@@ -449,14 +441,14 @@ export class SelectionController extends EventTarget {
dispose() {
document.removeEventListener("selectionchange", this.#onSelectionChange);
this.#textEditor = null;
this.#currentStyle = null;
this.#options = null;
this.#ranges.clear();
this.#ranges = null;
this.#range = null;
this.#selection = null;
this.#focusNode = null;
this.#anchorNode = null;
this.#mutations.dispose();
this.#mutations = null;
}
/**
@@ -522,28 +514,6 @@ export class SelectionController extends EventTarget {
return true;
}
/**
* Marks the start of a mutation.
*
* Clears all the mutations kept in CommandMutations.
*
* @returns {boolean}
*/
startMutation() {
this.#mutations.clear();
if (!this.#focusNode) return false;
return true;
}
/**
* Marks the end of a mutation.
*
* @returns {CommandMutations}
*/
endMutation() {
return this.#mutations;
}
/**
* Selects all content.
*
@@ -597,11 +567,18 @@ export class SelectionController extends EventTarget {
* @returns {SelectionController}
*/
cursorToEnd() {
const root = this.#textEditor.root;
const range = document.createRange(); //Create a range (a range is a like the selection but invisible)
range.selectNodeContents(this.#textEditor.element);
range.setStart(root.lastChild.firstChild.firstChild, root.lastChild.firstChild.firstChild?.nodeValue?.length ?? 0);
range.setEnd(root.lastChild.firstChild.firstChild, root.lastChild.firstChild.firstChild?.nodeValue?.length ?? 0);
range.collapse(false);
this.#selection.removeAllRanges();
this.#selection.addRange(range);
this.#updateState();
return this;
}
@@ -1340,7 +1317,6 @@ export class SelectionController extends EventTarget {
if (this.focusNode.nodeValue !== removedData) {
this.focusNode.nodeValue = removedData;
this.#mutations.update(this.focusTextSpan);
}
const paragraph = this.focusParagraph;
@@ -1383,7 +1359,6 @@ export class SelectionController extends EventTarget {
this.focusOffset,
newText,
);
this.#mutations.update(this.focusTextSpan);
return this.collapse(this.focusNode, this.focusOffset + newText.length);
}
@@ -1447,7 +1422,6 @@ export class SelectionController extends EventTarget {
this.#textEditor.root.replaceChildren(newParagraph);
return this.collapse(newTextNode, newText.length + 1);
}
this.#mutations.update(this.focusTextSpan);
return this.collapse(this.focusNode, startOffset + newText.length);
}
@@ -1525,8 +1499,6 @@ export class SelectionController extends EventTarget {
const currentParagraph = this.focusParagraph;
const newParagraph = createEmptyParagraph(this.#currentStyle);
currentParagraph.after(newParagraph);
this.#mutations.update(currentParagraph);
this.#mutations.add(newParagraph);
return this.collapse(newParagraph.firstChild.firstChild, 0);
}
@@ -1537,8 +1509,6 @@ export class SelectionController extends EventTarget {
const currentParagraph = this.focusParagraph;
const newParagraph = createEmptyParagraph(this.#currentStyle);
currentParagraph.before(newParagraph);
this.#mutations.update(currentParagraph);
this.#mutations.add(newParagraph);
return this.collapse(currentParagraph.firstChild.firstChild, 0);
}
@@ -1553,8 +1523,6 @@ export class SelectionController extends EventTarget {
this.#focusOffset,
);
this.focusParagraph.after(newParagraph);
this.#mutations.update(currentParagraph);
this.#mutations.add(newParagraph);
return this.collapse(newParagraph.firstChild.firstChild, 0);
}
@@ -1586,10 +1554,6 @@ export class SelectionController extends EventTarget {
this.focusOffset,
);
currentParagraph.after(newParagraph);
this.#mutations.update(currentParagraph);
this.#mutations.add(newParagraph);
// FIXME: Missing collapse?
}
@@ -1610,7 +1574,6 @@ export class SelectionController extends EventTarget {
const previousOffset = isLineBreak(previousTextSpan.firstChild)
? 0
: previousTextSpan.firstChild.nodeValue?.length || 0;
this.#mutations.remove(paragraphToBeRemoved);
return this.collapse(previousTextSpan.firstChild, previousOffset);
}
@@ -1632,8 +1595,6 @@ export class SelectionController extends EventTarget {
} else {
mergeParagraphs(previousParagraph, currentParagraph);
}
this.#mutations.remove(currentParagraph);
this.#mutations.update(previousParagraph);
return this.collapse(previousTextSpan.firstChild, previousOffset);
}
@@ -1647,8 +1608,6 @@ export class SelectionController extends EventTarget {
return;
}
mergeParagraphs(this.focusParagraph, nextParagraph);
this.#mutations.update(currentParagraph);
this.#mutations.remove(nextParagraph);
// FIXME: Missing collapse?
}
@@ -1665,7 +1624,6 @@ export class SelectionController extends EventTarget {
paragraphToBeRemoved.remove();
const nextTextSpan = nextParagraph.firstChild;
const nextOffset = this.focusOffset;
this.#mutations.remove(paragraphToBeRemoved);
return this.collapse(nextTextSpan.firstChild, nextOffset);
}
@@ -1680,7 +1638,6 @@ export class SelectionController extends EventTarget {
for (const textSpan of affectedTextSpans) {
if (textSpan.textContent === "") {
textSpan.remove();
this.#mutations.remove(textSpan);
}
}
@@ -1688,7 +1645,6 @@ export class SelectionController extends EventTarget {
for (const paragraph of affectedParagraphs) {
if (paragraph.children.length === 0) {
paragraph.remove();
this.#mutations.remove(paragraph);
}
}
}

View File

@@ -581,6 +581,136 @@ describe("SelectionController", () => {
expect(textEditorMock.root.textContent).toBe("");
});
test("`insertParagraph` should insert a new paragraph in an empty editor", () => {
const textEditorMock = TextEditorMock.createTextEditorMockEmpty();
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(textEditorMock, selection);
focus(
selection,
textEditorMock,
root.firstChild.firstChild.firstChild,
0,
);
selectionController.insertParagraph();
expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.dataset.itype).toBe("root");
expect(textEditorMock.root.children.length).toBe(2);
expect(textEditorMock.root.children.item(0)).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.children.item(0).dataset.itype).toBe("paragraph");
expect(textEditorMock.root.children.item(0).firstChild).toBeInstanceOf(
HTMLSpanElement,
);
expect(textEditorMock.root.children.item(0).firstChild.dataset.itype).toBe("span");
expect(textEditorMock.root.children.item(1)).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.children.item(1).dataset.itype).toBe("paragraph");
expect(textEditorMock.root.children.item(1).firstChild).toBeInstanceOf(
HTMLSpanElement,
);
expect(textEditorMock.root.children.item(1).firstChild.dataset.itype).toBe(
"span",
);
expect(textEditorMock.root.textContent).toBe("");
});
test("`insertParagraph` should insert a new paragraph after a text", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWith([
["Hello, World!"]
]);
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(
textEditorMock,
selection,
);
focus(
selection,
textEditorMock,
root.firstChild.firstChild.firstChild,
"Hello, World!".length
);
selectionController.insertParagraph();
expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.dataset.itype).toBe("root");
expect(textEditorMock.root.children.length).toBe(2);
expect(textEditorMock.root.children.item(0)).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.children.item(0).dataset.itype).toBe(
"paragraph",
);
expect(textEditorMock.root.children.item(0).firstChild).toBeInstanceOf(
HTMLSpanElement,
);
expect(textEditorMock.root.children.item(0).firstChild.dataset.itype).toBe(
"span",
);
expect(textEditorMock.root.children.item(0).firstChild.textContent).toBe(
"Hello, World!",
);
expect(textEditorMock.root.children.item(1)).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.children.item(1).dataset.itype).toBe(
"paragraph",
);
expect(textEditorMock.root.children.item(1).firstChild).toBeInstanceOf(
HTMLSpanElement,
);
expect(textEditorMock.root.children.item(1).firstChild.dataset.itype).toBe(
"span",
);
expect(textEditorMock.root.children.item(1).firstChild.firstChild).toBeInstanceOf(
HTMLBRElement,
);
expect(textEditorMock.root.textContent).toBe("Hello, World!");
});
test("`insertParagraph` should insert a new paragraph before a text", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWith([
["Hello, World!"],
]);
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(
textEditorMock,
selection,
);
focus(
selection,
textEditorMock,
root.firstChild.firstChild.firstChild,
0,
);
selectionController.insertParagraph();
expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.dataset.itype).toBe("root");
expect(textEditorMock.root.children.length).toBe(2);
expect(textEditorMock.root.children.item(0)).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.children.item(0).dataset.itype).toBe(
"paragraph",
);
expect(textEditorMock.root.children.item(0).firstChild).toBeInstanceOf(
HTMLSpanElement,
);
expect(textEditorMock.root.children.item(0).firstChild.dataset.itype).toBe(
"span",
);
expect(textEditorMock.root.children.item(0).firstChild.firstChild).toBeInstanceOf(
HTMLBRElement,
);
expect(textEditorMock.root.children.item(1)).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.children.item(1).dataset.itype).toBe(
"paragraph",
);
expect(textEditorMock.root.children.item(1).firstChild).toBeInstanceOf(
HTMLSpanElement,
);
expect(textEditorMock.root.children.item(1).firstChild.dataset.itype).toBe(
"span",
);
expect(textEditorMock.root.children.item(1).firstChild.textContent).toBe(
"Hello, World!",
);
expect(textEditorMock.root.textContent).toBe("Hello, World!");
});
test("`mergeBackwardParagraph` should merge two paragraphs in backward direction (backspace)", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWith([
["Hello, "],
@@ -1027,7 +1157,7 @@ describe("SelectionController", () => {
);
});
test.skip("`removeSelected` multiple paragraphs", () => {
test("`removeSelected` multiple paragraphs", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWith([
["Hello, "],
["\n"],
@@ -1392,7 +1522,10 @@ describe("SelectionController", () => {
root.firstChild.lastChild.firstChild.nodeValue.length - 3,
);
selectionController.applyStyles({
"font-family": "Montserrat, sans-serif",
"font-weight": "bold",
"--fills":
'[["^ ","~:fill-color","#000000","~:fill-opacity",1],["^ ","~:fill-color","#aa0000","~:fill-opacity",1]]',
});
expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.children.length).toBe(1);
@@ -1492,4 +1625,68 @@ describe("SelectionController", () => {
"ld!",
);
});
test("`selectAll` should select everything", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraphWith(["Hello, "], {
"font-style": "italic",
}),
createParagraphWith(["World!"], {
"font-style": "oblique",
}),
]);
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(textEditorMock, selection);
textEditorMock.element.focus();
selectionController.selectAll();
expect(selectionController.anchorNode).toBe(
root.firstChild.firstChild.firstChild
);
expect(selectionController.focusNode).toBe(
root.lastChild.firstChild.firstChild,
);
});
test("`cursorToEnd` should move cursor to the end", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraphWith(["Hello, "], {
"font-style": "italic",
}),
createParagraphWith(["World!"], {
"font-style": "oblique",
}),
]);
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(textEditorMock, selection);
textEditorMock.element.focus();
selectionController.cursorToEnd();
expect(selectionController.focusNode).toBe(root.lastChild.firstChild.firstChild);
expect(selectionController.focusAtEnd).toBeTruthy();
})
test("`dispose` should release every held reference", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraphWith(["Hello, "], {
"font-style": "italic",
}),
createParagraphWith(["World!"], {
"font-style": "oblique",
}),
]);
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(textEditorMock, selection);
focus(
selection,
textEditorMock,
root.firstChild.firstChild.firstChild,
0
);
selectionController.dispose();
expect(selectionController.selection).toBe(null);
expect(selectionController.currentStyle).toBe(null);
expect(selectionController.options).toBe(null);
});
});

View File

@@ -7,7 +7,7 @@ export DEVENV_PNAME="penpotdev";
export CURRENT_USER_ID=$(id -u);
export CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD);
export IMAGEMAGICK_VERSION=7.1.2-0
export IMAGEMAGICK_VERSION=7.1.2-13
# Safe directory to avoid ownership errors with Git
git config --global --add safe.directory /home/penpot/penpot || true

11
mcp/.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
.idea
node_modules
dist
*.bak
*.orig
temp
*.tsbuildinfo
# Log files
logs/
*.log

7
mcp/.prettierignore Normal file
View File

@@ -0,0 +1,7 @@
*.md
*.json
python-scripts/
.serena/
# auto-generated files
mcp-server/data/api_types.yml

20
mcp/.prettierrc Normal file
View File

@@ -0,0 +1,20 @@
{
"tabWidth": 4,
"overrides": [
{
"files": "*.yml",
"options": {
"tabWidth": 2
}
}
],
"useTabs": false,
"semi": true,
"singleQuote": false,
"quoteProps": "as-needed",
"trailingComma": "es5",
"bracketSpacing": true,
"arrowParens": "always",
"printWidth": 120,
"endOfLine": "auto"
}

1
mcp/.serena/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/cache

View File

@@ -0,0 +1,25 @@
# Code Style and Conventions
## General Principles
- **Object-Oriented Design**: VERY IMPORTANT: Use idiomatic, object-oriented style with explicit abstractions
- **Strategy Pattern**: Prefer explicitly typed interfaces over bare functions for non-trivial functionality
- **Clean Architecture**: Tools implement a common interface for consistent registration and execution
## TypeScript Configuration
- **Strict Mode**: All strict TypeScript options enabled
- **Target**: ES2022
- **Module System**: CommonJS
- **Declaration Files**: Generated with source maps
## Naming Conventions
- **Classes**: PascalCase (e.g., `ExeceuteCodeTool`, `PenpotMcpServer`)
- **Interfaces**: PascalCase (e.g., `Tool`)
- **Methods**: camelCase (e.g., `execute`, `registerTools`)
- **Constants**: camelCase for readonly properties (e.g., `definition`)
- **Files**: PascalCase for classes (e.g., `ExecuteCodeTool.ts`)
## Documentation Style
- **JSDoc**: Use comprehensive JSDoc comments for classes, methods, and interfaces
- **Description Format**: Initial elliptical phrase that defines *what* it is, followed by details
- **Comment Style**: VERY IMPORTANT: Start with lowercase for comments of code blocks (unless lengthy explanation with multiple sentences)

View File

@@ -0,0 +1,91 @@
# Penpot MCP Project Overview - Updated
## Purpose
This project is a Model Context Protocol (MCP) server for Penpot integration. It provides a TypeScript-based server that can be used to extend Penpot's functionality through custom tools with bidirectional WebSocket communication.
## Tech Stack
- **Language**: TypeScript
- **Runtime**: Node.js
- **Framework**: MCP SDK (@modelcontextprotocol/sdk)
- **Build Tool**: TypeScript Compiler (tsc) + esbuild
- **Package Manager**: pnpm
- **WebSocket**: ws library for real-time communication
## Project Structure
```
penpot-mcp/
├── common/ # Shared type definitions
│ ├── src/
│ │ ├── index.ts # Exports for shared types
│ │ └── types.ts # PluginTaskResult, request/response interfaces
│ └── package.json # @penpot-mcp/common package
├── mcp-server/ # Main MCP server implementation
│ ├── src/
│ │ ├── index.ts # Main server entry point
│ │ ├── PenpotMcpServer.ts # Enhanced with request/response correlation
│ │ ├── PluginTask.ts # Now supports result promises
│ │ ├── tasks/ # PluginTask implementations
│ │ └── tools/ # Tool implementations
│ └── package.json # Includes @penpot-mcp/common dependency
├── penpot-plugin/ # Penpot plugin with response capability
│ ├── src/
│ │ ├── main.ts # Enhanced WebSocket handling with response forwarding
│ │ └── plugin.ts # Now sends task responses back to server
│ └── package.json # Includes @penpot-mcp/common dependency
└── prepare-api-docs # Python project for the generation of API docs
```
## Key Tasks
### Adding a new Tool
1. Implement the tool class in `mcp-server/src/tools/` following the `Tool` interface.
IMPORTANT: Do not catch any exceptions in the `executeCore` method. Let them propagate to be handled centrally.
2. Register the tool in `PenpotMcpServer`.
Look at `PrintTextTool` as an example.
Many tools are linked to tasks that are handled in the plugin, i.e. they have an associated `PluginTask` implementation in `mcp-server/src/tasks/`.
### Adding a new PluginTask
1. Implement the input data interface for the task in `common/src/types.ts`.
2. Implement the `PluginTask` class in `mcp-server/src/tasks/`.
3. Implement the corresponding task handler class in the plugin (`penpot-plugin/src/task-handlers/`).
* In the success case, call `task.sendSuccess`.
* In the failure case, just throw an exception, which will be handled centrally!
* Look at `PrintTextTaskHandler` as an example.
4. Register the task handler in `penpot-plugin/src/plugin.ts` in the `taskHandlers` list.
## Key Components
### Enhanced WebSocket Protocol
- **Request Format**: `{id: string, task: string, params: any}`
- **Response Format**: `{id: string, result: {success: boolean, error?: string, data?: any}}`
- **Request/Response Correlation**: Using unique UUIDs for task tracking
- **Timeout Handling**: 30-second timeout with automatic cleanup
- **Type Safety**: Shared definitions via @penpot-mcp/common package
### Core Classes
- **PenpotMcpServer**: Enhanced with pending task tracking and response handling
- **PluginTask**: Now creates result promises that resolve when plugin responds
- **Tool implementations**: Now properly await task completion and report results
- **Plugin handlers**: Send structured responses back to server
### New Features
1. **Bidirectional Communication**: Plugin now responds with success/failure status
2. **Task Result Promises**: Every executePluginTask() sets and returns a promise
3. **Error Reporting**: Failed tasks properly report error messages to tools
4. **Shared Type Safety**: Common package ensures consistency across projects
5. **Timeout Protection**: Tasks don't hang indefinitely (30s limit)
6. **Request Correlation**: Unique IDs match requests to responses
## Task Flow
```
LLM Tool Call → MCP Server → WebSocket (Request) → Plugin → Penpot API
↑ ↓
Tool Response ← MCP Server ← WebSocket (Response) ← Plugin Result
```

View File

@@ -0,0 +1,70 @@
# Suggested Commands
## Development Commands
```bash
# Navigate to MCP server directory
cd penpot/mcp/server
# Install dependencies
pnpm install
# Build the TypeScript project
pnpm run build
# Start the server (production)
pnpm run start
# Start the server in development mode
npm run start:dev
```
## Testing and Development
```bash
# Run TypeScript compiler in watch mode
pnpx tsc --watch
# Check TypeScript compilation without emitting files
pnpx tsc --noEmit
```
## Windows-Specific Commands
```cmd
# Directory navigation
cd penpot/mcp/server
dir # List directory contents
type package.json # Display file contents
# Git operations
git status
git add .
git commit -m "message"
git push
# File operations
copy src\file.ts backup\file.ts # Copy files
del dist\* # Delete files
mkdir new-directory # Create directory
rmdir /s directory # Remove directory recursively
```
## Project Structure Navigation
```bash
# Key directories
cd penpot/mcp/server/src # Source code
cd penpot/mcp/server/src/tools # Tool implementations
cd penpot/mcp/server/src/interfaces # Type definitions
cd penpot/mcp/server/dist # Compiled output
```
## Common Utilities
```cmd
# Search for text in files
findstr /s /i "HelloWorld" *.ts
# Find files by name
dir /s /b *Tool.ts
# Process management
tasklist | findstr node # Find Node.js processes
taskkill /f /im node.exe # Kill Node.js processes
```

View File

@@ -0,0 +1,56 @@
# Task Completion Guidelines
## After Making Code Changes
### 1. Build and Test
```bash
cd mcp-server
npm run build:full # or npm run build for faster bundling only
```
### 2. Verify TypeScript Compilation
```bash
npx tsc --noEmit
```
### 3. Test the Server
```bash
# Start in development mode to test changes
npm run dev
```
### 4. Code Quality Checks
- Ensure all code follows the established conventions
- Verify JSDoc comments are complete and accurate
- Check that error handling is appropriate
- Use clean imports WITHOUT file extensions (esbuild handles resolution)
- Validate that tool interfaces are properly implemented
### 5. Integration Testing
- Test tool registration in the main server
- Verify MCP protocol compliance
- Ensure tool definitions match implementation
## Before Committing Changes
1. **Build Successfully**: `npm run build:full` completes without errors
2. **No TypeScript Errors**: `npx tsc --noEmit` passes
3. **Documentation Updated**: JSDoc comments reflect changes
4. **Tool Registry Updated**: New tools added to `registerTools()` method
5. **Interface Compliance**: All tools implement the `Tool` interface correctly
## File Organization
- Place new tools in `src/tools/` directory
- Update main server registration in `src/index.ts`
- Follow existing naming conventions
## Common Patterns
- All tools must implement the `Tool` interface
- Use readonly properties for tool definitions
- Include comprehensive error handling
- Follow the established documentation style
- Import WITHOUT file extensions (esbuild resolves them automatically)
## Build System
- Uses esbuild for fast bundling and TypeScript for declarations
- Import statements should omit file extensions entirely
- IDE refactoring is safe - no extension-related build failures

130
mcp/.serena/project.yml Normal file
View File

@@ -0,0 +1,130 @@
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed) on 2025-04-07
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: |
IMPORTANT: You use an idiomatic, object-oriented style.
In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions
rather than mere functions (i.e. use the strategy pattern, for example).
Comments:
When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase
clearly defines *what* it is. Any details then follow in subsequent sentences.
When describing what blocks of code do, you also use an elliptical style and start with a lower-case letter unless
the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is
required for sentences).
# the name by which the project can be referenced within Serena
project_name: "penpot-mcp"
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# This setting can, in turn, be overridden by CLI parameters (--mode).
default_modes:
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: utf-8
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp
# csharp_omnisharp dart elixir elm erlang
# fortran fsharp go groovy haskell
# java julia kotlin lua markdown
# matlab nix pascal perl php
# powershell python python_jedi r rego
# ruby ruby_solargraph rust scala swift
# terraform toml typescript typescript_vts vue
# yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- typescript

291
mcp/README.md Normal file
View File

@@ -0,0 +1,291 @@
![mcp-server-cover-github-1](https://github.com/user-attachments/assets/dcd14e63-fecd-424f-9a50-c1b1eafe2a4f)
# Penpot's Official MCP Server
Penpot integrates a LLM layer built on the Model Context Protocol
(MCP) via Penpot's Plugin API to interact with a Penpot design
file. Penpot's MCP server enables LLMs to perfom data queries,
transformation and creation operations.
Penpot's MCP Server is unlike any other you've seen. You get
design-to- design, code-to-design and design-code supercharged
workflows.
[![Penpot MCP video playlist](https://github.com/user-attachments/assets/204f1d99-ce51-41dd-a5dd-1ef739f8f089)](https://www.youtube.com/playlist?list=PLgcCPfOv5v57SKMuw1NmS0-lkAXevpn10)
## Architecture
The **Penpot MCP Server** exposes tools to AI clients (LLMs), which
support the retrieval of design data as well as the modification and
creation of design elements. The MCP server communicates with Penpot
via the dedicated **Penpot MCP Plugin**,
which connects to the MCP server via WebSocket.
This enables the LLM to carry out tasks in the context of a design file by
executing code that leverages the Penpot Plugin API.
The LLM is free to write and execute arbitrary code snippets
within the Penpot Plugin environment to accomplish its tasks.
![Architecture](resources/architecture.png)
This repository thus contains not only the MCP server implementation itself
but also the supporting Penpot MCP Plugin
(see section [Repository Structure](#repository-structure) below).
## Demonstration
[![Video](https://v32155.1blu.de/penpot/PenpotFest2025_thumbnail.png)](https://v32155.1blu.de/penpot/PenpotFest2025.mp4)
## Usage
To use the Penpot MCP server, you must
* run the MCP server and connect your AI client to it,
* run the web server providing the Penpot MCP plugin, and
* open the Penpot MCP plugin in Penpot and connect it to the MCP server.
Follow the steps below to enable the integration.
### Prerequisites
The project requires [Node.js](https://nodejs.org/) (tested with v22.x
with corepack).
Following the installation of Node.js, the tools `pnpm` and `npx`
should be available in your terminal. For ensure corepack installed
and enabled correctly, just execute the `./scripts/setup`.
It is also required to have `caddy` executeable in the path, it is
used for start a local server for generate types documentation from
the current branch. If you want to run it outside devenv where all
dependencies are already provided, please download caddy from
[here](https://caddyserver.com/download).
You should probably be using penpot devenv, where all this
dependencies are already present and correctly setup. But nothing
prevents you execute this outside of devenv if you satisfy the
specified dependencies.
### 1. Build & Launch the MCP Server and the Plugin Server
If it's your first execution, install the required dependencies:
```shell
cd mcp/
./scripts/setup
```
Then build all components and start the two servers:
```shell
pnpm run bootstrap
```
This bootstrap command will:
* install dependencies for all components (`pnpm -r run install`)
* 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]
> **Browser Connectivity Restrictions**
>
> Starting with Chromium version 142, the private network access (PNA) restrictions have been hardened,
> and when connecting to `localhost` from a web application served from a different origin
> (such as https://design.penpot.app), the connection must explicitly be allowed.
>
> Most Chromium-based browsers (e.g. Chrome, Vivaldi) will display a popup requesting permission
> to access the local network. Be sure to approve the request to allow the connection.
>
> Some browsers take additional security measures, and you may need to disable them.
> For example, in Brave, disable the "Shield" for the Penpot website to allow local network access.
>
> If your browser refuses to connect to the locally served plugin, check its configuration or
> try a different browser (e.g. Firefox) that does not enforce these restrictions.
1. Open Penpot in your browser
2. Navigate to a design file
3. Open the Plugins menu
4. Load the plugin using the development URL (`http://localhost:4400/manifest.json` by default)
5. Open the plugin UI
6. In the plugin UI, click "Connect to MCP server".
The connection status should change from "Not connected" to "Connected to MCP server".
(Check the browser's developer console for WebSocket connection logs.
Check the MCP server terminal for WebSocket connection messages.)
> [!IMPORTANT]
> Do not close the plugin's UI while using the MCP server, as this will close the connection.
### 3. Connect an MCP Client
By default, the server runs on port 4401 and provides:
- **Modern Streamable HTTP endpoint**: `http://localhost:4401/mcp`
- **Legacy SSE endpoint**: `http://localhost:4401/sse`
These endpoints can be used directly by MCP clients that support them.
Simply configure the client to connect the MCP server by providing the respective URL.
When using a client that only supports stdio transport,
a proxy like `mcp-remote` is required.
#### Using a Proxy for stdio Transport
NOTE: only relevant if you are executing this outside of devenv
The `mcp-remote` package can proxy stdio transport to HTTP/SSE,
allowing clients that support only stdio to connect to the MCP server indirectly.
1. Install `mcp-remote` globally if you haven't already:
npm install -g mcp-remote
2. Use `mcp-remote` to provide the launch command for your MCP client:
npx -y mcp-remote http://localhost:4401/sse --allow-http
#### Example: Claude Desktop
For Windows and macOS, there is the official [Claude Desktop app](https://claude.ai/download), which you can use as an MCP client.
For Linux, there is an [unofficial community version](https://github.com/aaddrick/claude-desktop-debian).
Since Claude Desktop natively supports only stdio transport, you will need to use a proxy like `mcp-remote`.
Install it as described above.
To add the server to Claude Desktop's configuration, locate the configuration file (or find it via Menu / File / Settings / Developer):
- **Windows**: `%APPDATA%/Claude/claude_desktop_config.json`
- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
- **Linux**: `~/.config/Claude/claude_desktop_config.json`
Add a `penpot` entry under `mcpServers` with the following content:
```json
{
"mcpServers": {
"penpot": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:4401/sse", "--allow-http"]
}
}
}
```
After updating the configuration file, restart Claude Desktop completely for the changes to take effect.
> [!IMPORTANT]
> Be sure to fully quit the app for the changes to take effect; closing the window is *not* sufficient.
> To fully terminate the app, choose Menu / File / Quit.
After the restart, you should see the MCP server listed when clicking on the "Search and tools" icon at the bottom
of the prompt input area.
#### Example: Claude Code
To add the Penpot MCP server to a Claude Code project, issue the command
claude mcp add penpot -t http http://localhost:4401/mcp
## Repository Structure
This repository is a monorepo containing four main components:
1. **Common Types** (`common/`):
- Shared TypeScript definitions for request/response protocol
- Ensures type safety across server and plugin components
2. **Penpot MCP Server** (`mcp-server/`):
- Provides MCP tools to LLMs for Penpot interaction
- Runs a WebSocket server accepting connections from the Penpot MCP plugin
- Implements request/response correlation with unique task IDs
- Handles task timeouts and proper error reporting
3. **Penpot MCP Plugin** (`penpot-plugin/`):
- Connects to the MCP server via WebSocket
- Executes tasks in Penpot using the Plugin API
- Sends structured responses back to the server#
4. **Helper Scripts** (`python-scripts/`):
- Python scripts that prepare data for the MCP server (development use)
The core components are written in TypeScript, rendering interactions with the
Penpot Plugin API both natural and type-safe.
## Configuration
The Penpot MCP server can be configured using environment variables. All configuration
options use the `PENPOT_MCP_` prefix for consistency.
### Server Configuration
| Environment Variable | Description | Default |
|------------------------------------|----------------------------------------------------------------------------|--------------|
| `PENPOT_MCP_SERVER_LISTEN_ADDRESS` | Address on which the MCP server listens (binds to) | `localhost` |
| `PENPOT_MCP_SERVER_PORT` | Port for the HTTP/SSE server | `4401` |
| `PENPOT_MCP_WEBSOCKET_PORT` | Port for the WebSocket server (plugin connection) | `4402` |
| `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` |
| `PENPOT_MCP_SERVER_ADDRESS` | Hostname or IP address via which clients can reach the MCP server | `localhost` |
| `PENPOT_MCP_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` |
### Logging Configuration
| Environment Variable | Description | Default |
|------------------------|------------------------------------------------------|----------|
| `PENPOT_MCP_LOG_LEVEL` | Log level: `trace`, `debug`, `info`, `warn`, `error` | `info` |
| `PENPOT_MCP_LOG_DIR` | Directory for log files | `logs` |
### Plugin Server Configuration
| Environment Variable | Description | Default |
|-------------------------------------------|-----------------------------------------------------------------------------------------|--------------|
| `PENPOT_MCP_PLUGIN_SERVER_LISTEN_ADDRESS` | Address on which the plugin web server listens (single address or comma-separated list) | (local only) |
## Beyond Local Execution
The above instructions describe how to run the MCP server and plugin server locally.
We are working on enabling remote deployments of the MCP server, particularly
in [multi-user mode](docs/multi-user-mode.md), where multiple Penpot users will
be able to connect to the same MCP server instance.
To run the server remotely (even for a single user),
you may set the following environment variables to configure the two servers
(MCP server & plugin server) appropriately:
* `PENPOT_MCP_REMOTE_MODE=true`: This ensures that the MCP server is operating
in remote mode, with local file system access disabled.
* `PENPOT_MCP_SERVER_LISTEN_ADDRESS` and `PENPOT_MCP_PLUGIN_SERVER_LISTEN_ADDRESS`:
Set these according to your requirements for remote connectivity.
To bind all interfaces, use `0.0.0.0` (use caution in untrusted networks).
* `PENPOT_MCP_SERVER_ADDRESS=<your-address>`: This sets the hostname or IP address
where the MCP server can be reached. The Penpot MCP Plugin uses this to construct
the WebSocket URL as `ws://<your-address>:<port>` (default port: `4402`).

View File

@@ -0,0 +1,41 @@
# Multi-User Mode
> [!WARNING]
> Multi-user mode is under development and not yet fully integrated.
> This information is provided for testing purposes only.
The Penpot MCP server supports a multi-user mode, allowing multiple Penpot users
to connect to the same MCP server instance simultaneously.
This supports remote deployments of the MCP server, without requiring each user
to run their own server instance.
## Limitations
Multi-user mode has the limitation that tools which read from or write to
the local file system are not supported, as the server cannot access
the client's file system. This affects the import and export tools.
## Running Components in Multi-User Mode
To run the MCP server and the Penpot MCP plugin in multi-user mode (for testing),
you can use the following command:
```shell
npm run bootstrap:multi-user
```
This will:
* launch the MCP server in multi-user mode (adding the `--multi-user` flag),
* build and launch the Penpot MCP plugin server in multi-user mode.
See the package.json scripts for both `mcp-server` and `penpot-plugin` for details.
In multi-user mode, users are required to be authenticated via a token.
* This token is provided in the URL used to connect to the MCP server,
e.g. `http://localhost:4401/mcp?userToken=USER_TOKEN`.
* The same token must be provided when connecting the Penpot MCP plugin
to the MCP server.
In the future, the token will, most likely be generated by Penpot and
provided to the plugin automatically.
:warning: For now, it is hard-coded in the plugin's source code for testing purposes.

26
mcp/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "mcp-meta",
"version": "1.0.0",
"description": "",
"scripts": {
"build": "pnpm -r run build",
"build:multi-user": "pnpm -r run build:multi-user",
"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",
"bootstrap:multi-user": "pnpm -r install && pnpm run build:multi-user && pnpm run start:multi-user",
"fmt": "prettier --write packages/",
"fmt:check": "prettier --check packages/"
},
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot.git"
},
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
"private": true,
"devDependencies": {
"concurrently": "^9.2.1",
"prettier": "^3.0.0"
}
}

View File

@@ -0,0 +1,20 @@
{
"name": "mcp-common",
"version": "1.0.0",
"description": "Shared type definitions and interfaces for Penpot MCP",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
"scripts": {
"build": "tsc --build --clean && tsc --build",
"watch": "tsc --watch",
"types:check": "tsc --noEmit",
"clean": "rm -rf dist/"
},
"devDependencies": {
"typescript": "^5.0.0"
},
"files": [
"dist/**/*"
]
}

View File

@@ -0,0 +1 @@
export * from "./types";

View File

@@ -0,0 +1,85 @@
/**
* Result of a plugin task execution.
*
* Contains the outcome status of a task and any additional result data.
*/
export interface PluginTaskResult<T> {
/**
* Optional result data from the task execution.
*/
data?: T;
}
/**
* Request message sent from server to plugin.
*
* Contains a unique identifier, task name, and parameters for execution.
*/
export interface PluginTaskRequest {
/**
* Unique identifier for request/response correlation.
*/
id: string;
/**
* The name of the task to execute.
*/
task: string;
/**
* The parameters for task execution.
*/
params: any;
}
/**
* Response message sent from plugin back to server.
*
* Contains the original request ID and the execution result.
*/
export interface PluginTaskResponse<T> {
/**
* Unique identifier matching the original request.
*/
id: string;
/**
* Whether the task completed successfully.
*/
success: boolean;
/**
* Optional error message if the task failed.
*/
error?: string;
/**
* The result of the task execution.
*/
data?: T;
}
/**
* Parameters for the executeCode task.
*/
export interface ExecuteCodeTaskParams {
/**
* The JavaScript code to be executed.
*/
code: string;
}
/**
* Result data for the executeCode task.
*/
export interface ExecuteCodeTaskResultData<T> {
/**
* The result of the executed code, if any.
*/
result: T;
/**
* Captured console output during code execution.
*/
log: string;
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"composite": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

24
mcp/packages/plugin/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,21 @@
# Penpot MCP Plugin
This project contains a Penpot plugin that accompanies the Penpot MCP server.
It connects to the MCP server via WebSocket, subsequently allowing the MCP
server to execute tasks in Penpot using the Plugin API.
## Setup
1. Install Dependencies
pnpm install
2. Build the Project
pnpm run build
3. Start a Local Development Server
pnpm run start
This will start a local development server at `http://localhost:4400`.

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Penpot plugin example</title>
</head>
<body>
<button type="button" data-appearance="secondary" data-handler="connect-mcp">Connect to MCP server</button>
<div id="connection-status" style="margin-top: 10px; font-size: 12px; color: #666">Not connected</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,24 @@
{
"name": "mcp-plugin",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "vite build --watch --config vite.config.ts",
"start:multi-user": "cross-env MULTI_USER_MODE=true vite build --watch --config vite.config.ts",
"build": "tsc && vite build --config vite.release.config.ts",
"build:multi-user": "tsc && cross-env MULTI_USER_MODE=true vite build --config vite.release.config.ts",
"types:check": "tsc --noEmit",
"clean": "rm -rf dist/"
},
"dependencies": {
"@penpot/plugin-styles": "1.4.1",
"@penpot/plugin-types": "1.4.1"
},
"devDependencies": {
"cross-env": "^7.0.3",
"typescript": "^5.8.3",
"vite": "^7.0.8",
"vite-live-preview": "^0.3.2"
}
}

View File

@@ -0,0 +1,6 @@
{
"name": "Penpot MCP Plugin",
"code": "plugin.js",
"description": "This plugin enables interaction with the Penpot MCP server",
"permissions": ["content:read", "content:write", "library:read", "library:write", "comment:read", "comment:write"]
}

View File

@@ -0,0 +1,425 @@
import { Board, Fill, FlexLayout, GridLayout, Page, Rectangle, Shape } from "@penpot/plugin-types";
export class PenpotUtils {
/**
* Generates an overview structure of the given shape,
* providing its id, name and type, and recursively its children's attributes.
* The `type` field indicates the type in the Penpot API.
* If the shape has a layout system (flex or grid), includes layout information.
*
* @param shape - The root shape to generate the structure from
* @param maxDepth - Optional maximum depth to traverse (leave undefined for unlimited)
* @returns An object representing the shape structure
*/
public static shapeStructure(shape: Shape, maxDepth: number | undefined = undefined): object {
let children = undefined;
if (maxDepth === undefined || maxDepth > 0) {
if ("children" in shape && shape.children) {
children = shape.children.map((child) =>
this.shapeStructure(child, maxDepth === undefined ? undefined : maxDepth - 1)
);
}
}
const result: any = {
id: shape.id,
name: shape.name,
type: shape.type,
children: children,
};
// add layout information if present
if ("flex" in shape && shape.flex) {
const flex: FlexLayout = shape.flex;
result.layout = {
type: "flex",
dir: flex.dir,
rowGap: flex.rowGap,
columnGap: flex.columnGap,
};
} else if ("grid" in shape && shape.grid) {
const grid: GridLayout = shape.grid;
result.layout = {
type: "grid",
rows: grid.rows,
columns: grid.columns,
rowGap: grid.rowGap,
columnGap: grid.columnGap,
};
}
return result;
}
/**
* 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 (defaults to penpot.root)
*/
public static findShapes(predicate: (shape: Shape) => boolean, root: Shape | null = penpot.root): Shape[] {
let result = new Array<Shape>();
let find = function (shape: Shape | null) {
if (!shape) {
return;
}
if (predicate(shape)) {
result.push(shape);
}
if ("children" in shape && shape.children) {
for (let child of shape.children) {
find(child);
}
}
};
find(root);
return result;
}
/**
* Finds the first shape 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)
*/
public static findShape(predicate: (shape: Shape) => boolean, root: Shape | null = null): Shape | null {
let find = function (shape: Shape | null): Shape | null {
if (!shape) {
return null;
}
if (predicate(shape)) {
return shape;
}
if ("children" in shape && shape.children) {
for (let child of shape.children) {
let result = find(child);
if (result) {
return result;
}
}
}
return null;
};
if (root === null) {
const pages = penpot.currentFile?.pages;
if (pages) {
for (let page of pages) {
let result = find(page.root);
if (result) {
return result;
}
}
}
return null;
} else {
return find(root);
}
}
/**
* Finds a shape by its unique ID.
*
* @param id - The unique ID of the shape to find
* @returns The shape with the matching ID, or null if not found
*/
public static findShapeById(id: string): Shape | null {
return this.findShape((shape) => shape.id === id);
}
public static findPage(predicate: (page: Page) => boolean): Page | null {
let page = penpot.currentFile!.pages.find(predicate);
return page || null;
}
public static getPages(): { id: string; name: string }[] {
return penpot.currentFile!.pages.map((page) => ({ id: page.id, name: page.name }));
}
public static getPageById(id: string): Page | null {
return this.findPage((page) => page.id === id);
}
public static getPageByName(name: string): Page | null {
return this.findPage((page) => page.name.toLowerCase() === name.toLowerCase());
}
public static getPageForShape(shape: Shape): Page | null {
for (const page of penpot.currentFile!.pages) {
if (page.getShapeById(shape.id)) {
return page;
}
}
return null;
}
public static generateCss(shape: Shape): string {
const page = this.getPageForShape(shape);
if (!page) {
throw new Error("Shape is not part of any page");
}
penpot.openPage(page);
return penpot.generateStyle([shape], { type: "css", includeChildren: true });
}
/**
* Checks if a child shape is fully contained within its parent's bounds.
* Visual containment means all edges of the child are within the parent's bounding box.
*
* @param child - The child shape to check
* @param parent - The parent shape to check against
* @returns true if child is fully contained within parent bounds, false otherwise
*/
public static isContainedIn(child: Shape, parent: Shape): boolean {
return (
child.x >= parent.x &&
child.y >= parent.y &&
child.x + child.width <= parent.x + parent.width &&
child.y + child.height <= parent.y + parent.height
);
}
/**
* Sets the position of a shape relative to its parent's position.
* This is a convenience method since parentX and parentY are read-only properties.
*
* @param shape - The shape to position
* @param parentX - The desired X position relative to the parent
* @param parentY - The desired Y position relative to the parent
* @throws Error if the shape has no parent
*/
public static setParentXY(shape: Shape, parentX: number, parentY: number): void {
if (!shape.parent) {
throw new Error("Shape has no parent - cannot set parent-relative position");
}
shape.x = shape.parent.x + parentX;
shape.y = shape.parent.y + parentY;
}
/**
* Adds a flex layout to a container while preserving the visual order of existing children.
* Without this, adding a flex layout can arbitrarily reorder children.
*
* The method sorts children by their current position (x for "row", y for "column") before
* adding the layout, then reorders them to maintain that visual sequence.
*
* @param container - The container (board) to add the flex layout to
* @param dir - The layout direction: "row" for horizontal, "column" for vertical
* @returns The created FlexLayout instance
*/
public static addFlexLayout(container: Board, dir: "column" | "row"): FlexLayout {
// obtain children sorted by position (ascending)
const children = "children" in container && container.children ? [...container.children] : [];
const sortedChildren = children.sort((a, b) => (dir === "row" ? a.x - b.x : a.y - b.y));
// add the flex layout
const flexLayout = container.addFlexLayout();
flexLayout.dir = dir;
// reorder children to preserve visual order; since the children array is reversed
// relative to visual order for dir="column" or dir="row", we insert each child at
// index 0 in sorted order, which places the first (smallest position) at the highest
// index, making it appear first visually
for (const child of sortedChildren) {
child.setParentIndex(0);
}
return flexLayout;
}
/**
* Analyzes all descendants of a shape by applying an evaluator function to each.
* Only descendants for which the evaluator returns a non-null/non-undefined value are included in the result.
* This is a general-purpose utility for validation, analysis, or collecting corrector functions.
*
* @param root - The root shape whose descendants to analyze
* @param evaluator - Function called for each descendant with (root, descendant); return null/undefined to skip
* @param maxDepth - Optional maximum depth to traverse (undefined for unlimited)
* @returns Array of objects containing the shape and the evaluator's result
*/
public static analyzeDescendants<T>(
root: Shape,
evaluator: (root: Shape, descendant: Shape) => T | null | undefined,
maxDepth: number | undefined = undefined
): Array<{ shape: Shape; result: NonNullable<T> }> {
const results: Array<{ shape: Shape; result: NonNullable<T> }> = [];
const traverse = (shape: Shape, currentDepth: number): void => {
const result = evaluator(root, shape);
if (result !== null && result !== undefined) {
results.push({ shape, result: result as NonNullable<T> });
}
if (maxDepth === undefined || currentDepth < maxDepth) {
if ("children" in shape && shape.children) {
for (const child of shape.children) {
traverse(child, currentDepth + 1);
}
}
}
};
// Start traversal with root's children (not root itself)
if ("children" in root && root.children) {
for (const child of root.children) {
traverse(child, 1);
}
}
return results;
}
/**
* Decodes a base64 string to a Uint8Array.
* This is required because the Penpot plugin environment does not provide the atob function.
*
* @param base64 - The base64-encoded string to decode
* @returns The decoded data as a Uint8Array
*/
public static atob(base64: string): Uint8Array {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const lookup = new Uint8Array(256);
for (let i = 0; i < chars.length; i++) {
lookup[chars.charCodeAt(i)] = i;
}
let bufferLength = base64.length * 0.75;
if (base64[base64.length - 1] === "=") {
bufferLength--;
if (base64[base64.length - 2] === "=") {
bufferLength--;
}
}
const bytes = new Uint8Array(bufferLength);
let p = 0;
for (let i = 0; i < base64.length; i += 4) {
const encoded1 = lookup[base64.charCodeAt(i)];
const encoded2 = lookup[base64.charCodeAt(i + 1)];
const encoded3 = lookup[base64.charCodeAt(i + 2)];
const encoded4 = lookup[base64.charCodeAt(i + 3)];
bytes[p++] = (encoded1 << 2) | (encoded2 >> 4);
bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2);
bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63);
}
return bytes;
}
/**
* Imports an image from base64 data into the Penpot design as a Rectangle shape filled with the image.
* The rectangle has the image's original proportions by default.
* Optionally accepts position (x, y) and dimensions (width, height) parameters.
* If only one dimension is provided, the other is calculated to maintain the image's aspect ratio.
*
* This function is used internally by the ImportImageTool in the MCP server.
*
* @param base64 - The base64-encoded image data
* @param mimeType - The MIME type of the image (e.g., "image/png")
* @param name - The name to assign to the newly created rectangle shape
* @param x - The x-coordinate for positioning the rectangle (optional)
* @param y - The y-coordinate for positioning the rectangle (optional)
* @param width - The desired width of the rectangle (optional)
* @param height - The desired height of the rectangle (optional)
*/
public static async importImage(
base64: string,
mimeType: string,
name: string,
x: number | undefined,
y: number | undefined,
width: number | undefined,
height: number | undefined
): Promise<Rectangle> {
// convert base64 to Uint8Array
const bytes = PenpotUtils.atob(base64);
// upload the image data to Penpot
const imageData = await penpot.uploadMediaData(name, bytes, mimeType);
// create a rectangle shape
const rect = penpot.createRectangle();
rect.name = name;
// calculate dimensions
let rectWidth, rectHeight;
const hasWidth = width !== undefined;
const hasHeight = height !== undefined;
if (hasWidth && hasHeight) {
// both width and height provided - use them directly
rectWidth = width;
rectHeight = height;
} else if (hasWidth) {
// only width provided - maintain aspect ratio
rectWidth = width;
rectHeight = rectWidth * (imageData.height / imageData.width);
} else if (hasHeight) {
// only height provided - maintain aspect ratio
rectHeight = height;
rectWidth = rectHeight * (imageData.width / imageData.height);
} else {
// neither provided - use original dimensions
rectWidth = imageData.width;
rectHeight = imageData.height;
}
// set rectangle dimensions
rect.resize(rectWidth, rectHeight);
// set position if provided
if (x !== undefined) {
rect.x = x;
}
if (y !== undefined) {
rect.y = y;
}
// apply the image as a fill
rect.fills = [{ fillOpacity: 1, fillImage: imageData }];
return rect;
}
/**
* Exports the given shape (or its fill) to BASE64 image data.
*
* This function is used internally by the ExportImageTool in the MCP server.
*
* @param shape - The shape whose image data to export
* @param mode - Either "shape" (to export the entire shape, including descendants) or "fill"
* to export the shape's raw fill image data
* @param asSVG - Whether to export as SVG rather than as a pixel image (only supported for mode "shape")
* @returns A byte array containing the exported image data.
* - For mode="shape", it will be PNG or SVG data depending on the value of `asSVG`.
* - For mode="fill", it will be whatever format the fill image is stored in.
*/
public static async exportImage(shape: Shape, mode: "shape" | "fill", asSVG: boolean): Promise<Uint8Array> {
switch (mode) {
case "shape":
return shape.export({ type: asSVG ? "svg" : "png" });
case "fill":
if (asSVG) {
throw new Error("Image fills cannot be exported as SVG");
}
// check whether the shape has the `fills` member
if (!("fills" in shape)) {
throw new Error("Shape with `fills` member is required for fill export mode");
}
// find first fill that has fillImage
const fills: Fill[] = (shape as any).fills;
for (const fill of fills) {
if (fill.fillImage) {
const imageData = fill.fillImage;
return imageData.data();
}
}
throw new Error("No fill with image data found in the shape");
default:
throw new Error(`Unsupported export mode: ${mode}`);
}
}
}

View File

@@ -0,0 +1,77 @@
/**
* Represents a task received from the MCP server in the Penpot MCP plugin
*/
export class Task<TParams = any> {
public isResponseSent: boolean = false;
/**
* @param requestId Unique identifier for the task request
* @param taskType The type of the task to execute
* @param params Task parameters/arguments
*/
constructor(
public requestId: string,
public taskType: string,
public params: TParams
) {}
/**
* Sends a task response back to the MCP server.
*/
protected sendResponse(success: boolean, data: any = undefined, error: any = undefined): void {
if (this.isResponseSent) {
console.error("Response already sent for task:", this.requestId);
return;
}
const response = {
type: "task-response",
response: {
id: this.requestId,
success: success,
data: data,
error: error,
},
};
// Send to main.ts which will forward to MCP server via WebSocket
penpot.ui.sendMessage(response);
console.log("Sent task response:", response);
this.isResponseSent = true;
}
public sendSuccess(data: any = undefined): void {
this.sendResponse(true, data);
}
public sendError(error: string): void {
this.sendResponse(false, undefined, error);
}
}
/**
* Abstract base class for task handlers in the Penpot MCP plugin.
*
* @template TParams - The type of parameters this handler expects
*/
export abstract class TaskHandler<TParams = any> {
/** The task type this handler is responsible for */
abstract readonly taskType: string;
/**
* Checks if this handler can process the given task.
*
* @param task - The task identifier to check
* @returns True if this handler applies to the given task
*/
isApplicableTo(task: Task): boolean {
return this.taskType === task.taskType;
}
/**
* Handles the task with the provided parameters.
*
* @param task - The task to be handled
*/
abstract handle(task: Task<TParams>): Promise<void>;
}

View File

@@ -0,0 +1,110 @@
import "./style.css";
// get the current theme from the URL
const searchParams = new URLSearchParams(window.location.search);
document.body.dataset.theme = searchParams.get("theme") ?? "light";
// Determine whether multi-user mode is enabled based on URL parameters
const isMultiUserMode = searchParams.get("multiUser") === "true";
console.log("Penpot MCP multi-user mode:", isMultiUserMode);
// WebSocket connection management
let ws: WebSocket | null = null;
const statusElement = document.getElementById("connection-status");
/**
* Updates the connection status display element.
*
* @param status - the base status text to display
* @param isConnectedState - whether the connection is in a connected state (affects color)
* @param message - optional additional message to append to the status
*/
function updateConnectionStatus(status: string, isConnectedState: boolean, message?: string): void {
if (statusElement) {
const displayText = message ? `${status}: ${message}` : status;
statusElement.textContent = displayText;
statusElement.style.color = isConnectedState ? "var(--accent-primary)" : "var(--error-700)";
}
}
/**
* Sends a task response back to the MCP server via WebSocket.
*
* @param response - The response containing task ID and result
*/
function sendTaskResponse(response: any): void {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(response));
console.log("Sent response to MCP server:", response);
} else {
console.error("WebSocket not connected, cannot send response");
}
}
/**
* Establishes a WebSocket connection to the MCP server.
*/
function connectToMcpServer(): void {
if (ws?.readyState === WebSocket.OPEN) {
updateConnectionStatus("Already connected", true);
return;
}
try {
let wsUrl = PENPOT_MCP_WEBSOCKET_URL;
if (isMultiUserMode) {
// TODO obtain proper userToken from penpot
const userToken = "dummyToken";
wsUrl += `?userToken=${encodeURIComponent(userToken)}`;
}
ws = new WebSocket(wsUrl);
updateConnectionStatus("Connecting...", false);
ws.onopen = () => {
console.log("Connected to MCP server");
updateConnectionStatus("Connected to MCP server", true);
};
ws.onmessage = (event) => {
console.log("Received from MCP server:", event.data);
try {
const request = JSON.parse(event.data);
// Forward the task request to the plugin for execution
parent.postMessage(request, "*");
} catch (error) {
console.error("Failed to parse WebSocket message:", error);
}
};
ws.onclose = (event: CloseEvent) => {
console.log("Disconnected from MCP server");
const message = event.reason || undefined;
updateConnectionStatus("Disconnected", false, message);
ws = null;
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
// note: WebSocket error events typically don't contain detailed error messages
updateConnectionStatus("Connection error", false);
};
} catch (error) {
console.error("Failed to connect to MCP server:", error);
const message = error instanceof Error ? error.message : undefined;
updateConnectionStatus("Connection failed", false, message);
}
}
document.querySelector("[data-handler='connect-mcp']")?.addEventListener("click", () => {
connectToMcpServer();
});
// Listen plugin.ts messages
window.addEventListener("message", (event) => {
if (event.data.source === "penpot") {
document.body.dataset.theme = event.data.theme;
} else if (event.data.type === "task-response") {
// Forward task response back to MCP server
sendTaskResponse(event.data.response);
}
});

View File

@@ -0,0 +1,69 @@
import { ExecuteCodeTaskHandler } from "./task-handlers/ExecuteCodeTaskHandler";
import { Task, TaskHandler } from "./TaskHandler";
/**
* Registry of all available task handlers.
*/
const taskHandlers: TaskHandler[] = [new ExecuteCodeTaskHandler()];
// Determine whether multi-user mode is enabled based on build-time configuration
declare const IS_MULTI_USER_MODE: boolean;
const isMultiUserMode = typeof IS_MULTI_USER_MODE !== "undefined" ? IS_MULTI_USER_MODE : false;
// Open the plugin UI (main.ts)
penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}&multiUser=${isMultiUserMode}`, { width: 158, height: 200 });
// Handle messages
penpot.ui.onMessage<string | { id: string; task: string; params: any }>((message) => {
// Handle plugin task requests
if (typeof message === "object" && message.task && message.id) {
handlePluginTaskRequest(message).catch((error) => {
console.error("Error in handlePluginTaskRequest:", error);
});
}
});
/**
* Handles plugin task requests received from the MCP server via WebSocket.
*
* @param request - The task request containing ID, task type and parameters
*/
async function handlePluginTaskRequest(request: { id: string; task: string; params: any }): Promise<void> {
console.log("Executing plugin task:", request.task, request.params);
const task = new Task(request.id, request.task, request.params);
// Find the appropriate handler
const handler = taskHandlers.find((h) => h.isApplicableTo(task));
if (handler) {
try {
// Cast the params to the expected type and handle the task
console.log("Processing task with handler:", handler);
await handler.handle(task);
// check whether a response was sent and send a generic success if not
if (!task.isResponseSent) {
console.warn("Handler did not send a response, sending generic success.");
task.sendSuccess("Task completed without a specific response.");
}
console.log("Task handled successfully:", task);
} catch (error) {
console.error("Error handling task:", error);
const errorMessage = error instanceof Error ? error.message : "Unknown error";
task.sendError(`Error handling task: ${errorMessage}`);
}
} else {
console.error("Unknown plugin task:", request.task);
task.sendError(`Unknown task type: ${request.task}`);
}
}
// Handle theme change in the iframe
penpot.on("themechange", (theme) => {
penpot.ui.sendMessage({
source: "penpot",
type: "themechange",
theme,
});
});

View File

@@ -0,0 +1,10 @@
@import "@penpot/plugin-styles/styles.css";
body {
line-height: 1.5;
padding: 10px;
}
p {
margin-block-end: 0.75rem;
}

View File

@@ -0,0 +1,212 @@
import { Task, TaskHandler } from "../TaskHandler";
import { ExecuteCodeTaskParams, ExecuteCodeTaskResultData } from "../../../common/src";
import { PenpotUtils } from "../PenpotUtils.ts";
/**
* Console implementation that captures all log output for code execution.
*
* Provides the same interface as the native console object but appends
* all output to an internal log string that can be retrieved.
*/
class ExecuteCodeTaskConsole {
/**
* Accumulated log output from all console method calls.
*/
private logOutput: string = "";
/**
* Resets the accumulated log output to empty string.
* Should be called before each code execution to start with clean logs.
*/
resetLog(): void {
this.logOutput = "";
}
/**
* Gets the accumulated log output from all console method calls.
* @returns The complete log output as a string
*/
getLog(): string {
return this.logOutput;
}
/**
* Appends a formatted message to the log output.
* @param level - Log level prefix (e.g., "LOG", "WARN", "ERROR")
* @param args - Arguments to log, will be stringified and joined
*/
private appendToLog(level: string, ...args: any[]): void {
const message = args
.map((arg) => (typeof arg === "object" ? JSON.stringify(arg, null, 2) : String(arg)))
.join(" ");
this.logOutput += `[${level}] ${message}\n`;
}
/**
* Logs a message to the captured output.
*/
log(...args: any[]): void {
this.appendToLog("LOG", ...args);
}
/**
* Logs a warning message to the captured output.
*/
warn(...args: any[]): void {
this.appendToLog("WARN", ...args);
}
/**
* Logs an error message to the captured output.
*/
error(...args: any[]): void {
this.appendToLog("ERROR", ...args);
}
/**
* Logs an informational message to the captured output.
*/
info(...args: any[]): void {
this.appendToLog("INFO", ...args);
}
/**
* Logs a debug message to the captured output.
*/
debug(...args: any[]): void {
this.appendToLog("DEBUG", ...args);
}
/**
* Logs a message with trace information to the captured output.
*/
trace(...args: any[]): void {
this.appendToLog("TRACE", ...args);
}
/**
* Logs a table to the captured output (simplified as JSON).
*/
table(data: any): void {
this.appendToLog("TABLE", data);
}
/**
* Starts a timer (simplified implementation that just logs).
*/
time(label?: string): void {
this.appendToLog("TIME", `Timer started: ${label || "default"}`);
}
/**
* Ends a timer (simplified implementation that just logs).
*/
timeEnd(label?: string): void {
this.appendToLog("TIME_END", `Timer ended: ${label || "default"}`);
}
/**
* Logs messages in a group (simplified to just log the label).
*/
group(label?: string): void {
this.appendToLog("GROUP", label || "");
}
/**
* Logs messages in a collapsed group (simplified to just log the label).
*/
groupCollapsed(label?: string): void {
this.appendToLog("GROUP_COLLAPSED", label || "");
}
/**
* Ends the current group (simplified implementation).
*/
groupEnd(): void {
this.appendToLog("GROUP_END", "");
}
/**
* Clears the console (no-op in this implementation since we want to capture logs).
*/
clear(): void {
// intentionally empty - we don't want to clear captured logs
}
/**
* Counts occurrences of calls with the same label (simplified implementation).
*/
count(label?: string): void {
this.appendToLog("COUNT", label || "default");
}
/**
* Resets the count for a label (simplified implementation).
*/
countReset(label?: string): void {
this.appendToLog("COUNT_RESET", label || "default");
}
/**
* Logs an assertion (simplified to just log if condition is false).
*/
assert(condition: boolean, ...args: any[]): void {
if (!condition) {
this.appendToLog("ASSERT", ...args);
}
}
}
/**
* Task handler for executing JavaScript code in the plugin context.
*
* Maintains a persistent context object that preserves state between code executions
* and captures all console output during execution.
*/
export class ExecuteCodeTaskHandler extends TaskHandler<ExecuteCodeTaskParams> {
readonly taskType = "executeCode";
/**
* Persistent context object that maintains state between code executions.
* Contains the penpot API, storage object, and custom console implementation.
*/
private readonly context: any;
constructor() {
super();
// initialize context, making penpot, penpotUtils, storage and the custom console available
this.context = {
penpot: penpot,
storage: {},
console: new ExecuteCodeTaskConsole(),
penpotUtils: PenpotUtils,
};
}
async handle(task: Task<ExecuteCodeTaskParams>): Promise<void> {
if (!task.params.code) {
task.sendError("executeCode task requires 'code' parameter");
return;
}
this.context.console.resetLog();
const context = this.context;
const code = task.params.code;
let result: any = await (async (ctx) => {
const fn = new Function(...Object.keys(ctx), `return (async () => { ${code} })();`);
return fn(...Object.values(ctx));
})(context);
console.log("Code execution result:", result);
// return result and captured log
let resultData: ExecuteCodeTaskResultData<any> = {
result: result,
log: this.context.console.getLog(),
};
task.sendSuccess(resultData);
}
}

4
mcp/packages/plugin/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
/// <reference types="vite/client" />
declare const IS_MULTI_USER_MODE: boolean;
declare const PENPOT_MCP_WEBSOCKET_URL: string;

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