Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Andrey Antukh 2026-07-09 19:37:16 +02:00
commit f84a8687f6
27 changed files with 461 additions and 488 deletions

View File

@ -35,7 +35,7 @@ Command what should be built. Format: `[Imperative verb] [what] in/on [where]`.
| Field | Rule | | Field | Rule |
|-------|------| |-------|------|
| **Labels** | `bug` (crashes/regressions) · `enhancement` (new features) · `community contribution` (PRs from non-core) · skip workflow labels (`backport candidate`, `team-qa`) | | **Labels** | `community contribution` (PRs from non-core) · skip workflow labels (`backport candidate`, `team-qa`) · do **not** add `bug` or `enhancement` labels (use Issue Type instead) |
| **Milestone** | Use the current or next planned milestone. Fetch available milestones: `gh api repos/penpot/penpot/milestones --jq '.[].title'`. If unsure, omit. | | **Milestone** | Use the current or next planned milestone. Fetch available milestones: `gh api repos/penpot/penpot/milestones --jq '.[].title'`. If unsure, omit. |
| **Project** | Always `Main` (project number 8). Use `--project "Main"` flag. | | **Project** | Always `Main` (project number 8). Use `--project "Main"` flag. |
| **Issue Type** | See Issue Type section below. Cannot be set via `gh issue create` — use GraphQL after creation. | | **Issue Type** | See Issue Type section below. Cannot be set via `gh issue create` — use GraphQL after creation. |
@ -114,8 +114,8 @@ Output: `https://github.com/penpot/penpot/issues/<NUMBER>`
| Docs | `IT_kwDOAcyBPM4B_IQz` | | Docs | `IT_kwDOAcyBPM4B_IQz` |
**Map:** **Map:**
- `bug` label → Bug - Bug report (steps to reproduce, expected vs. actual) → Bug
- `enhancement` label → Enhancement - Enhancement / new feature → Enhancement
- Feature/epic → Feature - Feature/epic → Feature
- Docs → Docs - Docs → Docs
- None of the above → Task - None of the above → Task

View File

@ -2,6 +2,20 @@
PR only on explicit request. Branch: issue/feature-specific; fallback `<type>/<short-description>` (`fix/...`, `feat/...`, `refactor/...`, `docs/...`, `chore/...`, `perf/...`). PR only on explicit request. Branch: issue/feature-specific; fallback `<type>/<short-description>` (`fix/...`, `feat/...`, `refactor/...`, `docs/...`, `chore/...`, `perf/...`).
## Target Branch
Auto-detect the base branch with `tools/detect-target-branch`:
```bash
TARGET=$(tools/detect-target-branch)
```
This outputs `staging` or `develop` by walking the local commit graph (pure local, no remote/network). Do not ask the user for the target branch unless the tool fails.
## Metadata
Always add the PR to the Main project (`--project "Main"`) unless the user explicitly requests a different project.
## Title Format ## Title Format
PR titles follow commit title conventions: PR titles follow commit title conventions:
@ -60,3 +74,24 @@ The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR.
- Follow `mem:workflow/creating-commits` for commits - Follow `mem:workflow/creating-commits` for commits
- Run the focused tests/lints appropriate to touched modules. - Run the focused tests/lints appropriate to touched modules.
- Do not force-push during review unless the maintainer workflow explicitly asks for it. - Do not force-push during review unless the maintainer workflow explicitly asks for it.
- When the user says the code is already pushed, trust that — do not verify remote branch existence via `git ls-remote` or `git fetch`.
## Creating the PR
```bash
cat > /tmp/pr-body.md << 'PR_BODY'
<body content here>
PR_BODY
TARGET=$(tools/detect-target-branch)
gh pr create \
--repo penpot/penpot \
--base "$TARGET" \
--head <branch> \
--title "<title>" \
--project "Main" \
--body-file /tmp/pr-body.md
rm -f /tmp/pr-body.md
```

View File

