diff --git a/.serena/memories/workflow/creating-issues.md b/.serena/memories/workflow/creating-issues.md index 9f3831b165..9aff0b50c1 100644 --- a/.serena/memories/workflow/creating-issues.md +++ b/.serena/memories/workflow/creating-issues.md @@ -35,7 +35,7 @@ Command what should be built. Format: `[Imperative verb] [what] in/on [where]`. | 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. | | **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. | @@ -114,8 +114,8 @@ Output: `https://github.com/penpot/penpot/issues/` | Docs | `IT_kwDOAcyBPM4B_IQz` | **Map:** -- `bug` label → Bug -- `enhancement` label → Enhancement +- Bug report (steps to reproduce, expected vs. actual) → Bug +- Enhancement / new feature → Enhancement - Feature/epic → Feature - Docs → Docs - None of the above → Task diff --git a/.serena/memories/workflow/creating-prs.md b/.serena/memories/workflow/creating-prs.md index 4c5e75c212..204fd49c76 100644 --- a/.serena/memories/workflow/creating-prs.md +++ b/.serena/memories/workflow/creating-prs.md @@ -2,6 +2,20 @@ PR only on explicit request. Branch: issue/feature-specific; fallback `/` (`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 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 - Run the focused tests/lints appropriate to touched modules. - 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' + +PR_BODY + +TARGET=$(tools/detect-target-branch) + +gh pr create \ + --repo penpot/penpot \ + --base "$TARGET" \ + --head \ + --title "" \ + --project "Main" \ + --body-file /tmp/pr-body.md + +rm -f /tmp/pr-body.md +``` diff --git a/backend/src/app/db.clj b/backend/src/app/db.clj index c4e432b225..f10b0089a0 100644 --- a/backend/src/app/db.clj +++ b/backend/src/app/db.clj @@ -331,7 +331,10 @@ This expands to a single SQL statement with placeholders for every 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}] (let [conn (get-connectable ds) sql (sql/insert-many table cols rows opts) @@ -341,6 +344,24 @@ opts (update opts :return-keys boolean)] (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! "A helper that build an UPDATE SQL statement and executes it. diff --git a/backend/src/app/util/shell.clj b/backend/src/app/util/shell.clj index 0d7349d632..61dd08e682 100644 --- a/backend/src/app/util/shell.clj +++ b/backend/src/app/util/shell.clj @@ -65,7 +65,6 @@ (assert (every? string? cmd) "the command should be a vector of strings") (let [executor (::wrk/executor system) - _ (assert (some? executor) "executor is required, check ::wrk/executor") full-cmd (cond->> cmd (seq prlimit) (into (prlimit-cmd prlimit))) @@ -74,6 +73,9 @@ _ (reduce-kv set-env env-map env) process (.start builder)] + (when-not executor + (throw (IllegalArgumentException. "invalid system/cfg provided, missing ::wrk/executor"))) + (if in (px/run! executor (fn [] diff --git a/backend/test/backend_tests/shell_test.clj b/backend/test/backend_tests/shell_test.clj index c77c585568..c9d1932c44 100644 --- a/backend/test/backend_tests/shell_test.clj +++ b/backend/test/backend_tests/shell_test.clj @@ -8,12 +8,17 @@ (:require [app.common.exceptions :as ex] [app.util.shell :as shell] + [app.worker :as-alias wrk] [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/testing "normal process completes within timeout" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["echo" "hello"] :timeout 10)] (t/is (= 0 (:exit result))) @@ -21,7 +26,7 @@ (t/deftest exec-captures-stderr (t/testing "stderr is captured separately" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["bash" "-c" "echo out; echo err >&2"] :timeout 10)] (t/is (= 0 (:exit result))) @@ -30,14 +35,14 @@ (t/deftest exec-non-zero-exit (t/testing "non-zero exit code is captured" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["bash" "-c" "exit 42"] :timeout 10)] (t/is (= 42 (:exit result)))))) (t/deftest exec-with-env (t/testing "environment variables are passed to the process" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["bash" "-c" "echo $MY_VAR"] :env {"MY_VAR" "test-value"} :timeout 10)] @@ -46,7 +51,7 @@ (t/deftest exec-with-input (t/testing "stdin input is passed to the process" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["cat"] :in "hello from stdin" :timeout 10)] @@ -57,7 +62,7 @@ (t/testing "process that exceeds timeout is killed and raises exception" (let [start (System/currentTimeMillis)] (try - (shell/exec! {} + (shell/exec! system :cmd ["sleep" "60"] :timeout 1) (t/is false "should have thrown") @@ -72,14 +77,14 @@ (t/deftest exec-no-timeout-waits (t/testing "without timeout, process runs to completion" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["sleep" "0.1"] :timeout nil)] (t/is (= 0 (:exit result)))))) (t/deftest exec-prlimit-normal (t/testing "normal process completes within prlimit" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["echo" "hello"] :prlimit {:mem 256 :cpu 10} :timeout 10)] @@ -88,7 +93,7 @@ (t/deftest exec-prlimit-cpu (t/testing "process exceeding CPU limit is killed" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["bash" "-c" "while true; do :; done"] :prlimit {:cpu 2} :timeout 10)] @@ -98,7 +103,7 @@ (t/testing "process exceeding memory limit is killed" ;; Use python3 to allocate more memory than the limit allows. ;; This test requires python3 to be available in the environment. - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["python3" "-c" "import sys; x = bytearray(600 * 1024 * 1024); sys.exit(0)"] :prlimit {:mem 256} diff --git a/common/src/app/common/types/modifiers.cljc b/common/src/app/common/types/modifiers.cljc index dbc0b52296..34a81365f1 100644 --- a/common/src/app/common/types/modifiers.cljc +++ b/common/src/app/common/types/modifiers.cljc @@ -500,9 +500,9 @@ shape-transform (:transform shape) shape-transform-inv (:transform-inverse 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) (gmt/transform-point-center shape-center shape-transform)) diff --git a/common/src/app/common/types/path.cljc b/common/src/app/common/types/path.cljc index 60e736914b..2b1188682f 100644 --- a/common/src/app/common/types/path.cljc +++ b/common/src/app/common/types/path.cljc @@ -392,7 +392,9 @@ ([shape] (convert-to-path shape {})) ([shape objects] - (-> (stp/convert-to-path shape objects) - (update :content impl/path-data)))) + (let [shape' (stp/convert-to-path shape objects)] + (if (identical? shape shape') + shape' + (update shape' :content impl/path-data))))) (dm/export impl/decode-segments) diff --git a/common/test/common_tests/types/modifiers_test.cljc b/common/test/common_tests/types/modifiers_test.cljc index 2cfb196fa8..405da89935 100644 --- a/common/test/common_tests/types/modifiers_test.cljc +++ b/common/test/common_tests/types/modifiers_test.cljc @@ -657,3 +657,35 @@ mods (ctm/rotation (ctm/empty) (gpt/point 50 25) 45) result (ctm/apply-structure-modifiers shape mods)] (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))))) diff --git a/common/test/common_tests/types/path_data_test.cljc b/common/test/common_tests/types/path_data_test.cljc index f56d61a539..714270cb62 100644 --- a/common/test/common_tests/types/path_data_test.cljc +++ b/common/test/common_tests/types/path_data_test.cljc @@ -1370,6 +1370,15 @@ ;; A path shape stays a path shape unchanged (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 (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 diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 1b854a48cb..1fd9a7b382 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -66,7 +66,7 @@ RUN set -eux; \ FROM base AS setup-opencode -ENV OPENCODE_VERSION=1.17.13 +ENV OPENCODE_VERSION=1.17.16 RUN set -ex; \ ARCH="$(dpkg --print-architecture)"; \ diff --git a/frontend/packages/draft-js/package.json b/frontend/packages/draft-js/package.json index 8ad22449d7..ec153b2eac 100644 --- a/frontend/packages/draft-js/package.json +++ b/frontend/packages/draft-js/package.json @@ -8,7 +8,7 @@ "author": "Andrey Antukh", "license": "MPL-2.0", "dependencies": { - "draft-js": "penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0", + "draft-js": "penpot/draft-js.git#ba3b26ed63a01227a3560e440531b69d79c03f35", "immutable": "^5.1.9" }, "peerDependencies": { diff --git a/frontend/packages/draft-js/pnpm-lock.yaml b/frontend/packages/draft-js/pnpm-lock.yaml deleted file mode 100644 index 6efab568fd..0000000000 --- a/frontend/packages/draft-js/pnpm-lock.yaml +++ /dev/null @@ -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 diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index c11f5736a6..e7ee6eda1f 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -263,8 +263,8 @@ importers: packages/draft-js: dependencies: draft-js: - specifier: penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0 - 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) + specifier: penpot/draft-js.git#ba3b26ed63a01227a3560e440531b69d79c03f35 + 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: specifier: ^5.1.9 version: 5.1.9 @@ -2886,8 +2886,8 @@ packages: resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} engines: {node: '>=20.19.0'} - draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0: - resolution: {gitHosted: true, tarball: 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, integrity: sha512-IN2r8sw36jcH32WPP9eBFXNglirhkFIsqgoKzwwSDxiCbeipumeKXjYB3vw82CxYRwKc+oRTMcZ5jNnCWnmnBw==, tarball: https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35} version: 0.11.7 peerDependencies: react: '>=0.14.0' @@ -8117,7 +8117,7 @@ snapshots: domelementtype: 3.0.0 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: fbjs: 3.0.5(encoding@0.1.13) immutable: 3.8.3 diff --git a/frontend/src/app/main/data/workspace/comments.cljs b/frontend/src/app/main/data/workspace/comments.cljs index 16251100bf..77735a7a42 100644 --- a/frontend/src/app/main/data/workspace/comments.cljs +++ b/frontend/src/app/main/data/workspace/comments.cljs @@ -24,7 +24,6 @@ [app.main.data.workspace.drawing :as dwd] [app.main.data.workspace.edition :as dwe] [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.zoom :as dwz] [app.main.repo :as rp] @@ -72,7 +71,7 @@ (rx/take-until stopper-s)))))) -(defn- handle-interrupt +(defn handle-interrupt [] (ptk/reify ::handle-interrupt ptk/WatchEvent @@ -87,8 +86,7 @@ ;; tool is active. When comments are merely visible during design, ;; `select-shape` emits `:interrupt` and this would otherwise wipe ;; the freshly selected shape, breaking click selection. - comments-mode? (rx/of (dwe/clear-edition-mode) - (dws/deselect-all true)) + comments-mode? (rx/of (dwe/clear-edition-mode)) :else (rx/empty)))))) ;; Event responsible of the what should be executed when user clicked diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index 96a1605704..b5696414c8 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -256,8 +256,8 @@ (defn assoc-position-data [shape position-data old-shape] - (let [deltav (gpt/to-vec (gpt/point (:selrect old-shape)) - (gpt/point (:selrect shape))) + (let [deltav (gpt/to-vec (gpt/point (ctm/safe-size-rect old-shape)) + (gpt/point (ctm/safe-size-rect shape))) position-data (-> position-data (gsh/move-position-data deltav))] diff --git a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs index a22b82f46a..ae6f49f688 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs @@ -35,8 +35,8 @@ (if-let [modifiers (:modifiers shape)] (let [shape' (gsh/transform-shape shape modifiers) - old-sr (dm/get-prop shape :selrect) - new-sr (dm/get-prop shape' :selrect) + old-sr (ctm/safe-size-rect shape) + new-sr (ctm/safe-size-rect shape') ;; We need to remove the movement because the dynamic modifiers will have move it deltav (gpt/to-vec (gpt/point new-sr) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs index 536c087b36..2851124260 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs @@ -522,7 +522,7 @@ [:* [:div {:class (stl/css :variant-property-list)} (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)) boolean-pair (ctv/find-boolean-pair (mapv :id options)) options (cond-> options diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs index b97343ec20..9fb646f205 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -1059,6 +1059,9 @@ :setPluginData (fn [key value] (cond + (not= file-id (:current-file-id @st/state)) + (u/not-valid plugin-id :setPluginData-non-local-library file-id) + (not (string? key)) (u/not-valid plugin-id :setPluginData-key key) @@ -1092,6 +1095,9 @@ :setSharedPluginData (fn [namespace key value] (cond + (not= file-id (:current-file-id @st/state)) + (u/not-valid plugin-id :setSharedPluginData-non-local-library file-id) + (not (string? namespace)) (u/not-valid plugin-id :setSharedPluginData-namespace namespace) diff --git a/frontend/src/app/plugins/page.cljs b/frontend/src/app/plugins/page.cljs index 76bc45a832..e668bc8756 100644 --- a/frontend/src/app/plugins/page.cljs +++ b/frontend/src/app/plugins/page.cljs @@ -272,13 +272,13 @@ (st/emit! (dp/set-plugin-data file-id :page id (keyword "shared" namespace) key value)))) :getSharedPluginDataKeys - (fn [self namespace] + (fn [namespace] (cond (not (string? namespace)) (u/not-valid plugin-id :page-plugin-data-namespace namespace) :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)])))))) :openPage diff --git a/frontend/test/frontend_tests/data/workspace_comments_test.cljs b/frontend/test/frontend_tests/data/workspace_comments_test.cljs new file mode 100644 index 0000000000..8e653875c1 --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_comments_test.cljs @@ -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))))))) diff --git a/frontend/test/frontend_tests/data/workspace_texts_test.cljs b/frontend/test/frontend_tests/data/workspace_texts_test.cljs index 15db703ef8..ee438672c5 100644 --- a/frontend/test/frontend_tests/data/workspace_texts_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_texts_test.cljs @@ -9,9 +9,11 @@ [app.common.geom.rect :as grc] [app.common.test-helpers.files :as cthf] [app.common.test-helpers.shapes :as cths] + [app.common.types.modifiers :as ctm] [app.common.types.shape :as cts] [app.common.types.text :as txt] [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] [frontend-tests.helpers.state :as ths])) @@ -374,3 +376,34 @@ "exactly one typography was added") (t/is (= "0.1" (:letter-spacing (first typographies))) "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)))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index a83cba73be..6f92bff904 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -12,6 +12,7 @@ [frontend-tests.data.uploads-test] [frontend-tests.data.viewer-test] [frontend-tests.data.workspace-colors-test] + [frontend-tests.data.workspace-comments-test] [frontend-tests.data.workspace-interactions-test] [frontend-tests.data.workspace-mcp-test] [frontend-tests.data.workspace-media-test] @@ -83,6 +84,7 @@ 'frontend-tests.data.uploads-test 'frontend-tests.data.viewer-test 'frontend-tests.data.workspace-colors-test + 'frontend-tests.data.workspace-comments-test 'frontend-tests.data.workspace-interactions-test 'frontend-tests.data.workspace-mcp-test 'frontend-tests.data.workspace-media-test diff --git a/package.json b/package.json index 77800eaca6..1454dbd2bc 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b", + "packageManager": "pnpm@11.10.0+sha512.0b7f8b98060031904c017e3a41eb187a16d40eeb829b95c4f8cb03681761fc4ab53dd219115b9b447f4dce1a05a214764461e7d3703392a9f32f9511ce8c86c8", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 00095c38b5..80dee3f1c9 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -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**: `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**: `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) diff --git a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts index 5f16958282..7d3a8ccf0a 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts @@ -34,6 +34,24 @@ describe('Library', () => { 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) => { const color = ctx.penpot.library.local.createColor(); expect(typeof color.libraryId).toBe('string'); @@ -136,6 +154,13 @@ describe('Library', () => { expect(color.getPluginData('k')).toBe('v'); 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', () => { @@ -170,6 +195,13 @@ describe('Library', () => { 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) => { const typo = ctx.penpot.library.local.createTypography(); expect(typeof typo.fontFamily).toBe('string'); @@ -256,6 +288,15 @@ describe('Library', () => { 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) => { const rect = ctx.penpot.createRectangle(); ctx.board.appendChild(rect); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts index 254717e63e..5564b2e3f1 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts @@ -26,6 +26,41 @@ describe('Plugin data', () => { if (file) { file.setPluginData('fileKey', '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(''); }); + // 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) => { const rect = ctx.penpot.createRectangle(); ctx.board.appendChild(rect); diff --git a/tools/detect-target-branch b/tools/detect-target-branch new file mode 100755 index 0000000000..5d3d584552 --- /dev/null +++ b/tools/detect-target-branch @@ -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