From 50bee5e1760f61be51c84ad9a9af9ce19d847920 Mon Sep 17 00:00:00 2001 From: wdeveloper16 Date: Fri, 24 Apr 2026 09:07:58 +0200 Subject: [PATCH 1/6] :sparkles: Add clipboard:read/write permissions to plugin system (#6980) (#9053) * :sparkles: Add clipboard:read/write permissions to plugin system (#6980) * :wrench: Fix prettier formatting in clipboard permission files --------- Co-authored-by: wdeveloper16 Co-authored-by: Andrey Antukh --- .../src/app/main/ui/workspace/plugins.cljs | 15 +++- frontend/src/app/plugins/register.cljs | 5 +- frontend/translations/en.po | 8 +++ .../plugins-runtime/src/lib/api/openUI.api.ts | 26 ++++++- .../src/lib/create-modal.spec.ts | 69 +++++++++++++++++++ .../plugins-runtime/src/lib/create-modal.ts | 10 +++ .../src/lib/modal/plugin-modal.spec.ts | 29 ++++++++ .../src/lib/modal/plugin-modal.ts | 11 ++- .../src/lib/models/manifest.schema.ts | 2 + .../src/lib/plugin-manager.spec.ts | 2 + .../plugins-runtime/src/lib/plugin-manager.ts | 18 ++++- 11 files changed, 188 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/plugins.cljs b/frontend/src/app/main/ui/workspace/plugins.cljs index fc319bf925..f4068c585a 100644 --- a/frontend/src/app/main/ui/workspace/plugins.cljs +++ b/frontend/src/app/main/ui/workspace/plugins.cljs @@ -302,7 +302,20 @@ [:div {:class (stl/css :permissions-list-entry)} deprecated-icon/oauth-1 [:p {:class (stl/css :permissions-list-text)} - (tr "workspace.plugins.permissions.allow-localstorage")]])]) + (tr "workspace.plugins.permissions.allow-localstorage")]]) + + (cond + (contains? permissions "clipboard:write") + [:div {:class (stl/css :permissions-list-entry)} + deprecated-icon/oauth-1 + [:p {:class (stl/css :permissions-list-text)} + (tr "workspace.plugins.permissions.clipboard-write")]] + + (contains? permissions "clipboard:read") + [:div {:class (stl/css :permissions-list-entry)} + deprecated-icon/oauth-1 + [:p {:class (stl/css :permissions-list-text)} + (tr "workspace.plugins.permissions.clipboard-read")]])]) (mf/defc plugins-permissions-dialog {::mf/register modal/components diff --git a/frontend/src/app/plugins/register.cljs b/frontend/src/app/plugins/register.cljs index e3792f3fc9..df9afb6380 100644 --- a/frontend/src/app/plugins/register.cljs +++ b/frontend/src/app/plugins/register.cljs @@ -54,7 +54,10 @@ (conj "library:read") (contains? permissions "comment:write") - (conj "comment:read")) + (conj "comment:read") + + (contains? permissions "clipboard:write") + (conj "clipboard:read")) plugin-url (u/uri plugin-url) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 41308a2694..5de7250a59 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -7646,6 +7646,14 @@ msgstr "" msgid "workspace.plugins.permissions.allow-download" msgstr "Start file downloads." +#: src/app/main/ui/workspace/plugins.cljs +msgid "workspace.plugins.permissions.clipboard-read" +msgstr "Read the contents of your clipboard." + +#: src/app/main/ui/workspace/plugins.cljs +msgid "workspace.plugins.permissions.clipboard-write" +msgstr "Read and write to your clipboard." + #: src/app/main/ui/workspace/plugins.cljs:287 msgid "workspace.plugins.permissions.allow-localstorage" msgstr "Store data in the browser." diff --git a/plugins/libs/plugins-runtime/src/lib/api/openUI.api.ts b/plugins/libs/plugins-runtime/src/lib/api/openUI.api.ts index dbbeba184c..3e56ea15bb 100644 --- a/plugins/libs/plugins-runtime/src/lib/api/openUI.api.ts +++ b/plugins/libs/plugins-runtime/src/lib/api/openUI.api.ts @@ -10,7 +10,27 @@ export const openUIApi = z z.enum(['dark', 'light']), openUISchema.optional(), z.boolean().optional(), + z.boolean().optional(), + z.boolean().optional(), ) - .implement((title, url, theme, options, allowDownloads) => { - return createModal(title, url, theme, options, allowDownloads); - }); + .implement( + ( + title, + url, + theme, + options, + allowDownloads, + allowClipboardRead, + allowClipboardWrite, + ) => { + return createModal( + title, + url, + theme, + options, + allowDownloads, + allowClipboardRead, + allowClipboardWrite, + ); + }, + ); diff --git a/plugins/libs/plugins-runtime/src/lib/create-modal.spec.ts b/plugins/libs/plugins-runtime/src/lib/create-modal.spec.ts index 80d28fe56c..4cefab63c2 100644 --- a/plugins/libs/plugins-runtime/src/lib/create-modal.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/create-modal.spec.ts @@ -104,4 +104,73 @@ describe('createModal', () => { expect(modal.wrapper.style.width).toEqual('200px'); expect(modal.wrapper.style.height).toEqual('200px'); }); + + it('should set allow-clipboard-read attribute when allowClipboardRead is true', () => { + const theme: Theme = 'light'; + + createModal( + 'Test Modal', + 'https://example.com', + theme, + undefined, + false, + true, + false, + ); + + expect(modalMock.setAttribute).toHaveBeenCalledWith( + 'allow-clipboard-read', + 'true', + ); + expect(modalMock.setAttribute).not.toHaveBeenCalledWith( + 'allow-clipboard-write', + 'true', + ); + }); + + it('should set allow-clipboard-write attribute when allowClipboardWrite is true', () => { + const theme: Theme = 'light'; + + createModal( + 'Test Modal', + 'https://example.com', + theme, + undefined, + false, + false, + true, + ); + + expect(modalMock.setAttribute).toHaveBeenCalledWith( + 'allow-clipboard-write', + 'true', + ); + expect(modalMock.setAttribute).not.toHaveBeenCalledWith( + 'allow-clipboard-read', + 'true', + ); + }); + + it('should set both clipboard attributes when both are true', () => { + const theme: Theme = 'light'; + + createModal( + 'Test Modal', + 'https://example.com', + theme, + undefined, + false, + true, + true, + ); + + expect(modalMock.setAttribute).toHaveBeenCalledWith( + 'allow-clipboard-read', + 'true', + ); + expect(modalMock.setAttribute).toHaveBeenCalledWith( + 'allow-clipboard-write', + 'true', + ); + }); }); diff --git a/plugins/libs/plugins-runtime/src/lib/create-modal.ts b/plugins/libs/plugins-runtime/src/lib/create-modal.ts index 01422c76b6..d6cd2d1623 100644 --- a/plugins/libs/plugins-runtime/src/lib/create-modal.ts +++ b/plugins/libs/plugins-runtime/src/lib/create-modal.ts @@ -10,6 +10,8 @@ export function createModal( theme: Theme, options?: OpenUIOptions, allowDownloads?: boolean, + allowClipboardRead?: boolean, + allowClipboardWrite?: boolean, ) { const modal = document.createElement('plugin-modal') as PluginModalElement; @@ -44,6 +46,14 @@ export function createModal( modal.setAttribute('allow-downloads', 'true'); } + if (allowClipboardRead) { + modal.setAttribute('allow-clipboard-read', 'true'); + } + + if (allowClipboardWrite) { + modal.setAttribute('allow-clipboard-write', 'true'); + } + document.body.appendChild(modal); return modal; diff --git a/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.spec.ts b/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.spec.ts index d7b774c2c3..fb19f291ab 100644 --- a/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.spec.ts @@ -99,6 +99,35 @@ describe('PluginModalElement', () => { modal.remove(); }); + it('should set iframe allow attribute for clipboard permissions', () => { + const modal = document.createElement('plugin-modal'); + modal.setAttribute('title', 'Test modal'); + modal.setAttribute('iframe-src', 'about:blank'); + modal.setAttribute('allow-clipboard-read', 'true'); + modal.setAttribute('allow-clipboard-write', 'true'); + document.body.appendChild(modal); + + const iframe = modal.shadowRoot?.querySelector('iframe'); + expect(iframe).toBeTruthy(); + expect(iframe?.allow).toContain('clipboard-read'); + expect(iframe?.allow).toContain('clipboard-write'); + + modal.remove(); + }); + + it('should not set clipboard allow attributes when permissions are absent', () => { + const modal = document.createElement('plugin-modal'); + modal.setAttribute('title', 'Test modal'); + modal.setAttribute('iframe-src', 'about:blank'); + document.body.appendChild(modal); + + const iframe = modal.shadowRoot?.querySelector('iframe'); + expect(iframe).toBeTruthy(); + expect(iframe?.allow).toBe(''); + + modal.remove(); + }); + it('should dispatch close event when close button is clicked', () => { const modal = document.createElement('plugin-modal'); modal.setAttribute('title', 'Test modal'); diff --git a/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts b/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts index c61ad7fce5..53ea472494 100644 --- a/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts +++ b/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts @@ -52,6 +52,10 @@ export class PluginModalElement extends HTMLElement { const title = this.getAttribute('title'); const iframeSrc = this.getAttribute('iframe-src'); const allowDownloads = this.getAttribute('allow-downloads') || false; + const allowClipboardRead = + this.getAttribute('allow-clipboard-read') || false; + const allowClipboardWrite = + this.getAttribute('allow-clipboard-write') || false; if (!title || !iframeSrc) { throw new Error('title and iframe-src attributes are required'); @@ -95,7 +99,12 @@ export class PluginModalElement extends HTMLElement { const iframe = document.createElement('iframe'); iframe.src = iframeSrc; - iframe.allow = ''; + + const allowList: string[] = []; + if (allowClipboardRead) allowList.push('clipboard-read'); + if (allowClipboardWrite) allowList.push('clipboard-write'); + iframe.allow = allowList.join('; '); + iframe.sandbox.add( 'allow-scripts', 'allow-forms', diff --git a/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts b/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts index bd5895c02b..16d4fd5e28 100644 --- a/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts +++ b/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts @@ -19,6 +19,8 @@ export const manifestSchema = z.object({ 'comment:write', 'allow:downloads', 'allow:localstorage', + 'clipboard:read', + 'clipboard:write', ]), ), }); diff --git a/plugins/libs/plugins-runtime/src/lib/plugin-manager.spec.ts b/plugins/libs/plugins-runtime/src/lib/plugin-manager.spec.ts index 206f43ba34..d53f4f5296 100644 --- a/plugins/libs/plugins-runtime/src/lib/plugin-manager.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/plugin-manager.spec.ts @@ -123,6 +123,8 @@ describe('createPluginManager', () => { 'light', { width: 400, height: 300 }, true, + false, + false, ); expect(mockModal.setTheme).toHaveBeenCalledWith('light'); expect(mockModal.addEventListener).toHaveBeenCalledWith( diff --git a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts index 0b4035794a..8b811f55eb 100644 --- a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts +++ b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts @@ -27,6 +27,14 @@ export async function createPluginManager( (s) => s === 'allow:downloads', ); + const allowClipboardRead = !!manifest.permissions.find( + (s) => s === 'clipboard:read', + ); + + const allowClipboardWrite = !!manifest.permissions.find( + (s) => s === 'clipboard:write', + ); + const themeChangeId = context.addListener('themechange', (theme: Theme) => { modal?.setTheme(theme); }); @@ -91,7 +99,15 @@ export async function createPluginManager( return; } - modal = openUIApi(name, modalUrl, theme, options, allowDownloads); + modal = openUIApi( + name, + modalUrl, + theme, + options, + allowDownloads, + allowClipboardRead, + allowClipboardWrite, + ); modal.setTheme(theme); From 8aacda22499b8c9aa8ec3f97665efffba581b472 Mon Sep 17 00:00:00 2001 From: Renzo <170978465+RenzoMXD@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:08:31 +0200 Subject: [PATCH 2/6] :sparkles: Add Shift+Numpad0/1/2 zoom shortcut aliases (#2457) (#9063) Signed-off-by: RenzoMXD <170978465+RenzoMXD@users.noreply.github.com> --- CHANGES.md | 2 +- frontend/packages/mousetrap/index.js | 8 ++++++++ frontend/src/app/main/data/workspace/shortcuts.cljs | 8 ++++---- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 4e63461ac5..ad5948645b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -46,7 +46,7 @@ - Add a search bar to filter colors in the color palette toolbar (by @eureka0928) [Github #7653](https://github.com/penpot/penpot/issues/7653) - Allow customising the OIDC login button label (by @wdeveloper16) [Github #7027](https://github.com/penpot/penpot/issues/7027) - Add page separators in Workspace [Taiga #13611](https://tree.taiga.io/project/penpot/us/13611?milestone=262806) - +- Add Shift+Numpad0/1/2 as aliases to Shift+0/1/2 for zoom shortcuts [Github #2457](https://github.com/penpot/penpot/issues/2457) ### :bug: Bugs fixed diff --git a/frontend/packages/mousetrap/index.js b/frontend/packages/mousetrap/index.js index 5a0bc3e0bc..12bcbab1b9 100644 --- a/frontend/packages/mousetrap/index.js +++ b/frontend/packages/mousetrap/index.js @@ -187,6 +187,14 @@ function _addEvent(object, type, callback) { */ function _characterFromEvent(e) { + // Numpad digits as "num0".."num9" — keeps them separate from main-row bindings across NumLock states and event types. + if (e.code && e.code.indexOf('Numpad') === 0) { + var suffix = e.code.substring(6); + if (suffix.length === 1 && suffix >= '0' && suffix <= '9') { + return 'num' + suffix; + } + } + // for keypress events we should return the character as is if (e.type == 'keypress') { var character = String.fromCharCode(e.which); diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs index a89662e8d3..e7ff9a99ed 100644 --- a/frontend/src/app/main/data/workspace/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/shortcuts.cljs @@ -514,17 +514,17 @@ :fn #(st/emit! (dw/decrease-zoom))} :reset-zoom {:tooltip (ds/shift "0") - :command "shift+0" + :command ["shift+0" "shift+num0"] :subsections [:zoom-workspace] :fn #(st/emit! dw/reset-zoom)} :fit-all {:tooltip (ds/shift "1") - :command "shift+1" + :command ["shift+1" "shift+num1"] :subsections [:zoom-workspace] :fn #(st/emit! dw/zoom-to-fit-all)} :zoom-selected {:tooltip (ds/shift "2") - :command ["shift+2" "@" "\""] + :command ["shift+2" "shift+num2" "@" "\""] :subsections [:zoom-workspace] :fn #(st/emit! dw/zoom-to-selected-shape)} @@ -626,7 +626,7 @@ (range 10) (map (fn [n] [(keyword (str "opacity-" n)) {:tooltip (str n) - :command (str n) + :command [(str n) (str "num" n)] :subsections [:modify-layers] :fn #(emit-when-no-readonly (dwly/pressed-opacity n))}]))))) From 6c7843f4b60d94cb05573874a3ae28fc28900f9d Mon Sep 17 00:00:00 2001 From: boskodev790 Date: Fri, 24 Apr 2026 02:09:49 -0500 Subject: [PATCH 3/6] :bug: Fix obfuscate-email crashing on malformed email or dotless domain (#9120) The viewer-side `obfuscate-email` helper used by `anonymize-member` when building share-link bundles called `clojure.string/split` on the raw email input and then on the extracted domain. Two failure modes: 1. When the stored email had no `@` (legacy data, LDAP-sourced UIDs, direct DB inserts, or fixtures that bypassed `::sm/email`), destructuring left `domain` bound to `nil` and the follow-up `(str/split nil "." 2)` raised `NullPointerException`. Because `obfuscate-email` runs inside `get-view-only-bundle`, the exception aborted the whole RPC response for share-link viewers, not just the field. 2. When the stored email used a single-label domain (`alice@localhost`), `(str/split "localhost" "." 2)` returned `["localhost"]`; destructuring bound `rest` to `nil` and the final `(str name "@****." rest)` produced a dangling-dot output `"****@****."` (nil coerces to empty in `str`). Guard both split calls with `(or x "")` so the chain is nil-safe, and emit the trailing `.` segment only when `rest` is present. Add three `deftest` groups covering the happy path, dotless domains, and malformed inputs (nil / empty / no-`@`), plus a CHANGES.md entry under the 2.17.0 Unreleased bugs-fixed section. Signed-off-by: Andrey Antukh Co-authored-by: Andrey Antukh --- CHANGES.md | 1 + backend/src/app/rpc/commands/viewer.clj | 12 +++++++--- .../test/backend_tests/rpc_viewer_test.clj | 23 +++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index ad5948645b..358adbed71 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -50,6 +50,7 @@ ### :bug: Bugs fixed +- Fix `get-view-only-bundle` crashing when a share-link viewer encounters a team member whose email lacks `@` (NullPointerException in `obfuscate-email`) or whose domain has no `.` (previously produced a dangling-dot `****@****.`); now the viewer-side obfuscation is nil-safe and omits the trailing dot when the domain has no TLD - Remove `corepack` from the MCP local launcher so it runs on Node.js 25+, where corepack is no longer bundled [Github #8877](https://github.com/penpot/penpot/issues/8877) - Fix Copy as SVG: emit a single valid SVG document when multiple shapes are selected, and publish `image/svg+xml` to the clipboard so the paste target works in Inkscape and other SVG-native tools [Github #838](https://github.com/penpot/penpot/issues/838) - Reset profile submenu state when the account menu closes (by @eureka0928) [Github #8947](https://github.com/penpot/penpot/issues/8947) diff --git a/backend/src/app/rpc/commands/viewer.clj b/backend/src/app/rpc/commands/viewer.clj index d2b191aeb4..37adca244f 100644 --- a/backend/src/app/rpc/commands/viewer.clj +++ b/backend/src/app/rpc/commands/viewer.clj @@ -28,19 +28,25 @@ (update :pages-index select-keys allowed))) (defn obfuscate-email + "Obfuscate the `email` for share-link members so the viewer only sees a + partially redacted address. Accepts any string shape (including nil, + missing `@`, or a domain with no `.`) and falls back to a fully-masked + result rather than throwing — the function is called while building the + view-only bundle for anonymous viewers, so an NPE here would abort the + entire share-link response." [email] (let [[name domain] - (str/split email "@" 2) + (str/split (or email "") "@" 2) [_ rest] - (str/split domain "." 2) + (str/split (or domain "") "." 2) name (if (> (count name) 3) (str (subs name 0 1) (apply str (take (dec (count name)) (repeat "*")))) "****")] - (str name "@****." rest))) + (str name "@****" (when rest (str "." rest))))) (defn anonymize-member [member] diff --git a/backend/test/backend_tests/rpc_viewer_test.clj b/backend/test/backend_tests/rpc_viewer_test.clj index 6c68c12e34..1e69ed87af 100644 --- a/backend/test/backend_tests/rpc_viewer_test.clj +++ b/backend/test/backend_tests/rpc_viewer_test.clj @@ -9,6 +9,7 @@ [app.common.uuid :as uuid] [app.db :as db] [app.rpc :as-alias rpc] + [app.rpc.commands.viewer :as viewer] [backend-tests.helpers :as th] [clojure.test :as t] [datoteka.fs :as fs])) @@ -16,6 +17,28 @@ (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) +(t/deftest obfuscate-email-happy-path + (t/is (= "a****@****.com" (viewer/obfuscate-email "alice@example.com"))) + (t/is (= "a****@****.example.com" (viewer/obfuscate-email "alice@sub.example.com"))) + (t/is (= "****@****.com" (viewer/obfuscate-email "bob@bar.com")))) + +(t/deftest obfuscate-email-handles-domain-without-dot + ;; `localhost`-style domains have no `.`; the previous implementation produced + ;; a dangling-dot output like "a****@****." — now the trailing `.` is only + ;; emitted when there actually is a TLD segment to append. + (t/is (= "a****@****" (viewer/obfuscate-email "alice@localhost"))) + (t/is (= "****@****" (viewer/obfuscate-email "x@y")))) + +(t/deftest obfuscate-email-handles-malformed-input + ;; These shapes must not throw — `obfuscate-email` runs while building the + ;; view-only bundle for share-link viewers and an NPE here aborts the whole + ;; RPC response. The previous implementation called `clojure.string/split` + ;; on `nil` for the `no-@` case, raising NullPointerException. + (t/is (= "****@****" (viewer/obfuscate-email nil))) + (t/is (= "****@****" (viewer/obfuscate-email ""))) + (t/is (= "r***@****" (viewer/obfuscate-email "root"))) ; no `@`, count > 3 + (t/is (= "****@****" (viewer/obfuscate-email "bob")))) ; no `@`, count <= 3 + (t/deftest retrieve-bundle (let [prof (th/create-profile* 1 {:is-active true}) prof2 (th/create-profile* 2 {:is-active true}) From 841b2e156e657f6a889429448e71ae698ef1662f Mon Sep 17 00:00:00 2001 From: Juan Flores <112629487+juan-flores077@users.noreply.github.com> Date: Fri, 24 Apr 2026 00:11:31 -0700 Subject: [PATCH 4/6] :bug: Fix typography style creation with tokenized line-height (#9121) When a text element has a line-height coming from a design token, the value may be a number (e.g. 1.5) and fails frontend data validation expecting a string. Normalize line-height before creating the typography style so the operation succeeds without throwing an assertion error. Signed-off-by: juan-flores077 --- CHANGES.md | 2 +- frontend/src/app/main/data/workspace/texts.cljs | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 358adbed71..cad6fe13a8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -78,7 +78,7 @@ - Fix copy to be more specific [Taiga #13990](https://tree.taiga.io/project/penpot/issue/13990) - Allow deleting the profile avatar after uploading [Github #9067](https://github.com/penpot/penpot/issues/9067) - Fix incorrect rendering when exporting text as SVG, PNG and JPG (by @edwin-rivera-dev) [Github #8516](https://github.com/penpot/penpot/issues/8516) - +- Fix typography style creation with tokenized line-height (by @juan-flores077) [Github #8479](https://github.com/penpot/penpot/issues/8479) ## 2.16.0 (Unreleased) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index e3eccc05ab..bb604b3f2f 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -935,6 +935,12 @@ (d/concat-vec txt/text-font-attrs txt/text-spacing-attrs txt/text-transform-attrs))) + values (cond-> values + (number? (:line-height values)) + (update :line-height str) + + (number? (:letter-spacing values)) + (update :letter-spacing str)) typ-id (uuid/next) typ (-> (if multiple? From 361c1c574b8254f3d5ee815900abdeb0c267f566 Mon Sep 17 00:00:00 2001 From: FairyPiggyDev Date: Fri, 24 Apr 2026 03:12:13 -0400 Subject: [PATCH 5/6] :bug: Fix plugin parse-point returning plain map instead of Point record (#9129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin parser's parse-point returned a plain `{:x … :y …}` map, but shape interaction schemas (for example schema:open-overlay-interaction) require the attribute to be a `::gpt/point` record. `(instance? Point {:x 0 :y 0})` is false, so validation silently rejected plugin `addInteraction` calls that passed `manualPositionLocation`; only a console warning was produced. Change parse-point to return a `gpt/point` record via `gpt/point`. All three call sites (parser.cljs:open-overlay, plugins/page.cljs, plugins/comments.cljs) continue to work because Point records support the same `:x`/`:y` access plain maps do. Add a unit test that covers nil input and verifies the returned value satisfies `gpt/point?`. Github #8409 Signed-off-by: FairyPigDev Signed-off-by: Andrey Antukh Co-authored-by: Andrey Antukh --- CHANGES.md | 1 + frontend/src/app/plugins/parser.cljs | 11 +++++-- .../frontend_tests/plugins/parser_test.cljs | 33 +++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 ++ 4 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 frontend/test/frontend_tests/plugins/parser_test.cljs diff --git a/CHANGES.md b/CHANGES.md index cad6fe13a8..c83eb3249d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -78,6 +78,7 @@ - Fix copy to be more specific [Taiga #13990](https://tree.taiga.io/project/penpot/issue/13990) - Allow deleting the profile avatar after uploading [Github #9067](https://github.com/penpot/penpot/issues/9067) - Fix incorrect rendering when exporting text as SVG, PNG and JPG (by @edwin-rivera-dev) [Github #8516](https://github.com/penpot/penpot/issues/8516) +- Fix plugin `addInteraction` silently rejecting `open-overlay` actions with `manualPositionLocation` [Github #8409](https://github.com/penpot/penpot/issues/8409) - Fix typography style creation with tokenized line-height (by @juan-flores077) [Github #8479](https://github.com/penpot/penpot/issues/8479) ## 2.16.0 (Unreleased) diff --git a/frontend/src/app/plugins/parser.cljs b/frontend/src/app/plugins/parser.cljs index 5d4148662d..29ec5c2bf7 100644 --- a/frontend/src/app/plugins/parser.cljs +++ b/frontend/src/app/plugins/parser.cljs @@ -7,6 +7,7 @@ (ns app.plugins.parser (:require [app.common.data :as d] + [app.common.geom.point :as gpt] [app.common.json :as json] [app.common.types.path :as path] [app.common.uuid :as uuid] @@ -26,10 +27,16 @@ (if (string? color) (-> color str/lower) color)) (defn parse-point + "Parses a point-like JS object into a `gpt/point` record. + + The schema for shape interactions (`schema:open-overlay-interaction`, + `::gpt/point`) requires a Point record — returning a plain map caused + plugin `addInteraction` calls with an `open-overlay` action and a + `manualPositionLocation` to be silently rejected. See issue #8409." [^js point] (when point - {:x (obj/get point "x") - :y (obj/get point "y")})) + (gpt/point (obj/get point "x") + (obj/get point "y")))) (defn parse-shape-type [type] diff --git a/frontend/test/frontend_tests/plugins/parser_test.cljs b/frontend/test/frontend_tests/plugins/parser_test.cljs new file mode 100644 index 0000000000..4b257b2023 --- /dev/null +++ b/frontend/test/frontend_tests/plugins/parser_test.cljs @@ -0,0 +1,33 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns frontend-tests.plugins.parser-test + (:require + [app.common.geom.point :as gpt] + [app.plugins.parser :as parser] + [cljs.test :as t :include-macros true])) + +(t/deftest test-parse-point-returns-gpt-point-record + ;; Regression test for issue #8409. + ;; + ;; The plugin parser used to return a plain map `{:x … :y …}`, but the + ;; shape-interaction schema expects `::gpt/point` (a Point record). + ;; Plugin `addInteraction` calls with an `open-overlay` action and + ;; `manualPositionLocation` were silently rejected by validation. + (t/testing "parse-point returns nil for nil input" + (t/is (nil? (parser/parse-point nil)))) + + (t/testing "parse-point returns a gpt/point record for valid input" + (let [result (parser/parse-point #js {:x 10 :y 20})] + (t/is (gpt/point? result)) + (t/is (= 10 (:x result))) + (t/is (= 20 (:y result))))) + + (t/testing "parse-point passes gpt/point? for a zero point" + (let [result (parser/parse-point #js {:x 0 :y 0})] + (t/is (gpt/point? result)) + (t/is (= 0 (:x result))) + (t/is (= 0 (:y result)))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 174ef34056..f54d9b5002 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -20,6 +20,7 @@ [frontend-tests.logic.pasting-in-containers-test] [frontend-tests.main-errors-test] [frontend-tests.plugins.context-shapes-test] + [frontend-tests.plugins.parser-test] [frontend-tests.svg-fills-test] [frontend-tests.tokens.import-export-test] [frontend-tests.tokens.logic.token-actions-test] @@ -63,6 +64,7 @@ 'frontend-tests.logic.groups-test 'frontend-tests.logic.pasting-in-containers-test 'frontend-tests.plugins.context-shapes-test + 'frontend-tests.plugins.parser-test 'frontend-tests.svg-fills-test 'frontend-tests.tokens.import-export-test 'frontend-tests.tokens.logic.token-actions-test From 25e6b939ba49f726008168705c5f7b9483b2a5a5 Mon Sep 17 00:00:00 2001 From: Full Stack Developer <30417830+jsdevninja@users.noreply.github.com> Date: Fri, 24 Apr 2026 02:13:46 -0500 Subject: [PATCH 6/6] :sparkles: Show detailed messages on file import errors (#9004) * :sparkles: Show detailed messages on file import errors Signed-off-by: jsdevninja * :sparkles: Fix test * :sparkles: Fix build error --------- Signed-off-by: jsdevninja --- .../src/app/main/ui/dashboard/import.cljs | 11 +++++-- .../src/app/main/ui/dashboard/import.scss | 17 +++++++++- frontend/src/app/worker/import.cljs | 31 ++++++++++++++----- 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard/import.cljs b/frontend/src/app/main/ui/dashboard/import.cljs index 6b4fe68678..1aa4282d7a 100644 --- a/frontend/src/app/main/ui/dashboard/import.cljs +++ b/frontend/src/app/main/ui/dashboard/import.cljs @@ -295,7 +295,9 @@ import-error? [:div {:class (stl/css :error-message)} - (tr "labels.error")] + (if (some? (:error entry)) + (tr (:error entry)) + (tr "labels.error"))] (and (not import-success?) (some? progress)) [:div {:class (stl/css :progress-message)} (parse-progress-message progress)]) @@ -491,7 +493,12 @@ [:ul {:class (stl/css :import-error-list)} (for [entry entries] (when (contains? #{:import-error :analyze-error} (:status entry)) - [:li {:class (stl/css :import-error-list-enry)} (:name entry)]))] + [:li {:class (stl/css :import-error-list-enry) + :key (dm/str (or (:file-id entry) (:uri entry) (:name entry)))} + [:div (:name entry)] + (when-let [err (:error entry)] + [:div {:class (stl/css :import-error-detail)} + (tr err)])]))] [:div (tr "dashboard.import.import-error.message2")]] (for [entry entries] diff --git a/frontend/src/app/main/ui/dashboard/import.scss b/frontend/src/app/main/ui/dashboard/import.scss index 7d8d0ff428..2d3cb22e67 100644 --- a/frontend/src/app/main/ui/dashboard/import.scss +++ b/frontend/src/app/main/ui/dashboard/import.scss @@ -149,10 +149,16 @@ .progress-message { display: flex; align-items: center; - height: deprecated.$s-32; + min-height: deprecated.$s-32; color: var(--modal-text-foreground-color); } + .error-message { + align-items: flex-start; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + .linked-library { display: flex; align-items: center; @@ -258,3 +264,12 @@ .import-error-list-enry { padding: var(--sp-xs) 0; } + +.import-error-detail { + @include deprecated.body-small-typography; + + margin-top: var(--sp-xs); + color: var(--modal-text-foreground-color); + white-space: pre-wrap; + overflow-wrap: anywhere; +} diff --git a/frontend/src/app/worker/import.cljs b/frontend/src/app/worker/import.cljs index 20c314f012..402da4ad5c 100644 --- a/frontend/src/app/worker/import.cljs +++ b/frontend/src/app/worker/import.cljs @@ -23,6 +23,22 @@ (log/set-level! :warn) +(defn- import-cause-message + "Prefer the server `:hint` (full text, e.g. SSE error payload), then `:explain` + when present; avoid the generic `stream exception` wrapper when a payload exists." + [cause default-msg] + (let [data (ex-data cause) + hint (some-> data :hint str/trim) + explain (some-> data :explain str/trim)] + (cond + (not (str/blank? hint)) hint + (not (str/blank? explain)) explain + :else + (let [msg (some-> (ex-message cause) str/trim)] + (if (or (str/blank? msg) (= msg "stream exception")) + default-msg + msg))))) + ;; Upload changes batches size (def ^:const change-batch-size 100) @@ -122,7 +138,7 @@ :error (tr "dashboard.import.analyze-error")})))) (rx/catch (fn [cause] - (let [error (or (ex-message cause) (tr "dashboard.import.analyze-error"))] + (let [error (import-cause-message cause (tr "dashboard.import.analyze-error"))] (rx/of (assoc file :error error :status :error)))))))) (defmethod impl/handler :analyze-import @@ -178,7 +194,7 @@ :project-id project-id :cause cause) (rx/of {:status :error - :error (ex-message cause) + :error (import-cause-message cause (tr "labels.error")) :file-id (:file-id data)}))))))) (->> (rx/from binfile-v3) @@ -212,8 +228,9 @@ :project-id project-id ::log/sync? true :cause cause) - (->> (rx/from entries) - (rx/map (fn [entry] - {:status :error - :error (ex-message cause) - :file-id (:file-id entry)})))))))))))) + (let [err (import-cause-message cause (tr "labels.error"))] + (->> (rx/from entries) + (rx/map (fn [entry] + {:status :error + :error err + :file-id (:file-id entry)})))))))))))))