@ -331,7 +331,10 @@
This expands to a single SQL statement with placeholders for every This expands to a single SQL statement with placeholders for every
value being inserted. For large data sets, this may exceed the limit value being inserted. For large data sets, this may exceed the limit
of sql string size and/or number of parameters." of sql string size and/or number of parameters.
See `insert-many-chunked!` for a safe alternative that automatically
partitions rows to stay within the parameter limit."
[ds table cols rows & {:as opts}] [ds table cols rows & {:as opts}]
(let [conn (get-connectable ds) (let [conn (get-connectable ds)
sql (sql/insert-many table cols rows opts) sql (sql/insert-many table cols rows opts)
@ -341,6 +344,24 @@
opts (update opts :return-keys boolean)] opts (update opts :return-keys boolean)]
(jdbc/execute! conn sql opts))) (jdbc/execute! conn sql opts)))
(def ^:private default-max-params
"PostgreSQL PreparedStatement parameter limit."
65535)
(defn insert-many-chunked!
"Like `insert-many!` but partitions rows into chunks that stay within
PostgreSQL's 65,535 PreparedStatement parameter limit.
The chunk size is computed as `floor(max-params / num-columns)`,
so callers do not need to calculate it. All chunks execute within
the same transaction when called inside `tx-run!`."
[ds table cols rows & {:keys [max-params] :as opts
:or {max-params default-max-params}}]
(let [chunk-size (quot max-params (count cols))
opts (dissoc opts :max-params)]
(doseq [chunk (partition-all chunk-size rows)]
(apply insert-many! ds table cols chunk (mapcat identity opts)))))
(defn update! (defn update!
"A helper that build an UPDATE SQL statement and executes it. "A helper that build an UPDATE SQL statement and executes it.

View File

@ -65,7 +65,6 @@
(assert (every? string? cmd) "the command should be a vector of strings") (assert (every? string? cmd) "the command should be a vector of strings")
(let [executor (::wrk/executor system) (let [executor (::wrk/executor system)
_ (assert (some? executor) "executor is required, check ::wrk/executor")
full-cmd (cond->> cmd full-cmd (cond->> cmd
(seq prlimit) (seq prlimit)
(into (prlimit-cmd prlimit))) (into (prlimit-cmd prlimit)))
@ -74,6 +73,9 @@
_ (reduce-kv set-env env-map env) _ (reduce-kv set-env env-map env)
process (.start builder)] process (.start builder)]
(when-not executor
(throw (IllegalArgumentException. "invalid system/cfg provided, missing ::wrk/executor")))
(if in (if in
(px/run! executor (px/run! executor
(fn [] (fn []

View File

@ -8,12 +8,17 @@
(:require (:require
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.util.shell :as shell] [app.util.shell :as shell]
[app.worker :as-alias wrk]
[clojure.string :as str] [clojure.string :as str]
[clojure.test :as t])) [clojure.test :as t]
[promesa.exec :as px]))
(def ^:private system
{::wrk/executor (px/cached-executor)})
(t/deftest exec-normal-completes (t/deftest exec-normal-completes
(t/testing "normal process completes within timeout" (t/testing "normal process completes within timeout"
(let [result (shell/exec! {} (let [result (shell/exec! system
:cmd ["echo" "hello"] :cmd ["echo" "hello"]
:timeout 10)] :timeout 10)]
(t/is (= 0 (:exit result))) (t/is (= 0 (:exit result)))
@ -21,7 +26,7 @@
(t/deftest exec-captures-stderr (t/deftest exec-captures-stderr
(t/testing "stderr is captured separately" (t/testing "stderr is captured separately"
(let [result (shell/exec! {} (let [result (shell/exec! system
:cmd ["bash" "-c" "echo out; echo err >&2"] :cmd ["bash" "-c" "echo out; echo err >&2"]
:timeout 10)] :timeout 10)]
(t/is (= 0 (:exit result))) (t/is (= 0 (:exit result)))
@ -30,14 +35,14 @@
(t/deftest exec-non-zero-exit (t/deftest exec-non-zero-exit
(t/testing "non-zero exit code is captured" (t/testing "non-zero exit code is captured"
(let [result (shell/exec! {} (let [result (shell/exec! system
:cmd ["bash" "-c" "exit 42"] :cmd ["bash" "-c" "exit 42"]
:timeout 10)] :timeout 10)]
(t/is (= 42 (:exit result)))))) (t/is (= 42 (:exit result))))))
(t/deftest exec-with-env (t/deftest exec-with-env
(t/testing "environment variables are passed to the process" (t/testing "environment variables are passed to the process"
(let [result (shell/exec! {} (let [result (shell/exec! system
:cmd ["bash" "-c" "echo $MY_VAR"] :cmd ["bash" "-c" "echo $MY_VAR"]
:env {"MY_VAR" "test-value"} :env {"MY_VAR" "test-value"}
:timeout 10)] :timeout 10)]
@ -46,7 +51,7 @@
(t/deftest exec-with-input (t/deftest exec-with-input
(t/testing "stdin input is passed to the process" (t/testing "stdin input is passed to the process"
(let [result (shell/exec! {} (let [result (shell/exec! system
:cmd ["cat"] :cmd ["cat"]
:in "hello from stdin" :in "hello from stdin"
:timeout 10)] :timeout 10)]
@ -57,7 +62,7 @@
(t/testing "process that exceeds timeout is killed and raises exception" (t/testing "process that exceeds timeout is killed and raises exception"
(let [start (System/currentTimeMillis)] (let [start (System/currentTimeMillis)]
(try (try
(shell/exec! {} (shell/exec! system
:cmd ["sleep" "60"] :cmd ["sleep" "60"]
:timeout 1) :timeout 1)
(t/is false "should have thrown") (t/is false "should have thrown")
@ -72,14 +77,14 @@
(t/deftest exec-no-timeout-waits (t/deftest exec-no-timeout-waits
(t/testing "without timeout, process runs to completion" (t/testing "without timeout, process runs to completion"
(let [result (shell/exec! {} (let [result (shell/exec! system
:cmd ["sleep" "0.1"] :cmd ["sleep" "0.1"]
:timeout nil)] :timeout nil)]
(t/is (= 0 (:exit result)))))) (t/is (= 0 (:exit result))))))
(t/deftest exec-prlimit-normal (t/deftest exec-prlimit-normal
(t/testing "normal process completes within prlimit" (t/testing "normal process completes within prlimit"
(let [result (shell/exec! {} (let [result (shell/exec! system
:cmd ["echo" "hello"] :cmd ["echo" "hello"]
:prlimit {:mem 256 :cpu 10} :prlimit {:mem 256 :cpu 10}
:timeout 10)] :timeout 10)]
@ -88,7 +93,7 @@
(t/deftest exec-prlimit-cpu (t/deftest exec-prlimit-cpu
(t/testing "process exceeding CPU limit is killed" (t/testing "process exceeding CPU limit is killed"
(let [result (shell/exec! {} (let [result (shell/exec! system
:cmd ["bash" "-c" "while true; do :; done"] :cmd ["bash" "-c" "while true; do :; done"]
:prlimit {:cpu 2} :prlimit {:cpu 2}
:timeout 10)] :timeout 10)]
@ -98,7 +103,7 @@
(t/testing "process exceeding memory limit is killed" (t/testing "process exceeding memory limit is killed"
;; Use python3 to allocate more memory than the limit allows. ;; Use python3 to allocate more memory than the limit allows.
;; This test requires python3 to be available in the environment. ;; This test requires python3 to be available in the environment.
(let [result (shell/exec! {} (let [result (shell/exec! system
:cmd ["python3" "-c" :cmd ["python3" "-c"
"import sys; x = bytearray(600 * 1024 * 1024); sys.exit(0)"] "import sys; x = bytearray(600 * 1024 * 1024); sys.exit(0)"]
:prlimit {:mem 256} :prlimit {:mem 256}

View File

@ -500,9 +500,9 @@
shape-transform (:transform shape) shape-transform (:transform shape)
shape-transform-inv (:transform-inverse shape) shape-transform-inv (:transform-inverse shape)
shape-center (gco/shape->center shape) shape-center (gco/shape->center shape)
{sr-width :width sr-height :height} (:selrect shape) {sr-width :width sr-height :height} (safe-size-rect shape)
origin (cond-> (gpt/point (:selrect shape)) origin (cond-> (gpt/point (safe-size-rect shape))
(some? shape-transform) (some? shape-transform)
(gmt/transform-point-center shape-center shape-transform)) (gmt/transform-point-center shape-center shape-transform))

View File

@ -392,7 +392,9 @@
([shape] ([shape]
(convert-to-path shape {})) (convert-to-path shape {}))
([shape objects] ([shape objects]
(-> (stp/convert-to-path shape objects) (let [shape' (stp/convert-to-path shape objects)]
(update :content impl/path-data)))) (if (identical? shape shape')
shape'
(update shape' :content impl/path-data)))))
(dm/export impl/decode-segments) (dm/export impl/decode-segments)

View File

@ -657,3 +657,35 @@
mods (ctm/rotation (ctm/empty) (gpt/point 50 25) 45) mods (ctm/rotation (ctm/empty) (gpt/point 50 25) 45)
result (ctm/apply-structure-modifiers shape mods)] result (ctm/apply-structure-modifiers shape mods)]
(t/is (mth/close? 45.0 (:rotation result)))))) (t/is (mth/close? 45.0 (:rotation result))))))
;; ─── change-orientation-modifiers — degenerate selrect ────────────────────────
(defn- make-degenerate-shape
"Build a shape whose selrect has zero width/height, simulating a shape
decoded from the server via map->Rect (bypasses make-rect's 0.01 floor)."
[x y selrect-width selrect-height]
(let [shape (make-shape x y 100 50)]
(assoc shape :selrect (grc/map->Rect {:x x :y y
:width selrect-width
:height selrect-height
:x1 x :y1 y
:x2 (+ x selrect-width)
:y2 (+ y selrect-height)}))))
(t/deftest change-orientation-zero-width-selrect-does-not-throw
(t/testing "orientation change on a shape with zero selrect width does not throw"
(let [shape (make-degenerate-shape 0 0 0 50)
mods (ctm/change-orientation-modifiers shape :horiz)]
(t/is (some? mods)))))
(t/deftest change-orientation-zero-height-selrect-does-not-throw
(t/testing "orientation change on a shape with zero selrect height does not throw"
(let [shape (make-degenerate-shape 0 0 100 0)
mods (ctm/change-orientation-modifiers shape :vert)]
(t/is (some? mods)))))
(t/deftest change-orientation-zero-width-and-height-selrect-does-not-throw
(t/testing "orientation change on a fully degenerate selrect does not throw"
(let [shape (make-degenerate-shape 0 0 0 0)
mods (ctm/change-orientation-modifiers shape :horiz)]
(t/is (some? mods)))))

View File

@ -1370,6 +1370,15 @@
;; A path shape stays a path shape unchanged ;; A path shape stays a path shape unchanged
(t/is (= :path (:type result))))) (t/is (= :path (:type result)))))
(t/deftest shape-to-path-svg-raw-does-not-throw
(let [shape {:type :svg-raw :x 0.0 :y 0.0 :width 100.0 :height 50.0
:selrect (make-selrect 0.0 0.0 100.0 50.0)
:content {:tag :text :attrs {:style {}}
:content [{:tag :tspan :attrs {} :content ["x"]}]}}
result (path/convert-to-path shape {})]
(t/is (= :svg-raw (:type result)))
(t/is (some? (:content result)))))
(t/deftest shape-to-path-rect-with-radius (t/deftest shape-to-path-rect-with-radius
(let [shape {:type :rect :x 0.0 :y 0.0 :width 100.0 :height 100.0 (let [shape {:type :rect :x 0.0 :y 0.0 :width 100.0 :height 100.0
:r1 10.0 :r2 10.0 :r3 10.0 :r4 10.0 :r1 10.0 :r2 10.0 :r3 10.0 :r4 10.0

View File

@ -66,7 +66,7 @@ RUN set -eux; \
FROM base AS setup-opencode FROM base AS setup-opencode
ENV OPENCODE_VERSION=1.17.13 ENV OPENCODE_VERSION=1.17.16
RUN set -ex; \ RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \ ARCH="$(dpkg --print-architecture)"; \

View File

@ -8,7 +8,7 @@
"author": "Andrey Antukh", "author": "Andrey Antukh",
"license": "MPL-2.0", "license": "MPL-2.0",
"dependencies": { "dependencies": {
"draft-js": "penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0", "draft-js": "penpot/draft-js.git#ba3b26ed63a01227a3560e440531b69d79c03f35",
"immutable": "^5.1.9" "immutable": "^5.1.9"
}, },
"peerDependencies": { "peerDependencies": {

View File

@ -1,449 +0,0 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
draft-js:
specifier: penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0
version: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
immutable:
specifier: ^5.1.4
version: 5.1.4
react:
specifier: '>=0.17.0'
version: 19.2.3
react-dom:
specifier: '>=0.17.0'
version: 19.2.3(react@19.2.3)
devDependencies:
esbuild:
specifier: ^0.27.2
version: 0.27.2
packages:
'@esbuild/aix-ppc64@0.27.2':
resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.2':
resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.2':
resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.2':
resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.2':
resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.2':
resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.2':
resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.2':
resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.2':
resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.2':
resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.2':
resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.2':
resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.2':
resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.2':
resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.2':
resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.2':
resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.2':
resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.2':
resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.2':
resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.2':
resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.2':
resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.2':
resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.2':
resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.2':
resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.2':
resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.2':
resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
cross-fetch@3.2.0:
resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==}
draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0:
resolution: {tarball: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0}
version: 0.11.7
peerDependencies:
react: '>=0.14.0'
react-dom: '>=0.14.0'
esbuild@0.27.2:
resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
engines: {node: '>=18'}
hasBin: true
fbjs-css-vars@1.0.2:
resolution: {integrity: sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==}
fbjs@3.0.5:
resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==}
immutable@3.7.6:
resolution: {integrity: sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==}
engines: {node: '>=0.8.0'}
immutable@5.1.4:
resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
promise@7.3.1:
resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==}
react-dom@19.2.3:
resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==}
peerDependencies:
react: ^19.2.3
react@19.2.3:
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
engines: {node: '>=0.10.0'}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
ua-parser-js@1.0.41:
resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==}
hasBin: true
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
snapshots:
'@esbuild/aix-ppc64@0.27.2':
optional: true
'@esbuild/android-arm64@0.27.2':
optional: true
'@esbuild/android-arm@0.27.2':
optional: true
'@esbuild/android-x64@0.27.2':
optional: true
'@esbuild/darwin-arm64@0.27.2':
optional: true
'@esbuild/darwin-x64@0.27.2':
optional: true
'@esbuild/freebsd-arm64@0.27.2':
optional: true
'@esbuild/freebsd-x64@0.27.2':
optional: true
'@esbuild/linux-arm64@0.27.2':
optional: true
'@esbuild/linux-arm@0.27.2':
optional: true
'@esbuild/linux-ia32@0.27.2':
optional: true
'@esbuild/linux-loong64@0.27.2':
optional: true
'@esbuild/linux-mips64el@0.27.2':
optional: true
'@esbuild/linux-ppc64@0.27.2':
optional: true
'@esbuild/linux-riscv64@0.27.2':
optional: true
'@esbuild/linux-s390x@0.27.2':
optional: true
'@esbuild/linux-x64@0.27.2':
optional: true
'@esbuild/netbsd-arm64@0.27.2':
optional: true
'@esbuild/netbsd-x64@0.27.2':
optional: true
'@esbuild/openbsd-arm64@0.27.2':
optional: true
'@esbuild/openbsd-x64@0.27.2':
optional: true
'@esbuild/openharmony-arm64@0.27.2':
optional: true
'@esbuild/sunos-x64@0.27.2':
optional: true
'@esbuild/win32-arm64@0.27.2':
optional: true
'@esbuild/win32-ia32@0.27.2':
optional: true
'@esbuild/win32-x64@0.27.2':
optional: true
asap@2.0.6: {}
cross-fetch@3.2.0:
dependencies:
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
fbjs: 3.0.5
immutable: 3.7.6
object-assign: 4.1.1
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
transitivePeerDependencies:
- encoding
esbuild@0.27.2:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.2
'@esbuild/android-arm': 0.27.2
'@esbuild/android-arm64': 0.27.2
'@esbuild/android-x64': 0.27.2
'@esbuild/darwin-arm64': 0.27.2
'@esbuild/darwin-x64': 0.27.2
'@esbuild/freebsd-arm64': 0.27.2
'@esbuild/freebsd-x64': 0.27.2
'@esbuild/linux-arm': 0.27.2
'@esbuild/linux-arm64': 0.27.2
'@esbuild/linux-ia32': 0.27.2
'@esbuild/linux-loong64': 0.27.2
'@esbuild/linux-mips64el': 0.27.2
'@esbuild/linux-ppc64': 0.27.2
'@esbuild/linux-riscv64': 0.27.2
'@esbuild/linux-s390x': 0.27.2
'@esbuild/linux-x64': 0.27.2
'@esbuild/netbsd-arm64': 0.27.2
'@esbuild/netbsd-x64': 0.27.2
'@esbuild/openbsd-arm64': 0.27.2
'@esbuild/openbsd-x64': 0.27.2
'@esbuild/openharmony-arm64': 0.27.2
'@esbuild/sunos-x64': 0.27.2
'@esbuild/win32-arm64': 0.27.2
'@esbuild/win32-ia32': 0.27.2
'@esbuild/win32-x64': 0.27.2
fbjs-css-vars@1.0.2: {}
fbjs@3.0.5:
dependencies:
cross-fetch: 3.2.0
fbjs-css-vars: 1.0.2
loose-envify: 1.4.0
object-assign: 4.1.1
promise: 7.3.1
setimmediate: 1.0.5
ua-parser-js: 1.0.41
transitivePeerDependencies:
- encoding
immutable@3.7.6: {}
immutable@5.1.4: {}
js-tokens@4.0.0: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
object-assign@4.1.1: {}
promise@7.3.1:
dependencies:
asap: 2.0.6
react-dom@19.2.3(react@19.2.3):
dependencies:
react: 19.2.3
scheduler: 0.27.0
react@19.2.3: {}
scheduler@0.27.0: {}
setimmediate@1.0.5: {}
tr46@0.0.3: {}
ua-parser-js@1.0.41: {}
webidl-conversions@3.0.1: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1

View File

@ -263,8 +263,8 @@ importers:
packages/draft-js: packages/draft-js:
dependencies: dependencies:
draft-js: draft-js:
specifier: penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0 specifier: penpot/draft-js.git#ba3b26ed63a01227a3560e440531b69d79c03f35
version: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
immutable: immutable:
specifier: ^5.1.9 specifier: ^5.1.9
version: 5.1.9 version: 5.1.9
@ -2886,8 +2886,8 @@ packages:
resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==}
engines: {node: '>=20.19.0'} engines: {node: '>=20.19.0'}
draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0: draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35:
resolution: {gitHosted: true, tarball: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0} resolution: {gitHosted: true, integrity: sha512-IN2r8sw36jcH32WPP9eBFXNglirhkFIsqgoKzwwSDxiCbeipumeKXjYB3vw82CxYRwKc+oRTMcZ5jNnCWnmnBw==, tarball: https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35}
version: 0.11.7 version: 0.11.7
peerDependencies: peerDependencies:
react: '>=0.14.0' react: '>=0.14.0'
@ -8117,7 +8117,7 @@ snapshots:
domelementtype: 3.0.0 domelementtype: 3.0.0
domhandler: 6.0.1 domhandler: 6.0.1
draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies: dependencies:
fbjs: 3.0.5(encoding@0.1.13) fbjs: 3.0.5(encoding@0.1.13)
immutable: 3.8.3 immutable: 3.8.3

View File

@ -24,7 +24,6 @@
[app.main.data.workspace.drawing :as dwd] [app.main.data.workspace.drawing :as dwd]
[app.main.data.workspace.edition :as dwe] [app.main.data.workspace.edition :as dwe]
[app.main.data.workspace.layout :as dwlo] [app.main.data.workspace.layout :as dwlo]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.viewport-wasm :as dwvw] [app.main.data.workspace.viewport-wasm :as dwvw]
[app.main.data.workspace.zoom :as dwz] [app.main.data.workspace.zoom :as dwz]
[app.main.repo :as rp] [app.main.repo :as rp]
@ -72,7 +71,7 @@
(rx/take-until stopper-s)))))) (rx/take-until stopper-s))))))
(defn- handle-interrupt (defn handle-interrupt
[] []
(ptk/reify ::handle-interrupt (ptk/reify ::handle-interrupt
ptk/WatchEvent ptk/WatchEvent
@ -87,8 +86,7 @@
;; tool is active. When comments are merely visible during design, ;; tool is active. When comments are merely visible during design,
;; `select-shape` emits `:interrupt` and this would otherwise wipe ;; `select-shape` emits `:interrupt` and this would otherwise wipe
;; the freshly selected shape, breaking click selection. ;; the freshly selected shape, breaking click selection.
comments-mode? (rx/of (dwe/clear-edition-mode) comments-mode? (rx/of (dwe/clear-edition-mode))
(dws/deselect-all true))
:else (rx/empty)))))) :else (rx/empty))))))
;; Event responsible of the what should be executed when user clicked ;; Event responsible of the what should be executed when user clicked

View File

@ -256,8 +256,8 @@
(defn assoc-position-data (defn assoc-position-data
[shape position-data old-shape] [shape position-data old-shape]
(let [deltav (gpt/to-vec (gpt/point (:selrect old-shape)) (let [deltav (gpt/to-vec (gpt/point (ctm/safe-size-rect old-shape))
(gpt/point (:selrect shape))) (gpt/point (ctm/safe-size-rect shape)))
position-data position-data
(-> position-data (-> position-data
(gsh/move-position-data deltav))] (gsh/move-position-data deltav))]

View File

@ -35,8 +35,8 @@
(if-let [modifiers (:modifiers shape)] (if-let [modifiers (:modifiers shape)]
(let [shape' (gsh/transform-shape shape modifiers) (let [shape' (gsh/transform-shape shape modifiers)
old-sr (dm/get-prop shape :selrect) old-sr (ctm/safe-size-rect shape)
new-sr (dm/get-prop shape' :selrect) new-sr (ctm/safe-size-rect shape')
;; We need to remove the movement because the dynamic modifiers will have move it ;; We need to remove the movement because the dynamic modifiers will have move it
deltav (gpt/to-vec (gpt/point new-sr) deltav (gpt/to-vec (gpt/point new-sr)

View File

@ -522,7 +522,7 @@
[:* [:*
[:div {:class (stl/css :variant-property-list)} [:div {:class (stl/css :variant-property-list)}
(for [[pos prop] (map-indexed vector props-first)] (for [[pos prop] (map-indexed vector props-first)]
(let [mixed-value? (not-every? #(= (:value prop) (:value (nth % pos))) properties) (let [mixed-value? (not-every? #(= (:value prop) (:value (get % pos))) properties)
options (get-options (:name prop)) options (get-options (:name prop))
boolean-pair (ctv/find-boolean-pair (mapv :id options)) boolean-pair (ctv/find-boolean-pair (mapv :id options))
options (cond-> options options (cond-> options

View File

@ -1059,6 +1059,9 @@
:setPluginData :setPluginData
(fn [key value] (fn [key value]
(cond (cond
(not= file-id (:current-file-id @st/state))
(u/not-valid plugin-id :setPluginData-non-local-library file-id)
(not (string? key)) (not (string? key))
(u/not-valid plugin-id :setPluginData-key key) (u/not-valid plugin-id :setPluginData-key key)
@ -1092,6 +1095,9 @@
:setSharedPluginData :setSharedPluginData
(fn [namespace key value] (fn [namespace key value]
(cond (cond
(not= file-id (:current-file-id @st/state))
(u/not-valid plugin-id :setSharedPluginData-non-local-library file-id)
(not (string? namespace)) (not (string? namespace))
(u/not-valid plugin-id :setSharedPluginData-namespace namespace) (u/not-valid plugin-id :setSharedPluginData-namespace namespace)

View File

@ -272,13 +272,13 @@
(st/emit! (dp/set-plugin-data file-id :page id (keyword "shared" namespace) key value)))) (st/emit! (dp/set-plugin-data file-id :page id (keyword "shared" namespace) key value))))
:getSharedPluginDataKeys :getSharedPluginDataKeys
(fn [self namespace] (fn [namespace]
(cond (cond
(not (string? namespace)) (not (string? namespace))
(u/not-valid plugin-id :page-plugin-data-namespace namespace) (u/not-valid plugin-id :page-plugin-data-namespace namespace)
:else :else
(let [page (u/proxy->page self)] (let [page (u/locate-page file-id id)]
(apply array (keys (dm/get-in page [:plugin-data (keyword "shared" namespace)])))))) (apply array (keys (dm/get-in page [:plugin-data (keyword "shared" namespace)]))))))
:openPage :openPage

View File

@ -0,0 +1,84 @@
;; 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 Sucursal en España SL
(ns frontend-tests.data.workspace-comments-test
(:require
[app.main.data.comments :as dcmt]
[app.main.data.workspace.comments :as dwcm]
[app.main.data.workspace.edition :as dwe]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[potok.v2.core :as ptk]))
(t/deftest test-handle-interrupt-draft
(t/async
done
(let [event (dwcm/handle-interrupt)
state {:comments-local {:draft {:id "draft-id"}}}
result (ptk/watch event state (rx/empty))]
(->> result
(rx/subs!
(fn [evt]
(t/is (= ::dcmt/close-comment-thread (ptk/type evt))))
(fn [err]
(done)
(js/console.error err)
(t/do-report {:type :error :message "Stream error" :actual err}))
(fn [_]
(done)))))))
(t/deftest test-handle-interrupt-open
(t/async
done
(let [event (dwcm/handle-interrupt)
state {:comments-local {:open {:id "thread-id"}}}
result (ptk/watch event state (rx/empty))]
(->> result
(rx/subs!
(fn [evt]
(t/is (= ::dcmt/close-comment-thread (ptk/type evt))))
(fn [err]
(done)
(js/console.error err)
(t/do-report {:type :error :message "Stream error" :actual err}))
(fn [_]
(done)))))))
(t/deftest test-handle-interrupt-comments-mode
(t/async
done
(let [event (dwcm/handle-interrupt)
state {:workspace-drawing {:tool :comments}}
result (ptk/watch event state (rx/empty))]
(->> result
(rx/subs!
(fn [evt]
(t/is (= ::dwe/clear-edition-mode (ptk/type evt))))
(fn [err]
(done)
(js/console.error err)
(t/do-report {:type :error :message "Stream error" :actual err}))
(fn [_]
(done)))))))
(t/deftest test-handle-interrupt-noop
(t/async
done
(let [event (dwcm/handle-interrupt)
state {}
result (ptk/watch event state (rx/empty))
emitted? (atom false)]
(->> result
(rx/subs!
(fn [_]
(reset! emitted? true))
(fn [err]
(done)
(js/console.error err)
(t/do-report {:type :error :message "Stream error" :actual err}))
(fn [_]
(t/is (false? @emitted?) "should not emit any events")
(done)))))))

View File

@ -9,9 +9,11 @@
[app.common.geom.rect :as grc] [app.common.geom.rect :as grc]
[app.common.test-helpers.files :as cthf] [app.common.test-helpers.files :as cthf]
[app.common.test-helpers.shapes :as cths] [app.common.test-helpers.shapes :as cths]
[app.common.types.modifiers :as ctm]
[app.common.types.shape :as cts] [app.common.types.shape :as cts]
[app.common.types.text :as txt] [app.common.types.text :as txt]
[app.main.data.workspace.texts :as dwt] [app.main.data.workspace.texts :as dwt]
[app.main.ui.workspace.shapes.text.viewport-texts-html :as vth]
[cljs.test :as t :include-macros true] [cljs.test :as t :include-macros true]
[frontend-tests.helpers.state :as ths])) [frontend-tests.helpers.state :as ths]))
@ -374,3 +376,34 @@
"exactly one typography was added") "exactly one typography was added")
(t/is (= "0.1" (:letter-spacing (first typographies))) (t/is (= "0.1" (:letter-spacing (first typographies)))
"float letter-spacing is normalised to 2-decimal string"))))))) "float letter-spacing is normalised to 2-decimal string")))))))
;; ---------------------------------------------------------------------------
;; Tests: fix-position with degenerate selrect
;; ---------------------------------------------------------------------------
(t/deftest fix-position-zero-width-selrect-does-not-throw
(t/testing "fix-position on a shape with zero selrect width does not throw"
(let [shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 50)
modifiers (ctm/change-dimensions-modifiers shape :width 200 {:ignore-lock? true})
shape' (assoc shape :modifiers modifiers)
result (vth/fix-position shape')]
(t/is (some? result))
(t/is (some? (:selrect result))))))
(t/deftest fix-position-zero-height-selrect-does-not-throw
(t/testing "fix-position on a shape with zero selrect height does not throw"
(let [shape (make-degenerate-text-shape :x 0 :y 0 :width 100 :height 0)
modifiers (ctm/change-dimensions-modifiers shape :height 80 {:ignore-lock? true})
shape' (assoc shape :modifiers modifiers)
result (vth/fix-position shape')]
(t/is (some? result))
(t/is (some? (:selrect result))))))
(t/deftest fix-position-zero-width-and-height-selrect-does-not-throw
(t/testing "fix-position on a fully degenerate selrect does not throw"
(let [shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 0)
modifiers (ctm/change-dimensions-modifiers shape :width 150 {:ignore-lock? true})
shape' (assoc shape :modifiers modifiers)
result (vth/fix-position shape')]
(t/is (some? result))
(t/is (some? (:selrect result))))))

View File

@ -12,6 +12,7 @@
[frontend-tests.data.uploads-test] [frontend-tests.data.uploads-test]
[frontend-tests.data.viewer-test] [frontend-tests.data.viewer-test]
[frontend-tests.data.workspace-colors-test] [frontend-tests.data.workspace-colors-test]
[frontend-tests.data.workspace-comments-test]
[frontend-tests.data.workspace-interactions-test] [frontend-tests.data.workspace-interactions-test]
[frontend-tests.data.workspace-mcp-test] [frontend-tests.data.workspace-mcp-test]
[frontend-tests.data.workspace-media-test] [frontend-tests.data.workspace-media-test]
@ -83,6 +84,7 @@
'frontend-tests.data.uploads-test 'frontend-tests.data.uploads-test
'frontend-tests.data.viewer-test 'frontend-tests.data.viewer-test
'frontend-tests.data.workspace-colors-test 'frontend-tests.data.workspace-colors-test
'frontend-tests.data.workspace-comments-test
'frontend-tests.data.workspace-interactions-test 'frontend-tests.data.workspace-interactions-test
'frontend-tests.data.workspace-mcp-test 'frontend-tests.data.workspace-mcp-test
'frontend-tests.data.workspace-media-test 'frontend-tests.data.workspace-media-test

View File

@ -4,7 +4,7 @@
"license": "MPL-2.0", "license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL", "author": "Kaleidos INC Sucursal en España SL",
"private": true, "private": true,
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b", "packageManager": "pnpm@11.10.0+sha512.0b7f8b98060031904c017e3a41eb187a16d40eeb829b95c4f8cb03681761fc4ab53dd219115b9b447f4dce1a05a214764461e7d3703392a9f32f9511ce8c86c8",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/penpot/penpot" "url": "https://github.com/penpot/penpot"

View File

@ -52,6 +52,8 @@
- **plugins-runtime**: `penpot.openPage()` (and `Page.openPage()`) now resolves immediately when the target page is already active, instead of waiting forever for a page-initialization event that never fires. - **plugins-runtime**: `penpot.openPage()` (and `Page.openPage()`) now resolves immediately when the target page is already active, instead of waiting forever for a page-initialization event that never fires.
- **plugins-runtime**: `Shape.shadows`, `Shape.exports` and grid `rows`/`columns` now return live proxies, so writing a member on a returned shadow/export/track (e.g. `shape.shadows[0].blur = 7`) persists to the shape instead of mutating a detached snapshot that was silently discarded. The shadow `color` remains a plain snapshot (reconfigure it by assigning `shadow.color`). - **plugins-runtime**: `Shape.shadows`, `Shape.exports` and grid `rows`/`columns` now return live proxies, so writing a member on a returned shadow/export/track (e.g. `shape.shadows[0].blur = 7`) persists to the shape instead of mutating a detached snapshot that was silently discarded. The shadow `color` remains a plain snapshot (reconfigure it by assigning `shadow.color`).
- **plugins-runtime**: Setting a variant component's `path` now renames the whole variant (its container and every main instance), like the `name` setter already did, instead of renaming only the component and leaving the file referentially inconsistent (which the backend rejected on save with a `variant-component-bad-name` error). - **plugins-runtime**: Setting a variant component's `path` now renames the whole variant (its container and every main instance), like the `name` setter already did, instead of renaming only the component and leaving the file referentially inconsistent (which the backend rejected on save with a `variant-component-bad-name` error).
- **plugins-runtime**: `Page.getSharedPluginDataKeys(namespace)` now works instead of always raising a namespace validation error: the implementation expected a spurious leading argument, so the caller's `namespace` was read as a missing second argument.
- **plugins-runtime**: Storing plugin data on a connected (non-local) shared library is now consistently rejected with a `setPluginData-non-local-library` error on the `Library` object as well as its assets (colors, typographies, components). Previously the `Library` object accepted the write and applied it optimistically, but plugin data is not part of library synchronization and the change only persists when the caller can edit the library file — on a read-only shared library it failed silently and was lost on reload. Plugin data can only be stored on the file currently being edited.
## 1.4.2 (2026-01-21) ## 1.4.2 (2026-01-21)

View File

@ -34,6 +34,24 @@ describe('Library', () => {
expect(Array.isArray(ctx.penpot.library.connected)).toBe(true); expect(Array.isArray(ctx.penpot.library.connected)).toBe(true);
}); });
// The Library object itself carries plugin data (it extends PluginData),
// stored on the underlying file. Exercised on the local library; the
// connected-library case (writing to a shared library's file/assets) shares
// the same code path but can't be fixtured here (connecting a library hangs).
test('local library stores plugin data', (ctx) => {
const lib = ctx.penpot.library.local;
lib.setPluginData('k', 'v');
expect(lib.getPluginData('k')).toBe('v');
expect(lib.getPluginDataKeys()).toContain('k');
});
test('local library stores shared plugin data', (ctx) => {
const lib = ctx.penpot.library.local;
lib.setSharedPluginData('ns', 'k', 'v');
expect(lib.getSharedPluginData('ns', 'k')).toBe('v');
expect(lib.getSharedPluginDataKeys('ns')).toContain('k');
});
test('library elements expose a libraryId', (ctx) => { test('library elements expose a libraryId', (ctx) => {
const color = ctx.penpot.library.local.createColor(); const color = ctx.penpot.library.local.createColor();
expect(typeof color.libraryId).toBe('string'); expect(typeof color.libraryId).toBe('string');
@ -136,6 +154,13 @@ describe('Library', () => {
expect(color.getPluginData('k')).toBe('v'); expect(color.getPluginData('k')).toBe('v');
expect(color.getPluginDataKeys()).toContain('k'); expect(color.getPluginDataKeys()).toContain('k');
}); });
test('color shared plugin data round-trips', (ctx) => {
const color = ctx.penpot.library.local.createColor();
color.setSharedPluginData('ns', 'k', 'v');
expect(color.getSharedPluginData('ns', 'k')).toBe('v');
expect(color.getSharedPluginDataKeys('ns')).toContain('k');
});
}); });
describe('Typographies', () => { describe('Typographies', () => {
@ -170,6 +195,13 @@ describe('Library', () => {
expect(typo.getPluginDataKeys()).toContain('k'); expect(typo.getPluginDataKeys()).toContain('k');
}); });
test('typography shared plugin data round-trips', (ctx) => {
const typo = ctx.penpot.library.local.createTypography();
typo.setSharedPluginData('ns', 'k', 'v');
expect(typo.getSharedPluginData('ns', 'k')).toBe('v');
expect(typo.getSharedPluginDataKeys('ns')).toContain('k');
});
test('typography fontFamily and fontId round-trip', (ctx) => { test('typography fontFamily and fontId round-trip', (ctx) => {
const typo = ctx.penpot.library.local.createTypography(); const typo = ctx.penpot.library.local.createTypography();
expect(typeof typo.fontFamily).toBe('string'); expect(typeof typo.fontFamily).toBe('string');
@ -256,6 +288,15 @@ describe('Library', () => {
expect(comp.getPluginDataKeys()).toContain('k'); expect(comp.getPluginDataKeys()).toContain('k');
}); });
test('component shared plugin data round-trips', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
const comp = ctx.penpot.library.local.createComponent([rect]);
comp.setSharedPluginData('ns', 'k', 'v');
expect(comp.getSharedPluginData('ns', 'k')).toBe('v');
expect(comp.getSharedPluginDataKeys('ns')).toContain('k');
});
test('component instance and mainInstance return shapes', (ctx) => { test('component instance and mainInstance return shapes', (ctx) => {
const rect = ctx.penpot.createRectangle(); const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect); ctx.board.appendChild(rect);

View File

@ -26,6 +26,41 @@ describe('Plugin data', () => {
if (file) { if (file) {
file.setPluginData('fileKey', 'fileValue'); file.setPluginData('fileKey', 'fileValue');
expect(file.getPluginData('fileKey')).toBe('fileValue'); expect(file.getPluginData('fileKey')).toBe('fileValue');
expect(file.getPluginDataKeys()).toContain('fileKey');
}
});
test('shared plugin data round-trips on the file', (ctx) => {
const file = ctx.penpot.currentFile;
expect(file).not.toBeNull();
if (file) {
file.setSharedPluginData('ns', 'fileShared', 'fileSharedValue');
expect(file.getSharedPluginData('ns', 'fileShared')).toBe(
'fileSharedValue',
);
expect(file.getSharedPluginDataKeys('ns')).toContain('fileShared');
}
});
test('plugin data round-trips on a page', (ctx) => {
const page = ctx.penpot.currentPage;
expect(page).not.toBeNull();
if (page) {
page.setPluginData('pageKey', 'pageValue');
expect(page.getPluginData('pageKey')).toBe('pageValue');
expect(page.getPluginDataKeys()).toContain('pageKey');
}
});
test('shared plugin data round-trips on a page', (ctx) => {
const page = ctx.penpot.currentPage;
expect(page).not.toBeNull();
if (page) {
page.setSharedPluginData('ns', 'pageShared', 'pageSharedValue');
expect(page.getSharedPluginData('ns', 'pageShared')).toBe(
'pageSharedValue',
);
expect(page.getSharedPluginDataKeys('ns')).toContain('pageShared');
} }
}); });
@ -45,6 +80,47 @@ describe('Plugin data', () => {
expect(rect.getPluginDataKeys()).toContain(''); expect(rect.getPluginDataKeys()).toContain('');
}); });
// Keys are opaque strings: no case normalization is applied. A camelCase key
// and its kebab-case spelling are distinct entries that each round-trip
// independently. Pins that behaviour so a future key-normalization regression
// (reported as camelCase keys "behaving incorrectly") would be caught here.
test('camelCase and kebab-case keys are distinct and both round-trip', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
rect.setPluginData('myKey', 'camel');
rect.setPluginData('my-key', 'kebab');
expect(rect.getPluginData('myKey')).toBe('camel');
expect(rect.getPluginData('my-key')).toBe('kebab');
const keys = rect.getPluginDataKeys();
expect(keys).toContain('myKey');
expect(keys).toContain('my-key');
});
// Same guarantee at the file-data storage location (a distinct code path from
// shape/page objects), covering a camelCase key on the file itself.
test('a camelCase key round-trips on the file', (ctx) => {
const file = ctx.penpot.currentFile;
expect(file).not.toBeNull();
if (file) {
file.setPluginData('camelCaseFileKey', 'value');
expect(file.getPluginData('camelCaseFileKey')).toBe('value');
expect(file.getPluginDataKeys()).toContain('camelCaseFileKey');
}
});
// camelCase is likewise preserved in the shared namespace and the shared key.
test('camelCase shared namespace and key round-trip', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
rect.setSharedPluginData('myNamespace', 'myKey', 'camel');
rect.setSharedPluginData('myNamespace', 'my-key', 'kebab');
expect(rect.getSharedPluginData('myNamespace', 'myKey')).toBe('camel');
expect(rect.getSharedPluginData('myNamespace', 'my-key')).toBe('kebab');
const keys = rect.getSharedPluginDataKeys('myNamespace');
expect(keys).toContain('myKey');
expect(keys).toContain('my-key');
});
test('setPluginData with a non-string value throws', (ctx) => { test('setPluginData with a non-string value throws', (ctx) => {
const rect = ctx.penpot.createRectangle(); const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect); ctx.board.appendChild(rect);

74
tools/detect-target-branch Executable file
View File

@ -0,0 +1,74 @@
#!/usr/bin/env bash
set -euo pipefail
# detect-target-branch
#
# Determines the target branch for a PR of the current branch.
#
# Uses git name-rev to find the nearest ref (branch/tag) that is an ancestor of
# HEAD, falling back to a graph walk if name-rev returns the current branch.
#
# Pure local — no remote, no SSH, no network. Works even when the current
# branch shares its tip commit with the target (not yet diverged).
#
# Usage:
# tools/detect-target-branch # print branch name
# tools/detect-target-branch --verbose # print "branch~N"
#
# Exit status:
# 0 — target found
# 1 — no target found (orphan commit, no other branches)
verbose=false
for arg do
case "$arg" in
--verbose|-v) verbose=true ;;
--name-only) ;;
--help|-h)
sed -n '/^#/,/^$/p' "$0" | sed 's/^# //; s/^#$//'
exit 0
;;
*)
echo "Unknown option: $arg" >&2
echo "Usage: $(basename "$0") [--verbose|-v] [--name-only] [--help|-h]" >&2
exit 1
;;
esac
done
current=$(git rev-parse --abbrev-ref HEAD)
# Use name-rev to find the nearest ref — it has smart tie-breaking
raw=$(git name-rev --name-only HEAD 2>/dev/null)
target=${raw%%~[0-9]*} # strip ~N distance suffix
# If name-rev returned the current branch itself, walk backwards
if [ "$target" = "$current" ] || [ -z "$target" ]; then
target=$(git rev-list HEAD | while read commit; do
branch=$(git branch --points-at="$commit" --format='%(refname:short)' 2>/dev/null \
| grep -v "^$current$" \
| head -1 || true)
if [ -n "$branch" ]; then
echo "$branch"
break
fi
done || true)
if [ -z "$target" ]; then
echo "error: unable to determine target branch" >&2
exit 1
fi
fi
if [ "$verbose" = true ]; then
target_commit=$(git rev-parse "$target" 2>/dev/null || true)
if [ -n "$target_commit" ]; then
distance=$(git rev-list --count HEAD ^"$target_commit" 2>/dev/null || echo "0")
echo "${target}~${distance}"
else
echo "${target}~?"
fi
else
echo "$target"
fi