mirror of
https://github.com/penpot/penpot.git
synced 2026-02-05 12:12:07 -05:00
Compare commits
4 Commits
staging-re
...
azazeln28-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
034b422f3a | ||
|
|
1a9ffaaccc | ||
|
|
cf2c460cb1 | ||
|
|
802798feb1 |
38
.github/ISSUE_TEMPLATE/new-render-bug-report.md
vendored
38
.github/ISSUE_TEMPLATE/new-render-bug-report.md
vendored
@@ -1,38 +0,0 @@
|
||||
---
|
||||
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.
|
||||
2
.github/workflows/build-bundle.yml
vendored
2
.github/workflows/build-bundle.yml
vendored
@@ -40,7 +40,7 @@ on:
|
||||
jobs:
|
||||
build-bundle:
|
||||
name: Build and Upload Penpot Bundle
|
||||
runs-on: penpot-runner-01
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
|
||||
7
.github/workflows/build-docker-devenv.yml
vendored
7
.github/workflows/build-docker-devenv.yml
vendored
@@ -7,14 +7,9 @@ jobs:
|
||||
build-and-push:
|
||||
name: Build and push DevEnv Docker image
|
||||
environment: release-admins
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
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
|
||||
|
||||
|
||||
16
.github/workflows/build-docker.yml
vendored
16
.github/workflows/build-docker.yml
vendored
@@ -19,14 +19,9 @@ on:
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push Penpot Docker Images
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: ubuntu-24.04-arm
|
||||
|
||||
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:
|
||||
@@ -71,15 +66,6 @@ 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
|
||||
|
||||
2
.github/workflows/commit-checker.yml
vendored
2
.github/workflows/commit-checker.yml
vendored
@@ -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|Reapply).+[^.])$'
|
||||
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).+[^.])$'
|
||||
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
45
.github/workflows/tests-mcp.yml
vendored
@@ -1,45 +0,0 @@
|
||||
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;
|
||||
@@ -1,6 +1,10 @@
|
||||
# CHANGELOG
|
||||
|
||||
## 2.13.0
|
||||
## 2.13.0 (Unreleased)
|
||||
|
||||
### :boom: Breaking changes & Deprecations
|
||||
|
||||
### :rocket: Epics and highlights
|
||||
|
||||
### :heart: Community contributions (Thank you!)
|
||||
|
||||
@@ -35,9 +39,6 @@
|
||||
- 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
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
:deps
|
||||
{penpot/common {:local/root "../common"}
|
||||
org.clojure/clojure {:mvn/version "1.12.4"}
|
||||
org.clojure/clojure {:mvn/version "1.12.2"}
|
||||
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.9"
|
||||
:git/sha "5fad7a9"
|
||||
{:git/tag "v11.8"
|
||||
:git/sha "1d1b33f"
|
||||
: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.9"}
|
||||
org.postgresql/postgresql {:mvn/version "42.7.7"}
|
||||
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.3"}
|
||||
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.2"}
|
||||
|
||||
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.41.21"}}
|
||||
software.amazon.awssdk/s3 {:mvn/version "2.33.10"}}
|
||||
|
||||
:paths ["src" "resources" "target/classes"]
|
||||
:aliases
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{:deps
|
||||
{org.clojure/clojure {:mvn/version "1.12.4"}
|
||||
{org.clojure/clojure {:mvn/version "1.12.2"}
|
||||
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.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.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.slf4j/slf4j-api {:mvn/version "2.0.17"}
|
||||
pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.41"}
|
||||
pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.40"}
|
||||
|
||||
selmer/selmer {:mvn/version "1.12.70"}
|
||||
selmer/selmer {:mvn/version "1.12.69"}
|
||||
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.1"}
|
||||
integrant/integrant {:mvn/version "1.0.0"}
|
||||
|
||||
funcool/cuerdas {:mvn/version "2026.415"}
|
||||
funcool/promesa
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"design-tokens/v1"
|
||||
"text-editor/v2-html-paste"
|
||||
"text-editor/v2"
|
||||
"text-editor-wasm/v1"
|
||||
"render-wasm/v1"
|
||||
"variants/v1"})
|
||||
|
||||
@@ -78,6 +79,7 @@
|
||||
"plugins/runtime"
|
||||
"text-editor/v2-html-paste"
|
||||
"text-editor/v2"
|
||||
"text-editor-wasm/v1"
|
||||
"tokens/numeric-input"
|
||||
"render-wasm/v1"})
|
||||
|
||||
@@ -127,6 +129,7 @@
|
||||
: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"
|
||||
|
||||
@@ -407,19 +407,17 @@
|
||||
(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 & {:as styles}]
|
||||
[content text]
|
||||
(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
|
||||
|
||||
@@ -97,12 +97,9 @@
|
||||
(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}
|
||||
token-name-validation-regex])
|
||||
#"^[a-zA-Z0-9_-][a-zA-Z0-9$_-]*(\.[a-zA-Z0-9$_-]+)*$"])
|
||||
|
||||
(def ^:private schema:color
|
||||
[:map
|
||||
|
||||
@@ -1462,12 +1462,11 @@ Will return a value that matches this schema:
|
||||
(def ^:private schema:dtcg-node
|
||||
[:schema {:registry
|
||||
{::simple-value
|
||||
[:or :string :int :double ::sm/boolean]
|
||||
[:or :string :int :double]
|
||||
::value
|
||||
[:or
|
||||
[:ref ::simple-value]
|
||||
[:vector ::simple-value]
|
||||
[:vector [:map-of :string ::simple-value]]
|
||||
[:map-of :string [:or
|
||||
[:ref ::simple-value]
|
||||
[:vector ::simple-value]]]]}}
|
||||
|
||||
@@ -31,7 +31,7 @@ RUN set -ex; \
|
||||
|
||||
FROM base AS setup-node
|
||||
|
||||
ENV NODE_VERSION=v22.22.0 \
|
||||
ENV NODE_VERSION=v22.21.1 \
|
||||
PATH=/opt/node/bin:$PATH
|
||||
|
||||
RUN set -eux; \
|
||||
@@ -97,19 +97,18 @@ RUN set -eux; \
|
||||
|
||||
FROM base AS setup-jvm
|
||||
|
||||
# https://clojure.org/releases/tools
|
||||
ENV CLOJURE_VERSION=1.12.4.1602
|
||||
ENV CLOJURE_VERSION=1.12.3.1577
|
||||
|
||||
RUN set -eux; \
|
||||
ARCH="$(dpkg --print-architecture)"; \
|
||||
case "${ARCH}" in \
|
||||
aarch64|arm64) \
|
||||
ESUM='9903c6b19183a33725ca1dfdae5b72400c9d00995c76fafc4a0d31c5152f33f7'; \
|
||||
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_aarch64.tar.gz'; \
|
||||
ESUM='8c5321f16d9f1d8149f83e4e9ff8ca5d9e94320b92d205e6db42a604de3d1140'; \
|
||||
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.30.17-ca-jdk25.0.1-linux_aarch64.tar.gz'; \
|
||||
;; \
|
||||
amd64|x86_64) \
|
||||
ESUM='946ad9766d98fc6ab495a1a120072197db54997f6925fb96680f1ecd5591db4e'; \
|
||||
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_x64.tar.gz'; \
|
||||
ESUM='471b3e62bdffaed27e37005d842d8639f10d244ccce1c7cdebf7abce06c8313e'; \
|
||||
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.30.17-ca-jdk25.0.1-linux_x64.tar.gz'; \
|
||||
;; \
|
||||
*) \
|
||||
echo "Unsupported arch: ${ARCH}"; \
|
||||
@@ -180,10 +179,9 @@ RUN set -eux; \
|
||||
|
||||
FROM base AS setup-utils
|
||||
|
||||
ENV CLJKONDO_VERSION=2026.01.19 \
|
||||
ENV CLJKONDO_VERSION=2025.07.28 \
|
||||
BABASHKA_VERSION=1.12.208 \
|
||||
CLJFMT_VERSION=0.15.6 \
|
||||
PIXI_VERSION=0.63.2
|
||||
CLJFMT_VERSION=0.13.1
|
||||
|
||||
RUN set -ex; \
|
||||
ARCH="$(dpkg --print-architecture)"; \
|
||||
@@ -226,26 +224,6 @@ 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 \
|
||||
@@ -397,7 +375,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-13 /opt/imagick /opt/imagick
|
||||
COPY --from=penpotapp/imagemagick:7.1.2-0 /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
|
||||
@@ -420,6 +398,7 @@ 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
|
||||
|
||||
|
||||
@@ -46,11 +46,6 @@ services:
|
||||
- 9090:9090
|
||||
- 9091:9091
|
||||
|
||||
# MCP
|
||||
- 4400:4400
|
||||
- 4401:4401
|
||||
- 4402:4402
|
||||
|
||||
environment:
|
||||
- EXTERNAL_UID=${CURRENT_USER_ID}
|
||||
# SMTP setup
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
{
|
||||
auto_https off
|
||||
auto_https off
|
||||
}
|
||||
|
||||
localhost:3449 {
|
||||
reverse_proxy localhost:4449
|
||||
tls /home/selfsigned.crt /home/selfsigned.key
|
||||
header -Strict-Transport-Security
|
||||
reverse_proxy localhost:4449
|
||||
tls /home/selfsigned.crt /home/selfsigned.key
|
||||
}
|
||||
|
||||
http://localhost:3450 {
|
||||
reverse_proxy localhost:4449
|
||||
reverse_proxy localhost:4449
|
||||
}
|
||||
|
||||
http://penpot-devenv-main:3450 {
|
||||
reverse_proxy localhost:4449
|
||||
reverse_proxy localhost:4449
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@ 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 ll='ls --color -GFlh'
|
||||
alias rm='rm -rf'
|
||||
alias rm='rm -r'
|
||||
alias ls='ls --color -F'
|
||||
alias lsd='ls -d *(/)'
|
||||
alias lsf='ls -h *(.)'
|
||||
|
||||
@@ -121,28 +121,6 @@ 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;
|
||||
}
|
||||
@@ -163,14 +141,8 @@ http {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
}
|
||||
|
||||
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 /nitrate/ {
|
||||
proxy_pass http://127.0.0.1:3000/;
|
||||
}
|
||||
|
||||
location /wasm-playground {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
set -g default-command "${SHELL}"
|
||||
set -g mouse off
|
||||
set -g history-limit 50000
|
||||
setw -g mode-keys emacs
|
||||
|
||||
@@ -6,7 +6,7 @@ ENV LANG='C.UTF-8' \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
TZ=Etc/UTC
|
||||
|
||||
ARG IMAGEMAGICK_VERSION=7.1.2-13
|
||||
ARG IMAGEMAGICK_VERSION=7.1.1-47
|
||||
|
||||
RUN set -e; \
|
||||
apt-get -qq update; \
|
||||
|
||||
@@ -5,7 +5,7 @@ ENV LANG='C.UTF-8' \
|
||||
LC_ALL='C.UTF-8' \
|
||||
JAVA_HOME="/opt/jdk" \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
NODE_VERSION=v22.22.0 \
|
||||
NODE_VERSION=v22.21.1 \
|
||||
TZ=Etc/UTC
|
||||
|
||||
RUN set -ex; \
|
||||
@@ -46,12 +46,12 @@ RUN set -eux; \
|
||||
ARCH="$(dpkg --print-architecture)"; \
|
||||
case "${ARCH}" in \
|
||||
aarch64|arm64) \
|
||||
ESUM='9903c6b19183a33725ca1dfdae5b72400c9d00995c76fafc4a0d31c5152f33f7'; \
|
||||
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_aarch64.tar.gz'; \
|
||||
ESUM='8c5321f16d9f1d8149f83e4e9ff8ca5d9e94320b92d205e6db42a604de3d1140'; \
|
||||
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.30.17-ca-jdk25.0.1-linux_aarch64.tar.gz'; \
|
||||
;; \
|
||||
amd64|x86_64) \
|
||||
ESUM='946ad9766d98fc6ab495a1a120072197db54997f6925fb96680f1ecd5591db4e'; \
|
||||
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_x64.tar.gz'; \
|
||||
ESUM='471b3e62bdffaed27e37005d842d8639f10d244ccce1c7cdebf7abce06c8313e'; \
|
||||
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.30.17-ca-jdk25.0.1-linux_x64.tar.gz'; \
|
||||
;; \
|
||||
*) \
|
||||
echo "Unsupported arch: ${ARCH}"; \
|
||||
|
||||
@@ -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.22.0 \
|
||||
NODE_VERSION=v22.21.1 \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
PATH=/opt/node/bin:/opt/imagick/bin:$PATH
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import { platform } from "os";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
@@ -7,10 +6,6 @@ import { platform } from "os";
|
||||
*/
|
||||
// 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
|
||||
*/
|
||||
@@ -48,20 +43,12 @@ 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",
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
{
|
||||
"~: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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"~: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"
|
||||
}
|
||||
@@ -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", { timeout: 30000 })
|
||||
await expect(this.pageName).toHaveText("Page 1");
|
||||
}
|
||||
|
||||
async sendPresenceMessage(fixture) {
|
||||
@@ -383,46 +383,19 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
await this.page.keyboard.press("T");
|
||||
await this.page.waitForTimeout(timeToWait);
|
||||
await this.clickAndMove(x1, y1, x2, y2);
|
||||
await expect(this.page.getByTestId("text-editor")).toBeVisible();
|
||||
|
||||
await this.page.waitForTimeout(timeToWait);
|
||||
if (initialText) {
|
||||
await this.page.keyboard.type(initialText);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the selected element into the clipboard, or copy the
|
||||
* content of the locator into the clipboard.
|
||||
* Copies the selected element into the clipboard.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
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 });
|
||||
|
||||
async copy() {
|
||||
return this.page.keyboard.press("Control+C");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -434,9 +407,9 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
async paste(kind = "keyboard") {
|
||||
if (kind === "context-menu") {
|
||||
await this.viewport.click({ button: "right" });
|
||||
return this.page.getByText("Paste", { exact: true }).click();
|
||||
return this.page.getByText("PasteCtrlV").click();
|
||||
}
|
||||
return this.page.keyboard.press("ControlOrMeta+V");
|
||||
return this.page.keyboard.press("Control+V");
|
||||
}
|
||||
|
||||
async panOnViewportAt(x, y, width, height) {
|
||||
@@ -459,8 +432,8 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
await this.page.mouse.up();
|
||||
}
|
||||
|
||||
async clickLeafLayer(name, clickOptions = {}, index = 0) {
|
||||
const layer = this.layers.getByText(name).nth(index);
|
||||
async clickLeafLayer(name, clickOptions = {}) {
|
||||
const layer = this.layers.getByText(name).first();
|
||||
await layer.waitFor();
|
||||
await layer.click(clickOptions);
|
||||
await this.page.waitForTimeout(500);
|
||||
@@ -471,16 +444,15 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
await this.clickLeafLayer(name, clickOptions);
|
||||
}
|
||||
|
||||
async clickToggableLayer(name, clickOptions = {}, index = 0) {
|
||||
async clickToggableLayer(name, clickOptions = {}) {
|
||||
const layer = this.layers
|
||||
.getByTestId("layer-row")
|
||||
.filter({ hasText: name })
|
||||
.nth(index);
|
||||
const button = layer.getByTestId("toggle-content");
|
||||
.filter({ hasText: name });
|
||||
const button = layer.getByRole("button");
|
||||
|
||||
await expect(button).toBeVisible();
|
||||
await button.waitFor();
|
||||
await button.click(clickOptions);
|
||||
await button.waitFor({ ariaExpanded: true });
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
async expectSelectedLayer(name) {
|
||||
@@ -523,7 +495,13 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
|
||||
async clickColorPalette(clickOptions = {}) {
|
||||
await this.palette
|
||||
.getByRole("button", { name: /Color Palette/ })
|
||||
.getByRole("button", { name: "Color Palette (Alt+P)" })
|
||||
.click(clickOptions);
|
||||
}
|
||||
|
||||
async clickColorPalette(clickOptions = {}) {
|
||||
await this.palette
|
||||
.getByRole("button", { name: "Color Palette (Alt+P)" })
|
||||
.click(clickOptions);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
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");
|
||||
});
|
||||
@@ -15,8 +15,6 @@ 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" }),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
|
||||
import { WasmWorkspacePage, WASM_FLAGS } from "../pages/WasmWorkspacePage";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await WasmWorkspacePage.init(page);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { WorkspacePage } from "../pages/WorkspacePage";
|
||||
const timeToWait = 100;
|
||||
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
await Clipboard.enable(context, Clipboard.Permission.ALL);
|
||||
await Clipboard.enable(context, Clipboard.Permission.ONLY_WRITE);
|
||||
|
||||
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.skip("Update an already created text shape by inserting text in between", async ({
|
||||
test("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.skip("Update a new text shape prepending text by pasting text", async ({
|
||||
test("Update a new text shape prepending text by pasting text", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
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(
|
||||
@@ -41,9 +34,9 @@ const setupVariantsFileWithVariant = async (workspacePage) => {
|
||||
await setupVariantsFile(workspacePage);
|
||||
|
||||
await workspacePage.clickLeafLayer("Rectangle");
|
||||
await workspacePage.page.keyboard.press("ControlOrMeta+k");
|
||||
await workspacePage.page.keyboard.press("Control+k");
|
||||
await workspacePage.page.waitForTimeout(500);
|
||||
await workspacePage.page.keyboard.press("ControlOrMeta+k");
|
||||
await workspacePage.page.keyboard.press("Control+k");
|
||||
await workspacePage.page.waitForTimeout(500);
|
||||
|
||||
// We wait until layer-row starts looking like it an component
|
||||
@@ -163,7 +156,7 @@ test("User duplicates a variant container", async ({ page }) => {
|
||||
await variant.container.click();
|
||||
|
||||
//Duplicate the variant container
|
||||
await workspacePage.page.keyboard.press("ControlOrMeta+d");
|
||||
await workspacePage.page.keyboard.press("Control+d");
|
||||
|
||||
const variant_original = await findVariant(workspacePage, 1); // On duplicate, the new item is the first
|
||||
const variant_duplicate = await findVariant(workspacePage, 0);
|
||||
@@ -176,27 +169,25 @@ test("User duplicates a variant container", async ({ page }) => {
|
||||
await validateVariant(variant_duplicate);
|
||||
});
|
||||
|
||||
test("User copy paste a variant container", async ({ page, context }) => {
|
||||
test("User copy paste a variant container", async ({ page }) => {
|
||||
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.clickLeafLayer("Rectangle");
|
||||
await workspacePage.copy("keyboard");
|
||||
await workspacePage.page.keyboard.press("Control+c");
|
||||
|
||||
// Paste the variant container
|
||||
await workspacePage.clickAt(400, 400);
|
||||
await workspacePage.paste("keyboard");
|
||||
|
||||
const variants = workspacePage.layers.getByText("Rectangle");
|
||||
await expect(variants).toHaveCount(2);
|
||||
await workspacePage.page.keyboard.press("Control+v");
|
||||
|
||||
const variantDuplicate = findVariantNoWait(workspacePage, 0);
|
||||
const variantOriginal = findVariantNoWait(workspacePage, 1);
|
||||
@@ -221,17 +212,18 @@ test("User cut paste a variant container", async ({ page }) => {
|
||||
await variant.container.click();
|
||||
|
||||
//Cut the variant container
|
||||
await workspacePage.cut("keyboard");
|
||||
await workspacePage.page.keyboard.press("Control+x");
|
||||
await workspacePage.page.waitForTimeout(500);
|
||||
|
||||
//Paste the variant container
|
||||
await workspacePage.clickAt(500, 500);
|
||||
await workspacePage.paste("keyboard");
|
||||
await workspacePage.page.keyboard.press("Control+v");
|
||||
await workspacePage.page.waitForTimeout(500);
|
||||
|
||||
const variantPasted = await findVariant(workspacePage, 0);
|
||||
|
||||
// Expand the layers
|
||||
await workspacePage.clickToggableLayer("Rectangle");
|
||||
await variantPasted.container.locator("button").first().click();
|
||||
|
||||
// The variants are valid
|
||||
await validateVariant(variantPasted);
|
||||
@@ -247,34 +239,27 @@ test("User cut paste a variant container into a board, and undo twice", async ({
|
||||
|
||||
//Create a board
|
||||
await workspacePage.boardButton.click();
|
||||
// NOTE: this board should not intersect the existing variants, otherwise
|
||||
// this test is flaky
|
||||
await workspacePage.clickWithDragViewportAt(200, 200, 100, 100);
|
||||
await workspacePage.clickWithDragViewportAt(500, 500, 100, 100);
|
||||
await workspacePage.clickAt(495, 495);
|
||||
const board = await workspacePage.rootShape.locator("Board");
|
||||
|
||||
// Select the variant container
|
||||
// await variant.container.click();
|
||||
await workspacePage.clickLeafLayer("Rectangle");
|
||||
await variant.container.click();
|
||||
|
||||
//Cut the variant container
|
||||
await workspacePage.cut("keyboard");
|
||||
await expect(variant.container).not.toBeVisible();
|
||||
await workspacePage.page.keyboard.press("Control+x");
|
||||
await workspacePage.page.waitForTimeout(500);
|
||||
|
||||
//Select the board
|
||||
await workspacePage.clickLeafLayer("Board");
|
||||
|
||||
//Paste the variant container inside the board
|
||||
await workspacePage.paste("keyboard");
|
||||
await expect(variant.container).toBeVisible();
|
||||
await workspacePage.page.keyboard.press("Control+v");
|
||||
|
||||
//Undo twice
|
||||
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();
|
||||
await workspacePage.page.keyboard.press("Control+z");
|
||||
await workspacePage.page.keyboard.press("Control+z");
|
||||
await workspacePage.page.waitForTimeout(500);
|
||||
|
||||
const variantAfterUndo = await findVariant(workspacePage, 0);
|
||||
|
||||
@@ -291,12 +276,12 @@ test("User copy paste a variant", async ({ page }) => {
|
||||
// Select the variant1
|
||||
await variant.variant1.click();
|
||||
|
||||
// Copy the variant
|
||||
await workspacePage.copy("keyboard");
|
||||
//Cut the variant
|
||||
await workspacePage.page.keyboard.press("Control+c");
|
||||
|
||||
// Paste the variant
|
||||
//Paste the variant
|
||||
await workspacePage.clickAt(500, 500);
|
||||
await workspacePage.paste("keyboard");
|
||||
await workspacePage.page.keyboard.press("Control+v");
|
||||
|
||||
const copy = await workspacePage.layers
|
||||
.getByTestId("layer-row")
|
||||
@@ -317,11 +302,11 @@ test("User cut paste a variant outside the container", async ({ page }) => {
|
||||
await variant.variant1.click();
|
||||
|
||||
//Cut the variant
|
||||
await workspacePage.cut("keyboard");
|
||||
await workspacePage.page.keyboard.press("Control+x");
|
||||
|
||||
//Paste the variant
|
||||
await workspacePage.clickAt(500, 500);
|
||||
await workspacePage.paste("keyboard");
|
||||
await workspacePage.page.keyboard.press("Control+v");
|
||||
|
||||
const component = await workspacePage.layers
|
||||
.getByTestId("layer-row")
|
||||
@@ -339,11 +324,15 @@ test("User drag and drop a variant outside the container", async ({ page }) => {
|
||||
const variant = await findVariant(workspacePage, 0);
|
||||
|
||||
// Drag and drop the variant
|
||||
// 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);
|
||||
await workspacePage.clickWithDragViewportAt(350, 400, 0, 200);
|
||||
|
||||
await expect(workspacePage.layers.getByText("Rectangle / Value 1")).toBeVisible();
|
||||
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();
|
||||
});
|
||||
|
||||
test("User cut paste a component inside a variant", async ({ page }) => {
|
||||
@@ -356,14 +345,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("ControlOrMeta+k");
|
||||
await workspacePage.page.keyboard.press("Control+k");
|
||||
|
||||
//Cut the component
|
||||
await workspacePage.cut("keyboard");
|
||||
await workspacePage.page.keyboard.press("Control+x");
|
||||
|
||||
//Paste the component inside the variant
|
||||
await variant.container.click();
|
||||
await workspacePage.paste("keyboard");
|
||||
await workspacePage.page.keyboard.press("Control+v");
|
||||
|
||||
const variant3 = await workspacePage.layers
|
||||
.getByTestId("layer-row")
|
||||
@@ -387,7 +376,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("ControlOrMeta+k");
|
||||
await workspacePage.page.keyboard.press("Control+k");
|
||||
|
||||
//Rename the component
|
||||
await workspacePage.layers.getByText("Ellipse").dblclick();
|
||||
@@ -398,11 +387,11 @@ test("User cut paste a component with path inside a variant", async ({
|
||||
await workspacePage.page.keyboard.press("Enter");
|
||||
|
||||
//Cut the component
|
||||
await workspacePage.cut("keyboard");
|
||||
await workspacePage.page.keyboard.press("Control+x");
|
||||
|
||||
//Paste the component inside the variant
|
||||
await variant.container.click();
|
||||
await workspacePage.paste("keyboard");
|
||||
await workspacePage.page.keyboard.press("Control+v");
|
||||
|
||||
const variant3 = await workspacePage.layers
|
||||
.getByTestId("layer-row")
|
||||
@@ -426,7 +415,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("ControlOrMeta+k");
|
||||
await workspacePage.page.keyboard.press("Control+k");
|
||||
|
||||
//Rename the component
|
||||
await workspacePage.layers.getByText("Ellipse").dblclick();
|
||||
@@ -437,7 +426,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, 200, 0);
|
||||
await workspacePage.clickWithDragViewportAt(510, 510, 0, -200);
|
||||
|
||||
const variant3 = await workspacePage.layers
|
||||
.getByTestId("layer-row")
|
||||
@@ -457,8 +446,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("ControlOrMeta+k");
|
||||
await workspacePage.page.keyboard.press("ControlOrMeta+k");
|
||||
await workspacePage.page.keyboard.press("Control+k");
|
||||
await workspacePage.page.keyboard.press("Control+k");
|
||||
|
||||
const variantOrigin = await findVariantNoWait(workspacePage, 1);
|
||||
|
||||
@@ -468,11 +457,11 @@ test("User cut paste a variant into another container", async ({ page }) => {
|
||||
await variantOrigin.variant1.click();
|
||||
|
||||
//Cut the variant
|
||||
await workspacePage.cut("keyboard");
|
||||
await workspacePage.page.keyboard.press("Control+x");
|
||||
|
||||
//Paste the variant
|
||||
await workspacePage.layers.getByText("Ellipse").first().click();
|
||||
await workspacePage.paste("keyboard");
|
||||
await workspacePage.page.keyboard.press("Control+v");
|
||||
|
||||
const variant3 = workspacePage.layers
|
||||
.getByTestId("layer-row")
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
|
||||
import { WorkspacePage } from "../pages/WorkspacePage";
|
||||
import { presenceFixture } from "../../data/workspace/ws-notifications";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await WasmWorkspacePage.init(page);
|
||||
await WorkspacePage.init(page);
|
||||
});
|
||||
|
||||
test("User loads worskpace with empty file", async ({ page }) => {
|
||||
const workspacePage = new WasmWorkspacePage(page);
|
||||
const workspacePage = new WorkspacePage(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 WasmWorkspacePage(page);
|
||||
const workspacePage = new WorkspacePage(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 WasmWorkspacePage(page);
|
||||
const workspacePage = new WorkspacePage(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 WasmWorkspacePage(page);
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await workspacePage.setupEmptyFile();
|
||||
await workspacePage.mockRPC(
|
||||
"update-file?id=*",
|
||||
@@ -52,12 +52,13 @@ test("User draws a rect", async ({ page }) => {
|
||||
await workspacePage.rectShapeButton.click();
|
||||
await workspacePage.clickWithDragViewportAt(128, 128, 200, 100);
|
||||
|
||||
await workspacePage.hideUI();
|
||||
await expect(workspacePage.canvas).toHaveScreenshot();
|
||||
const shape = await workspacePage.rootShape.locator("rect");
|
||||
await expect(shape).toHaveAttribute("width", "200");
|
||||
await expect(shape).toHaveAttribute("height", "100");
|
||||
});
|
||||
|
||||
test("User makes a group", async ({ page }) => {
|
||||
const workspacePage = new WasmWorkspacePage(page);
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await workspacePage.setupEmptyFile();
|
||||
await workspacePage.mockRPC(
|
||||
/get\-file\?/,
|
||||
@@ -73,14 +74,14 @@ test("User makes a group", async ({ page }) => {
|
||||
pageId: "6191cd35-bb1f-81f7-8004-7cc63d087375",
|
||||
});
|
||||
await workspacePage.clickLeafLayer("Rectangle");
|
||||
await workspacePage.page.keyboard.press("ControlOrMeta+g");
|
||||
await workspacePage.page.keyboard.press("Control+g");
|
||||
await workspacePage.expectSelectedLayer("Group");
|
||||
});
|
||||
|
||||
test("Bug 7654 - Toolbar keeps toggling on and off on spacebar press", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspacePage = new WasmWorkspacePage(page);
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await workspacePage.setupEmptyFile();
|
||||
await workspacePage.goToWorkspace();
|
||||
|
||||
@@ -90,10 +91,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 selection rectangle appears", async ({
|
||||
test("Bug 7525 - User moves a scrollbar and no selciont rectangle appears", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspacePage = new WasmWorkspacePage(page);
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await workspacePage.setupEmptyFile();
|
||||
await workspacePage.mockRPC(
|
||||
/get\-file\?/,
|
||||
@@ -109,8 +110,8 @@ test("Bug 7525 - User moves a scrollbar and no selection rectangle appears", asy
|
||||
pageId: "6191cd35-bb1f-81f7-8004-7cc63d087375",
|
||||
});
|
||||
|
||||
// Move created rect to a corner, in order to get scrollbars
|
||||
await workspacePage.panOnViewportAt(128, 128, 600, 600);
|
||||
// Move created rect to a corner, in orther to get scrollbars
|
||||
await workspacePage.panOnViewportAt(128, 128, 300, 300);
|
||||
|
||||
// Check scrollbars appear
|
||||
const horizontalScrollbar = workspacePage.horizontalScrollbar;
|
||||
@@ -129,7 +130,7 @@ test("Bug 7525 - User moves a scrollbar and no selection rectangle appears", asy
|
||||
test("User adds a library and its automatically selected in the color palette", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspacePage = new WasmWorkspacePage(page);
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await workspacePage.setupEmptyFile();
|
||||
await workspacePage.mockRPC(
|
||||
"link-file-to-library",
|
||||
@@ -174,7 +175,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 WasmWorkspacePage(page);
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await workspacePage.setupEmptyFile();
|
||||
await workspacePage.goToWorkspace();
|
||||
await workspacePage.moveButton.click();
|
||||
@@ -217,7 +218,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 WasmWorkspacePage(page);
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await workspacePage.setupEmptyFile();
|
||||
await workspacePage.goToWorkspace();
|
||||
|
||||
@@ -234,7 +235,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 WasmWorkspacePage(page);
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await workspacePage.setupEmptyFile();
|
||||
await workspacePage.goToWorkspace();
|
||||
await workspacePage.pageName.click();
|
||||
@@ -244,7 +245,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 WasmWorkspacePage(page);
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await workspacePage.setupEmptyFile(page);
|
||||
await workspacePage.mockRPC(/get\-file\?/, "workspace/get-file-9066.json");
|
||||
|
||||
@@ -272,7 +273,7 @@ test("Bug 9066 - Problem with grid layout", async ({ page }) => {
|
||||
});
|
||||
|
||||
test("User have toolbar", async ({ page }) => {
|
||||
const workspacePage = new WasmWorkspacePage(page);
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await workspacePage.setupEmptyFile(page);
|
||||
await workspacePage.goToWorkspace();
|
||||
|
||||
@@ -281,7 +282,7 @@ test("User have toolbar", async ({ page }) => {
|
||||
});
|
||||
|
||||
test("User have edition menu entries", async ({ page }) => {
|
||||
const workspacePage = new WasmWorkspacePage(page);
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await workspacePage.setupEmptyFile(page);
|
||||
await workspacePage.goToWorkspace();
|
||||
|
||||
@@ -297,7 +298,7 @@ test("User have edition menu entries", async ({ page }) => {
|
||||
});
|
||||
|
||||
test("Copy/paste properties", async ({ page, context }) => {
|
||||
const workspacePage = new WasmWorkspacePage(page);
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await workspacePage.setupEmptyFile(page);
|
||||
await workspacePage.mockRPC(
|
||||
/get\-file\?/,
|
||||
@@ -354,23 +355,23 @@ test("Copy/paste properties", async ({ page, context }) => {
|
||||
});
|
||||
|
||||
test("[Taiga #9929] Paste text in workspace", async ({ page, context }) => {
|
||||
const workspacePage = new WasmWorkspacePage(page);
|
||||
const workspacePage = new WorkspacePage(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(/^Paste/i).click();
|
||||
await page.getByText("PasteCtrlV").click();
|
||||
await workspacePage.viewport
|
||||
.getByRole("textbox")
|
||||
.getByText("Lorem ipsum dolor");
|
||||
});
|
||||
|
||||
test("[Taiga #9930] Zoom fit all doesn't fit all shapes", async ({
|
||||
test("[Taiga #9930] Zoom fit all doesn't fits all", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const workspacePage = new WasmWorkspacePage(page);
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await workspacePage.setupEmptyFile(page);
|
||||
await workspacePage.mockRPC(/get\-file\?/, "workspace/get-file-9930.json");
|
||||
await workspacePage.goToWorkspace({
|
||||
@@ -378,18 +379,16 @@ test("[Taiga #9930] Zoom fit all doesn't fit all shapes", async ({
|
||||
pageId: "fb9798e7-a547-80ae-8005-9ffda4a13e2c",
|
||||
});
|
||||
|
||||
const zoom = page.getByTitle("Zoom");
|
||||
const zoom = await page.getByTitle("Zoom");
|
||||
await zoom.click();
|
||||
|
||||
const zoomIn = page.getByRole("button", { name: "Zoom in" });
|
||||
const zoomIn = await 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",
|
||||
@@ -411,7 +410,7 @@ test("[Taiga #9930] Zoom fit all doesn't fit all shapes", async ({
|
||||
|
||||
const viewportBoundingBox = await workspacePage.viewport.boundingBox();
|
||||
for (const id of ids) {
|
||||
const shape = page.locator(`.viewport-selrect`);
|
||||
const shape = await page.locator(`.ws-shape-wrapper > g#${id}`);
|
||||
const shapeBoundingBox = await shape.boundingBox();
|
||||
expect(contains(viewportBoundingBox, shapeBoundingBox)).toBeTruthy();
|
||||
}
|
||||
@@ -420,7 +419,7 @@ test("[Taiga #9930] Zoom fit all doesn't fit all shapes", async ({
|
||||
test("Bug 9877, user navigation to dashboard from header goes to blank page", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspacePage = new WasmWorkspacePage(page);
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await workspacePage.setupEmptyFile(page);
|
||||
|
||||
await workspacePage.goToWorkspace();
|
||||
@@ -437,7 +436,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 WasmWorkspacePage(page);
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await workspacePage.setupEmptyFile(page);
|
||||
await workspacePage.mockGetFile("workspace/get-file-8371.json");
|
||||
await workspacePage.goToWorkspace({
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB |
@@ -1,90 +0,0 @@
|
||||
<!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>
|
||||
@@ -179,56 +179,6 @@
|
||||
(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))
|
||||
@@ -675,17 +625,8 @@
|
||||
|
||||
(let [objects (dsh/lookup-page-objects state)
|
||||
|
||||
geometry-entries
|
||||
(parse-geometry-modifiers modif-tree)
|
||||
|
||||
snap-pixel?
|
||||
(and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))
|
||||
|
||||
transforms
|
||||
(into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?))
|
||||
|
||||
ignore-tree
|
||||
(calculate-ignore-tree-wasm transforms objects)
|
||||
(calculate-ignore-tree modif-tree objects)
|
||||
|
||||
options
|
||||
(-> params
|
||||
@@ -695,6 +636,15 @@
|
||||
;; way we don't have to check all the attributes
|
||||
(assoc :attrs transform-attrs))
|
||||
|
||||
geometry-entries
|
||||
(parse-geometry-modifiers modif-tree)
|
||||
|
||||
snap-pixel?
|
||||
(and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))
|
||||
|
||||
transforms
|
||||
(into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?))
|
||||
|
||||
modif-tree
|
||||
(propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state))
|
||||
|
||||
|
||||
@@ -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 (remove uuid/zero?) (filter #(contains? objects %)))]
|
||||
ids (->> ids (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")
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
[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]
|
||||
@@ -776,7 +777,20 @@
|
||||
(rx/of (v2-update-text-editor-styles id attrs)))
|
||||
|
||||
(when (features/active-feature? state "render-wasm/v1")
|
||||
(rx/of (dwwt/resize-wasm-text-debounce id)))))))
|
||||
(rx/concat
|
||||
;; Apply style to selected spans and sync content
|
||||
(when (wasm.api/text-editor-is-active?)
|
||||
(let [span-attrs (select-keys attrs txt/text-node-attrs)]
|
||||
(when (not (empty? span-attrs))
|
||||
(let [result (wasm.api/apply-style-to-selection span-attrs)]
|
||||
(when result
|
||||
(rx/of (v2-update-text-shape-content
|
||||
(:shape-id result) (:content result)
|
||||
:update-name? true)))))))
|
||||
;; Resize (with delay for font-id changes)
|
||||
(cond->> (rx/of (dwwt/resize-wasm-text id))
|
||||
(contains? attrs :font-id)
|
||||
(rx/delay 200))))))))
|
||||
|
||||
ptk/EffectEvent
|
||||
(effect [_ state _]
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
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]
|
||||
@@ -18,7 +17,6 @@
|
||||
[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]))
|
||||
|
||||
@@ -64,84 +62,6 @@
|
||||
(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]
|
||||
|
||||
@@ -36,12 +36,10 @@
|
||||
|
||||
(defn- hide-popover
|
||||
[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)))
|
||||
(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
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
(def context (mf/create-context nil))
|
||||
|
||||
(mf/defc form-input*
|
||||
[{:keys [name trim] :rest props}]
|
||||
[{:keys [name] :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 trim))))
|
||||
(fm/on-input-change form input-name value true))))
|
||||
|
||||
props
|
||||
(mf/spread-props props {:on-change on-change
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
(def ^:private schema:properties-row
|
||||
[:map
|
||||
[:term :string]
|
||||
[:detail {:optional true} [:maybe :string]]
|
||||
[:detail :string]
|
||||
[:property {:optional true} :string] ;; CSS valid property
|
||||
[:token {:optional true} :any] ;; resolved token object
|
||||
[:copiable {:optional true} :boolean]])
|
||||
|
||||
@@ -78,15 +78,13 @@
|
||||
(fn []
|
||||
(close-modals)
|
||||
;; FIXME: move set-mode to uri?
|
||||
(st/emit! :interrupt
|
||||
(dw/set-options-mode :design)
|
||||
(st/emit! (dw/set-options-mode :design)
|
||||
(dcm/go-to-dashboard-recent))))
|
||||
|
||||
nav-to-project
|
||||
(mf/use-fn
|
||||
(mf/deps project-id)
|
||||
#(st/emit! :interrupt
|
||||
(dcm/go-to-dashboard-files ::rt/new-window true :project-id project-id)))]
|
||||
#(st/emit! (dcm/go-to-dashboard-files ::rt/new-window true :project-id project-id)))]
|
||||
|
||||
(mf/with-effect [editing?]
|
||||
(when ^boolean editing?
|
||||
|
||||
@@ -345,11 +345,9 @@
|
||||
max-height (max height selrect-height)
|
||||
valign (-> shape :content :vertical-align)
|
||||
y (:y selrect)
|
||||
y (if (and valign (> height selrect-height))
|
||||
(case valign
|
||||
"bottom" (- y (- height selrect-height))
|
||||
"center" (- y (/ (- height selrect-height) 2))
|
||||
y)
|
||||
y (case valign
|
||||
"bottom" (+ y (- selrect-height height))
|
||||
"center" (+ y (/ (- selrect-height height) 2))
|
||||
y)]
|
||||
[(assoc selrect :y y :width max-width :height max-height) transform])
|
||||
|
||||
@@ -401,8 +399,7 @@
|
||||
(dm/fmt "scale(%)" maybe-zoom))}))]
|
||||
|
||||
[:g.text-editor {:clip-path (dm/fmt "url(#%)" clip-id)
|
||||
:transform (dm/str transform)
|
||||
:data-testid "text-editor"}
|
||||
:transform (dm/str transform)}
|
||||
[:defs
|
||||
[:clipPath {:id clip-id}
|
||||
[:rect {:x x :y y :width width :height height}]]]
|
||||
|
||||
@@ -119,9 +119,6 @@
|
||||
[: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])
|
||||
|
||||
|
||||
@@ -108,7 +108,6 @@
|
||||
: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
|
||||
@@ -119,7 +118,6 @@
|
||||
: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}
|
||||
|
||||
@@ -590,8 +590,7 @@
|
||||
:values values}]
|
||||
|
||||
[:div {:class (stl/css :rotation)
|
||||
:title (tr "workspace.options.rotation")
|
||||
:data-testid "rotation"}
|
||||
:title (tr "workspace.options.rotation")}
|
||||
[:span {:class (stl/css :icon)} deprecated-icon/rotation]
|
||||
[:> deprecated-input/numeric-input*
|
||||
{:no-validate true
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
[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]
|
||||
@@ -52,15 +51,12 @@
|
||||
;; Both variants provide identical color-picker and text-input behavior, but
|
||||
;; differ in how they persist the value within the form’s nested structure.
|
||||
|
||||
|
||||
(defn- resolve-value
|
||||
[tokens prev-token token-name value]
|
||||
(let [valid-token-name?
|
||||
(and (string? token-name)
|
||||
(re-matches cto/token-name-validation-regex token-name))
|
||||
|
||||
token
|
||||
(let [token
|
||||
{:value value
|
||||
:name (if (or (not valid-token-name?) (str/blank? token-name))
|
||||
:name (if (str/blank? token-name)
|
||||
"__PENPOT__TOKEN__NAME__PLACEHOLDER__"
|
||||
token-name)}
|
||||
|
||||
|
||||
@@ -50,13 +50,9 @@
|
||||
|
||||
(defn- resolve-value
|
||||
[tokens prev-token token-name value]
|
||||
(let [valid-token-name?
|
||||
(and (string? token-name)
|
||||
(re-matches cto/token-name-validation-regex token-name))
|
||||
|
||||
token
|
||||
(let [token
|
||||
{:value (cto/split-font-family value)
|
||||
:name (if (or (not valid-token-name?) (str/blank? token-name))
|
||||
:name (if (str/blank? token-name)
|
||||
"__PENPOT__TOKEN__NAME__PLACEHOLDER__"
|
||||
token-name)}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
(: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]
|
||||
@@ -141,13 +140,9 @@
|
||||
|
||||
(defn- resolve-value
|
||||
[tokens prev-token token-name value]
|
||||
(let [valid-token-name?
|
||||
(and (string? token-name)
|
||||
(re-matches cto/token-name-validation-regex token-name))
|
||||
|
||||
token
|
||||
(let [token
|
||||
{:value value
|
||||
:name (if (or (not valid-token-name?) (str/blank? token-name))
|
||||
:name (if (str/blank? token-name)
|
||||
"__PENPOT__TOKEN__NAME__PLACEHOLDER__"
|
||||
token-name)}
|
||||
tokens
|
||||
|
||||
@@ -230,7 +230,6 @@
|
||||
: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"))
|
||||
|
||||
@@ -19,10 +19,14 @@
|
||||
[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]
|
||||
@@ -91,7 +95,17 @@
|
||||
::dwsp/interrupt)
|
||||
|
||||
(when (and (not= edition id) (or text-editing? grid-editing?))
|
||||
(st/emit! (dw/clear-edition-mode)))
|
||||
(st/emit! (dw/clear-edition-mode))
|
||||
;; Sync and stop WASM text editor when exiting edit mode
|
||||
(when (and text-editing?
|
||||
(features/active-feature? @st/state "render-wasm/v1")
|
||||
wasm.wasm/context-initialized?)
|
||||
(when-let [{:keys [shape-id content]} (wasm.api/text-editor-sync-content)]
|
||||
(st/emit! (dwt/v2-update-text-shape-content
|
||||
shape-id content
|
||||
:update-name? true
|
||||
:finalize? true)))
|
||||
(wasm.api/text-editor-stop)))
|
||||
|
||||
(when (and (not text-editing?)
|
||||
(not blocked)
|
||||
@@ -184,6 +198,21 @@
|
||||
(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)
|
||||
@@ -223,8 +252,15 @@
|
||||
(when (and (not drawing-path?) shape)
|
||||
(cond
|
||||
(and editable? (not= id edition) (not read-only?))
|
||||
(st/emit! (dw/select-shape id)
|
||||
(dw/start-editing-selected))
|
||||
(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)))
|
||||
|
||||
(some? selected-shape)
|
||||
(do
|
||||
@@ -346,7 +382,9 @@
|
||||
(let [last-position (mf/use-var nil)]
|
||||
(mf/use-fn
|
||||
(fn [event]
|
||||
(let [raw-pt (dom/get-client-position event)
|
||||
(let [native-event (unchecked-get event "nativeEvent")
|
||||
off-pt (dom/get-offset-position native-event)
|
||||
raw-pt (dom/get-client-position event)
|
||||
pt (uwvv/point->viewport raw-pt)
|
||||
|
||||
;; We calculate the delta because Safari's MouseEvent.movementX/Y drop
|
||||
@@ -355,6 +393,8 @@
|
||||
(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
|
||||
|
||||
@@ -164,7 +164,6 @@
|
||||
;; 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)
|
||||
@@ -316,7 +315,7 @@
|
||||
(and (cfh/group-shape? objects %)
|
||||
(not (contains? child-parent? %)))
|
||||
(and (features/active-feature? @st/state "render-wasm/v1")
|
||||
(cfh/text-shape? objects %)
|
||||
(cfh/text-shape? (get objects %))
|
||||
(not (wasm.api/intersect-position-in-shape % @last-point-ref)))))))
|
||||
|
||||
remove-measure-xf
|
||||
|
||||
@@ -75,31 +75,32 @@
|
||||
[{: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?))]
|
||||
(when (and (some? leftmost) (some? topmost) (some? rightmost))
|
||||
(let [left-top (gpt/to-vec leftmost topmost)
|
||||
left-top-angle (gpt/angle left-top)
|
||||
rightmost (->> points (remove #{leftmost topmost}) (reduce right?))
|
||||
|
||||
top-right (gpt/to-vec topmost rightmost)
|
||||
top-right-angle (gpt/angle top-right)
|
||||
left-top (gpt/to-vec leftmost topmost)
|
||||
left-top-angle (gpt/angle left-top)
|
||||
|
||||
;; 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)])
|
||||
top-right (gpt/to-vec topmost rightmost)
|
||||
top-right-angle (gpt/angle top-right)
|
||||
|
||||
delta-x (if grid-edition? 40 0)
|
||||
delta-y (if grid-edition? 50 10)
|
||||
;; 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)])
|
||||
|
||||
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)))))))
|
||||
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)))))
|
||||
|
||||
@@ -66,6 +66,14 @@
|
||||
(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))
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
[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]
|
||||
@@ -407,7 +408,14 @@
|
||||
|
||||
(when picking-color?
|
||||
[:> pixel-overlay/pixel-overlay-wasm* {:viewport-ref viewport-ref
|
||||
:canvas-ref canvas-ref}])]
|
||||
:canvas-ref canvas-ref}])
|
||||
|
||||
;; WASM text editor contenteditable (must be outside SVG to work)
|
||||
(when (and show-text-editor?
|
||||
(features/active-feature? @st/state "text-editor-wasm/v1"))
|
||||
[:& text-editor-input {:shape editing-shape
|
||||
:zoom zoom
|
||||
:vbox vbox}])]
|
||||
|
||||
[:canvas {:id "render"
|
||||
:data-testid "canvas-wasm-shapes"
|
||||
@@ -452,7 +460,10 @@
|
||||
:height (max 0 (- (:height vbox) rule-area-size))}]]]
|
||||
|
||||
[:g {:style {:pointer-events (if disable-events? "none" "auto")}}
|
||||
(when show-text-editor?
|
||||
;; Text editor handling:
|
||||
;; - When text-editor-wasm/v1 is active, contenteditable is rendered in viewport-overlays (HTML DOM)
|
||||
(when (and show-text-editor?
|
||||
(not (features/active-feature? @st/state "text-editor-wasm/v1")))
|
||||
(if (features/active-feature? @st/state "text-editor/v2")
|
||||
[:& editor-v2/text-editor {:shape editing-shape
|
||||
:canvas-ref canvas-ref
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
[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]
|
||||
@@ -339,14 +338,9 @@
|
||||
|
||||
:else
|
||||
(let [page (dsh/lookup-page @st/state)
|
||||
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))
|
||||
shape (-> (cts/setup-shape {:type :text :x 0 :y 0 :grow-type :auto-width})
|
||||
(update :content txt/change-text text)
|
||||
(assoc :position-data nil))
|
||||
|
||||
changes
|
||||
(-> (cb/empty-changes)
|
||||
@@ -354,9 +348,7 @@
|
||||
(cb/with-objects (:objects page))
|
||||
(cb/add-object shape))]
|
||||
|
||||
(st/emit!
|
||||
(ch/commit-changes changes)
|
||||
(dwwt/resize-wasm-text-debounce (:id shape)))
|
||||
(st/emit! (ch/commit-changes changes))
|
||||
(shape/shape-proxy plugin-id (:id shape)))))
|
||||
|
||||
:createShapeFromSvg
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
[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]
|
||||
@@ -74,6 +75,19 @@
|
||||
;; 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))
|
||||
|
||||
@@ -109,11 +123,36 @@
|
||||
(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"))))
|
||||
|
||||
@@ -187,25 +226,6 @@
|
||||
|
||||
(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?
|
||||
@@ -216,6 +236,47 @@
|
||||
(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)]
|
||||
@@ -859,22 +920,6 @@
|
||||
|
||||
(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)))
|
||||
@@ -1066,7 +1111,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]
|
||||
@@ -1541,33 +1586,41 @@
|
||||
(persistent! result)))
|
||||
|
||||
result
|
||||
(->> 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)]
|
||||
(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)]
|
||||
|
||||
(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}))))))]
|
||||
;; 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)]
|
||||
(mem/free)
|
||||
|
||||
result)))
|
||||
@@ -1601,7 +1654,4 @@
|
||||
(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)
|
||||
|
||||
|
||||
@@ -97,8 +97,9 @@
|
||||
|
||||
;; IMPORTANT: Only TTF fonts can be stored.
|
||||
(defn- store-font-buffer
|
||||
[font-data font-array-buffer emoji? fallback?]
|
||||
[shape-id 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")
|
||||
@@ -106,6 +107,10 @@
|
||||
|
||||
(.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)
|
||||
@@ -114,31 +119,24 @@
|
||||
(: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
|
||||
[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)))))}))
|
||||
[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))))})
|
||||
|
||||
(defn- google-font-ttf-url
|
||||
[font-id font-variant-id font-weight font-style]
|
||||
@@ -157,31 +155,22 @@
|
||||
: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
|
||||
[font-data asset-id emoji? fallback?]
|
||||
[shape-id 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? (font-stored? font-data emoji?)]
|
||||
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?))]
|
||||
(when-not font-stored?
|
||||
(fetch-font font-data uri emoji? fallback?)))))
|
||||
(fetch-font shape-id font-data uri emoji? fallback?)))))
|
||||
|
||||
(defn serialize-font-style
|
||||
[font-style]
|
||||
@@ -291,8 +280,8 @@
|
||||
"regular"
|
||||
font-variant-id))
|
||||
|
||||
(defn make-font-data
|
||||
[font]
|
||||
(defn store-font
|
||||
[shape-id font]
|
||||
(let [font-id (get font :font-id)
|
||||
font-variant-id (get font :font-variant-id)
|
||||
normalized-variant-id (when font-variant-id
|
||||
@@ -312,21 +301,14 @@
|
||||
(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)]
|
||||
{: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?)))
|
||||
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?)))
|
||||
|
||||
;; 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.
|
||||
@@ -359,8 +341,8 @@
|
||||
#{}))))
|
||||
|
||||
(defn store-fonts
|
||||
[fonts]
|
||||
(keep (fn [font] (store-font font)) fonts))
|
||||
[shape-id fonts]
|
||||
(keep (fn [font] (store-font shape-id font)) fonts))
|
||||
|
||||
(defn add-emoji-font
|
||||
[fonts]
|
||||
|
||||
@@ -61,6 +61,29 @@
|
||||
[]
|
||||
(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."
|
||||
|
||||
305
frontend/src/app/render_wasm/text_editor.cljs
Normal file
305
frontend/src/app/render_wasm/text_editor.cljs
Normal file
@@ -0,0 +1,305 @@
|
||||
;; 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}))))))))
|
||||
240
frontend/src/app/render_wasm/text_editor_input.cljs
Normal file
240
frontend/src/app/render_wasm/text_editor_input.cljs
Normal file
@@ -0,0 +1,240 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.render-wasm.text-editor-input
|
||||
"Contenteditable DOM element for WASM text editor input"
|
||||
(:require
|
||||
[app.common.geom.shapes :as gsh]
|
||||
[app.main.data.workspace.texts :as dwt]
|
||||
[app.main.store :as st]
|
||||
[app.render-wasm.api :as wasm.api]
|
||||
[app.render-wasm.text-editor :as text-editor]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.object :as obj]
|
||||
[cuerdas.core :as str]
|
||||
[goog.events :as events]
|
||||
[rumext.v2 :as mf])
|
||||
(:import goog.events.EventType))
|
||||
|
||||
(defn- sync-wasm-text-editor-content!
|
||||
"Sync WASM text editor content back to the shape via the standard
|
||||
commit pipeline. Called after every text-modifying input."
|
||||
[& {:keys [finalize?]}]
|
||||
(when-let [{:keys [shape-id content]} (text-editor/text-editor-sync-content)]
|
||||
(st/emit! (dwt/v2-update-text-shape-content
|
||||
shape-id content
|
||||
:update-name? true
|
||||
:finalize? finalize?))))
|
||||
|
||||
(mf/defc text-editor-input
|
||||
"Contenteditable element positioned over the text shape to capture input events."
|
||||
{::mf/wrap-props false}
|
||||
[props]
|
||||
(let [shape (obj/get props "shape")
|
||||
zoom (obj/get props "zoom")
|
||||
vbox (obj/get props "vbox")
|
||||
|
||||
contenteditable-ref (mf/use-ref nil)
|
||||
composing? (mf/use-state false)
|
||||
|
||||
;; Calculate screen position from shape bounds
|
||||
shape-bounds (gsh/shape->rect shape)
|
||||
screen-x (* (- (:x shape-bounds) (:x vbox)) zoom)
|
||||
screen-y (* (- (:y shape-bounds) (:y vbox)) zoom)
|
||||
screen-w (* (:width shape-bounds) zoom)
|
||||
screen-h (* (:height shape-bounds) zoom)]
|
||||
|
||||
;; Focus contenteditable on mount
|
||||
(mf/use-effect
|
||||
(fn []
|
||||
(when-let [node (mf/ref-val contenteditable-ref)]
|
||||
(.focus node))
|
||||
js/undefined))
|
||||
|
||||
;; Animation loop for cursor blink
|
||||
(mf/use-effect
|
||||
(fn []
|
||||
(let [raf-id (atom nil)
|
||||
animate (fn animate []
|
||||
(when (text-editor/text-editor-is-active?)
|
||||
(wasm.api/request-render "cursor-blink")
|
||||
(reset! raf-id (js/requestAnimationFrame animate))))]
|
||||
(animate)
|
||||
(fn []
|
||||
(when @raf-id
|
||||
(js/cancelAnimationFrame @raf-id))))))
|
||||
|
||||
;; Document-level keydown handler for control keys
|
||||
(mf/use-effect
|
||||
(fn []
|
||||
(let [on-doc-keydown
|
||||
(fn [e]
|
||||
(when (and (text-editor/text-editor-is-active?)
|
||||
(not @composing?))
|
||||
(let [key (.-key e)
|
||||
ctrl? (or (.-ctrlKey e) (.-metaKey e))
|
||||
shift? (.-shiftKey e)]
|
||||
(cond
|
||||
;; Escape: finalize and stop
|
||||
(= key "Escape")
|
||||
(do
|
||||
(dom/prevent-default e)
|
||||
(sync-wasm-text-editor-content! :finalize? true)
|
||||
(text-editor/text-editor-stop))
|
||||
|
||||
;; Ctrl+A: select all (key is "a" or "A" depending on platform)
|
||||
(and ctrl? (= (str/lower key) "a"))
|
||||
(do
|
||||
(dom/prevent-default e)
|
||||
(text-editor/text-editor-select-all)
|
||||
(wasm.api/request-render "text-select-all"))
|
||||
|
||||
;; Enter
|
||||
(= key "Enter")
|
||||
(do
|
||||
(dom/prevent-default e)
|
||||
(text-editor/text-editor-insert-paragraph)
|
||||
(sync-wasm-text-editor-content!)
|
||||
(wasm.api/request-render "text-paragraph"))
|
||||
|
||||
;; Backspace
|
||||
(= key "Backspace")
|
||||
(do
|
||||
(dom/prevent-default e)
|
||||
(text-editor/text-editor-delete-backward)
|
||||
(sync-wasm-text-editor-content!)
|
||||
(wasm.api/request-render "text-delete-backward"))
|
||||
|
||||
;; Delete
|
||||
(= key "Delete")
|
||||
(do
|
||||
(dom/prevent-default e)
|
||||
(text-editor/text-editor-delete-forward)
|
||||
(sync-wasm-text-editor-content!)
|
||||
(wasm.api/request-render "text-delete-forward"))
|
||||
|
||||
;; Arrow keys
|
||||
(= key "ArrowLeft")
|
||||
(do
|
||||
(dom/prevent-default e)
|
||||
(text-editor/text-editor-move-cursor 0 shift?)
|
||||
(wasm.api/request-render "text-cursor-move"))
|
||||
|
||||
(= key "ArrowRight")
|
||||
(do
|
||||
(dom/prevent-default e)
|
||||
(text-editor/text-editor-move-cursor 1 shift?)
|
||||
(wasm.api/request-render "text-cursor-move"))
|
||||
|
||||
(= key "ArrowUp")
|
||||
(do
|
||||
(dom/prevent-default e)
|
||||
(text-editor/text-editor-move-cursor 2 shift?)
|
||||
(wasm.api/request-render "text-cursor-move"))
|
||||
|
||||
(= key "ArrowDown")
|
||||
(do
|
||||
(dom/prevent-default e)
|
||||
(text-editor/text-editor-move-cursor 3 shift?)
|
||||
(wasm.api/request-render "text-cursor-move"))
|
||||
|
||||
(= key "Home")
|
||||
(do
|
||||
(dom/prevent-default e)
|
||||
(text-editor/text-editor-move-cursor 4 shift?)
|
||||
(wasm.api/request-render "text-cursor-move"))
|
||||
|
||||
(= key "End")
|
||||
(do
|
||||
(dom/prevent-default e)
|
||||
(text-editor/text-editor-move-cursor 5 shift?)
|
||||
(wasm.api/request-render "text-cursor-move"))
|
||||
|
||||
;; Let contenteditable handle text input via on-input
|
||||
:else nil))))]
|
||||
(events/listen js/document EventType.KEYDOWN on-doc-keydown true)
|
||||
(fn []
|
||||
(events/unlisten js/document EventType.KEYDOWN on-doc-keydown true)))))
|
||||
|
||||
;; Composition and input events
|
||||
(let [on-composition-start
|
||||
(mf/use-fn
|
||||
(fn [_event]
|
||||
(reset! composing? true)))
|
||||
|
||||
on-composition-end
|
||||
(mf/use-fn
|
||||
(fn [^js event]
|
||||
(reset! composing? false)
|
||||
(let [data (.-data event)]
|
||||
(when data
|
||||
(text-editor/text-editor-insert-text data)
|
||||
(sync-wasm-text-editor-content!)
|
||||
(wasm.api/request-render "text-composition"))
|
||||
(when-let [node (mf/ref-val contenteditable-ref)]
|
||||
(set! (.-textContent node) "")))))
|
||||
|
||||
on-paste
|
||||
(mf/use-fn
|
||||
(fn [^js event]
|
||||
(dom/prevent-default event)
|
||||
(let [clipboard-data (.-clipboardData event)
|
||||
text (.getData clipboard-data "text/plain")]
|
||||
(when (and text (seq text))
|
||||
(text-editor/text-editor-insert-text text)
|
||||
(sync-wasm-text-editor-content!)
|
||||
(wasm.api/request-render "text-paste"))
|
||||
(when-let [node (mf/ref-val contenteditable-ref)]
|
||||
(set! (.-textContent node) "")))))
|
||||
|
||||
on-copy
|
||||
(mf/use-fn
|
||||
(fn [^js event]
|
||||
(when (text-editor/text-editor-is-active?)
|
||||
(dom/prevent-default event)
|
||||
(when (text-editor/text-editor-get-selection)
|
||||
(let [text (text-editor/text-editor-export-selection)]
|
||||
(.setData (.-clipboardData event) "text/plain" text))))))
|
||||
|
||||
on-input
|
||||
(mf/use-fn
|
||||
(fn [^js event]
|
||||
(let [native-event (.-nativeEvent event)
|
||||
input-type (.-inputType native-event)
|
||||
data (.-data native-event)]
|
||||
;; Skip composition-related input events - composition-end handles those
|
||||
(when (and (not @composing?)
|
||||
(not= input-type "insertCompositionText"))
|
||||
(when (and data (seq data))
|
||||
(text-editor/text-editor-insert-text data)
|
||||
(sync-wasm-text-editor-content!)
|
||||
(wasm.api/request-render "text-input"))
|
||||
(when-let [node (mf/ref-val contenteditable-ref)]
|
||||
(set! (.-textContent node) ""))))))]
|
||||
|
||||
[:div
|
||||
{:ref contenteditable-ref
|
||||
:contentEditable true
|
||||
:suppressContentEditableWarning true
|
||||
:on-composition-start on-composition-start
|
||||
:on-composition-end on-composition-end
|
||||
:on-input on-input
|
||||
:on-paste on-paste
|
||||
:on-copy on-copy
|
||||
;; FIXME on-click
|
||||
;; :on-click on-click
|
||||
:id "text-editor-wasm-input"
|
||||
;; FIXME
|
||||
:style {:position "absolute"
|
||||
:left (str screen-x "px")
|
||||
:top (str screen-y "px")
|
||||
:width (str screen-w "px")
|
||||
:height (str screen-h "px")
|
||||
:opacity 0
|
||||
:overflow "hidden"
|
||||
:white-space "pre"
|
||||
:cursor "text"
|
||||
:z-index 10}}])))
|
||||
@@ -106,20 +106,17 @@
|
||||
|
||||
(defn stop-propagation
|
||||
[^js event]
|
||||
(when (and (some? event)
|
||||
(fn? (.-stopPropagation event)))
|
||||
(when event
|
||||
(.stopPropagation event)))
|
||||
|
||||
(defn stop-immediate-propagation
|
||||
[^js event]
|
||||
(when (and (some? event)
|
||||
(fn? (.-stopImmediatePropagation event)))
|
||||
(when event
|
||||
(.stopImmediatePropagation event)))
|
||||
|
||||
(defn prevent-default
|
||||
[^js event]
|
||||
(when (and (some? event)
|
||||
(fn? (.-preventDefault event)))
|
||||
(when event
|
||||
(.preventDefault event)))
|
||||
|
||||
(defn get-target
|
||||
|
||||
@@ -7,16 +7,15 @@
|
||||
(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 [_]
|
||||
(dom/prevent-default native-event))
|
||||
(.preventDefault native-event))
|
||||
|
||||
(stopPropagation [_]
|
||||
(dom/stop-propagation native-event)))
|
||||
(.stopPropagation native-event)))
|
||||
|
||||
(defn keyboard-event?
|
||||
[o]
|
||||
|
||||
@@ -405,8 +405,12 @@ 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);
|
||||
this.#notifyLayout(LayoutType.FULL);
|
||||
const mutations = this.#selectionController.endMutation();
|
||||
this.#notifyLayout(LayoutType.FULL, mutations);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -452,12 +456,19 @@ 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();
|
||||
}
|
||||
this.#notifyLayout(LayoutType.FULL);
|
||||
|
||||
const mutations = this.#selectionController.endMutation();
|
||||
this.#notifyLayout(LayoutType.FULL, mutations);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -465,12 +476,14 @@ export class TextEditor extends EventTarget {
|
||||
* Notifies that the edited texts needs layout.
|
||||
*
|
||||
* @param {'full'|'partial'} type
|
||||
* @param {CommandMutations} mutations
|
||||
*/
|
||||
#notifyLayout(type = LayoutType.FULL) {
|
||||
#notifyLayout(type = LayoutType.FULL, mutations) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("needslayout", {
|
||||
detail: {
|
||||
type: type,
|
||||
mutations: mutations,
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -617,8 +630,10 @@ export class TextEditor extends EventTarget {
|
||||
* @returns {TextEditor}
|
||||
*/
|
||||
applyStylesToSelection(styles) {
|
||||
this.#selectionController.startMutation();
|
||||
this.#selectionController.applyStyles(styles);
|
||||
this.#notifyLayout(LayoutType.FULL);
|
||||
const mutations = this.#selectionController.endMutation();
|
||||
this.#notifyLayout(LayoutType.FULL, mutations);
|
||||
this.#changeController.notifyImmediately();
|
||||
return this;
|
||||
}
|
||||
|
||||
66
frontend/text-editor/src/editor/commands/CommandMutations.js
Normal file
66
frontend/text-editor/src/editor/commands/CommandMutations.js
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,71 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { insertInto, removeSlice, removeBackward, removeForward, removeWordBackward, replaceWith, findPreviousWordBoundary } from "./Text";
|
||||
import { insertInto, removeBackward, removeForward, replaceWith } from "./Text";
|
||||
|
||||
describe("Text", () => {
|
||||
test("* should throw when passed wrong parameters", () => {
|
||||
@@ -51,23 +51,4 @@ 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, ");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, test, expect } from "vitest";
|
||||
import { getFills } from "./Color.js";
|
||||
|
||||
/* @vitest-environment jsdom */
|
||||
describe.skip("Color", () => {
|
||||
describe("Color", () => {
|
||||
test("getFills", () => {
|
||||
expect(getFills("#aa0000")).toBe(
|
||||
'[["^ ","~:fill-color","#aa0000","~:fill-opacity",1]]',
|
||||
|
||||
@@ -49,6 +49,7 @@ 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";
|
||||
@@ -144,6 +145,13 @@ export class SelectionController extends EventTarget {
|
||||
*/
|
||||
#debug = null;
|
||||
|
||||
/**
|
||||
* Command Mutations.
|
||||
*
|
||||
* @type {CommandMutations}
|
||||
*/
|
||||
#mutations = new CommandMutations();
|
||||
|
||||
/**
|
||||
* Style defaults.
|
||||
*
|
||||
@@ -441,14 +449,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -514,6 +522,28 @@ 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.
|
||||
*
|
||||
@@ -567,18 +597,11 @@ 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.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.selectNodeContents(this.#textEditor.element);
|
||||
range.collapse(false);
|
||||
|
||||
this.#selection.removeAllRanges();
|
||||
this.#selection.addRange(range);
|
||||
|
||||
this.#updateState();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -1317,6 +1340,7 @@ export class SelectionController extends EventTarget {
|
||||
|
||||
if (this.focusNode.nodeValue !== removedData) {
|
||||
this.focusNode.nodeValue = removedData;
|
||||
this.#mutations.update(this.focusTextSpan);
|
||||
}
|
||||
|
||||
const paragraph = this.focusParagraph;
|
||||
@@ -1359,6 +1383,7 @@ export class SelectionController extends EventTarget {
|
||||
this.focusOffset,
|
||||
newText,
|
||||
);
|
||||
this.#mutations.update(this.focusTextSpan);
|
||||
return this.collapse(this.focusNode, this.focusOffset + newText.length);
|
||||
}
|
||||
|
||||
@@ -1422,6 +1447,7 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1499,6 +1525,8 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1509,6 +1537,8 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1523,6 +1553,8 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1554,6 +1586,10 @@ export class SelectionController extends EventTarget {
|
||||
this.focusOffset,
|
||||
);
|
||||
currentParagraph.after(newParagraph);
|
||||
|
||||
this.#mutations.update(currentParagraph);
|
||||
this.#mutations.add(newParagraph);
|
||||
|
||||
// FIXME: Missing collapse?
|
||||
}
|
||||
|
||||
@@ -1574,6 +1610,7 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1595,6 +1632,8 @@ export class SelectionController extends EventTarget {
|
||||
} else {
|
||||
mergeParagraphs(previousParagraph, currentParagraph);
|
||||
}
|
||||
this.#mutations.remove(currentParagraph);
|
||||
this.#mutations.update(previousParagraph);
|
||||
return this.collapse(previousTextSpan.firstChild, previousOffset);
|
||||
}
|
||||
|
||||
@@ -1608,6 +1647,8 @@ export class SelectionController extends EventTarget {
|
||||
return;
|
||||
}
|
||||
mergeParagraphs(this.focusParagraph, nextParagraph);
|
||||
this.#mutations.update(currentParagraph);
|
||||
this.#mutations.remove(nextParagraph);
|
||||
|
||||
// FIXME: Missing collapse?
|
||||
}
|
||||
@@ -1624,6 +1665,7 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1638,6 +1680,7 @@ export class SelectionController extends EventTarget {
|
||||
for (const textSpan of affectedTextSpans) {
|
||||
if (textSpan.textContent === "") {
|
||||
textSpan.remove();
|
||||
this.#mutations.remove(textSpan);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1645,6 +1688,7 @@ export class SelectionController extends EventTarget {
|
||||
for (const paragraph of affectedParagraphs) {
|
||||
if (paragraph.children.length === 0) {
|
||||
paragraph.remove();
|
||||
this.#mutations.remove(paragraph);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,136 +581,6 @@ 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, "],
|
||||
@@ -1157,7 +1027,7 @@ describe("SelectionController", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("`removeSelected` multiple paragraphs", () => {
|
||||
test.skip("`removeSelected` multiple paragraphs", () => {
|
||||
const textEditorMock = TextEditorMock.createTextEditorMockWith([
|
||||
["Hello, "],
|
||||
["\n"],
|
||||
@@ -1522,10 +1392,7 @@ 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);
|
||||
@@ -1625,68 +1492,4 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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-13
|
||||
export IMAGEMAGICK_VERSION=7.1.2-0
|
||||
|
||||
# Safe directory to avoid ownership errors with Git
|
||||
git config --global --add safe.directory /home/penpot/penpot || true
|
||||
|
||||
11
mcp/.gitignore
vendored
11
mcp/.gitignore
vendored
@@ -1,11 +0,0 @@
|
||||
.idea
|
||||
node_modules
|
||||
dist
|
||||
*.bak
|
||||
*.orig
|
||||
temp
|
||||
*.tsbuildinfo
|
||||
|
||||
# Log files
|
||||
logs/
|
||||
*.log
|
||||
@@ -1,7 +0,0 @@
|
||||
*.md
|
||||
*.json
|
||||
python-scripts/
|
||||
.serena/
|
||||
|
||||
# auto-generated files
|
||||
mcp-server/data/api_types.yml
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"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
1
mcp/.serena/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
/cache
|
||||
@@ -1,25 +0,0 @@
|
||||
# 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)
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,56 +0,0 @@
|
||||
# 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
|
||||
@@ -1,130 +0,0 @@
|
||||
|
||||
|
||||
# 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
291
mcp/README.md
@@ -1,291 +0,0 @@
|
||||

|
||||
|
||||
# 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.
|
||||
|
||||
|
||||
[](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.
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
[](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`).
|
||||
@@ -1,41 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"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/**/*"
|
||||
]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./types";
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"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
24
mcp/packages/plugin/.gitignore
vendored
@@ -1,24 +0,0 @@
|
||||
# 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?
|
||||
@@ -1,21 +0,0 @@
|
||||
# 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`.
|
||||
@@ -1,15 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* 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>;
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
@import "@penpot/plugin-styles/styles.css";
|
||||
|
||||
body {
|
||||
line-height: 1.5;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-block-end: 0.75rem;
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
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
4
mcp/packages/plugin/src/vite-env.d.ts
vendored
@@ -1,4 +0,0 @@
|
||||
/// <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
Reference in New Issue
Block a user