mirror of
https://github.com/penpot/penpot.git
synced 2026-04-25 11:18:36 +00:00
Merge remote-tracking branch 'origin/develop' into develop
This commit is contained in:
commit
7532bf411c
@ -46,10 +46,11 @@
|
|||||||
- Add a search bar to filter colors in the color palette toolbar (by @eureka0928) [Github #7653](https://github.com/penpot/penpot/issues/7653)
|
- 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)
|
- 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 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
|
### :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)
|
- 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)
|
- 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)
|
- Reset profile submenu state when the account menu closes (by @eureka0928) [Github #8947](https://github.com/penpot/penpot/issues/8947)
|
||||||
@ -77,7 +78,8 @@
|
|||||||
- Fix copy to be more specific [Taiga #13990](https://tree.taiga.io/project/penpot/issue/13990)
|
- 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)
|
- 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 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)
|
## 2.16.0 (Unreleased)
|
||||||
|
|
||||||
|
|||||||
@ -28,19 +28,25 @@
|
|||||||
(update :pages-index select-keys allowed)))
|
(update :pages-index select-keys allowed)))
|
||||||
|
|
||||||
(defn obfuscate-email
|
(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]
|
[email]
|
||||||
(let [[name domain]
|
(let [[name domain]
|
||||||
(str/split email "@" 2)
|
(str/split (or email "") "@" 2)
|
||||||
|
|
||||||
[_ rest]
|
[_ rest]
|
||||||
(str/split domain "." 2)
|
(str/split (or domain "") "." 2)
|
||||||
|
|
||||||
name
|
name
|
||||||
(if (> (count name) 3)
|
(if (> (count name) 3)
|
||||||
(str (subs name 0 1) (apply str (take (dec (count name)) (repeat "*"))))
|
(str (subs name 0 1) (apply str (take (dec (count name)) (repeat "*"))))
|
||||||
"****")]
|
"****")]
|
||||||
|
|
||||||
(str name "@****." rest)))
|
(str name "@****" (when rest (str "." rest)))))
|
||||||
|
|
||||||
(defn anonymize-member
|
(defn anonymize-member
|
||||||
[member]
|
[member]
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
|
[app.rpc.commands.viewer :as viewer]
|
||||||
[backend-tests.helpers :as th]
|
[backend-tests.helpers :as th]
|
||||||
[clojure.test :as t]
|
[clojure.test :as t]
|
||||||
[datoteka.fs :as fs]))
|
[datoteka.fs :as fs]))
|
||||||
@ -16,6 +17,28 @@
|
|||||||
(t/use-fixtures :once th/state-init)
|
(t/use-fixtures :once th/state-init)
|
||||||
(t/use-fixtures :each th/database-reset)
|
(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
|
(t/deftest retrieve-bundle
|
||||||
(let [prof (th/create-profile* 1 {:is-active true})
|
(let [prof (th/create-profile* 1 {:is-active true})
|
||||||
prof2 (th/create-profile* 2 {:is-active true})
|
prof2 (th/create-profile* 2 {:is-active true})
|
||||||
|
|||||||
@ -187,6 +187,14 @@ function _addEvent(object, type, callback) {
|
|||||||
*/
|
*/
|
||||||
function _characterFromEvent(e) {
|
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
|
// for keypress events we should return the character as is
|
||||||
if (e.type == 'keypress') {
|
if (e.type == 'keypress') {
|
||||||
var character = String.fromCharCode(e.which);
|
var character = String.fromCharCode(e.which);
|
||||||
|
|||||||
@ -514,17 +514,17 @@
|
|||||||
:fn #(st/emit! (dw/decrease-zoom))}
|
:fn #(st/emit! (dw/decrease-zoom))}
|
||||||
|
|
||||||
:reset-zoom {:tooltip (ds/shift "0")
|
:reset-zoom {:tooltip (ds/shift "0")
|
||||||
:command "shift+0"
|
:command ["shift+0" "shift+num0"]
|
||||||
:subsections [:zoom-workspace]
|
:subsections [:zoom-workspace]
|
||||||
:fn #(st/emit! dw/reset-zoom)}
|
:fn #(st/emit! dw/reset-zoom)}
|
||||||
|
|
||||||
:fit-all {:tooltip (ds/shift "1")
|
:fit-all {:tooltip (ds/shift "1")
|
||||||
:command "shift+1"
|
:command ["shift+1" "shift+num1"]
|
||||||
:subsections [:zoom-workspace]
|
:subsections [:zoom-workspace]
|
||||||
:fn #(st/emit! dw/zoom-to-fit-all)}
|
:fn #(st/emit! dw/zoom-to-fit-all)}
|
||||||
|
|
||||||
:zoom-selected {:tooltip (ds/shift "2")
|
:zoom-selected {:tooltip (ds/shift "2")
|
||||||
:command ["shift+2" "@" "\""]
|
:command ["shift+2" "shift+num2" "@" "\""]
|
||||||
:subsections [:zoom-workspace]
|
:subsections [:zoom-workspace]
|
||||||
:fn #(st/emit! dw/zoom-to-selected-shape)}
|
:fn #(st/emit! dw/zoom-to-selected-shape)}
|
||||||
|
|
||||||
@ -626,7 +626,7 @@
|
|||||||
(range 10)
|
(range 10)
|
||||||
(map (fn [n] [(keyword (str "opacity-" n))
|
(map (fn [n] [(keyword (str "opacity-" n))
|
||||||
{:tooltip (str n)
|
{:tooltip (str n)
|
||||||
:command (str n)
|
:command [(str n) (str "num" n)]
|
||||||
:subsections [:modify-layers]
|
:subsections [:modify-layers]
|
||||||
:fn #(emit-when-no-readonly (dwly/pressed-opacity n))}])))))
|
:fn #(emit-when-no-readonly (dwly/pressed-opacity n))}])))))
|
||||||
|
|
||||||
|
|||||||
@ -935,6 +935,12 @@
|
|||||||
(d/concat-vec txt/text-font-attrs
|
(d/concat-vec txt/text-font-attrs
|
||||||
txt/text-spacing-attrs
|
txt/text-spacing-attrs
|
||||||
txt/text-transform-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-id (uuid/next)
|
||||||
typ (-> (if multiple?
|
typ (-> (if multiple?
|
||||||
|
|||||||
@ -295,7 +295,9 @@
|
|||||||
|
|
||||||
import-error?
|
import-error?
|
||||||
[:div {:class (stl/css :error-message)}
|
[: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))
|
(and (not import-success?) (some? progress))
|
||||||
[:div {:class (stl/css :progress-message)} (parse-progress-message progress)])
|
[:div {:class (stl/css :progress-message)} (parse-progress-message progress)])
|
||||||
@ -491,7 +493,12 @@
|
|||||||
[:ul {:class (stl/css :import-error-list)}
|
[:ul {:class (stl/css :import-error-list)}
|
||||||
(for [entry entries]
|
(for [entry entries]
|
||||||
(when (contains? #{:import-error :analyze-error} (:status entry))
|
(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")]]
|
[:div (tr "dashboard.import.import-error.message2")]]
|
||||||
|
|
||||||
(for [entry entries]
|
(for [entry entries]
|
||||||
|
|||||||
17
frontend/src/app/main/ui/dashboard/import.scss
vendored
17
frontend/src/app/main/ui/dashboard/import.scss
vendored
@ -149,10 +149,16 @@
|
|||||||
.progress-message {
|
.progress-message {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: deprecated.$s-32;
|
min-height: deprecated.$s-32;
|
||||||
color: var(--modal-text-foreground-color);
|
color: var(--modal-text-foreground-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
align-items: flex-start;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.linked-library {
|
.linked-library {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -258,3 +264,12 @@
|
|||||||
.import-error-list-enry {
|
.import-error-list-enry {
|
||||||
padding: var(--sp-xs) 0;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -302,7 +302,20 @@
|
|||||||
[:div {:class (stl/css :permissions-list-entry)}
|
[:div {:class (stl/css :permissions-list-entry)}
|
||||||
deprecated-icon/oauth-1
|
deprecated-icon/oauth-1
|
||||||
[:p {:class (stl/css :permissions-list-text)}
|
[: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/defc plugins-permissions-dialog
|
||||||
{::mf/register modal/components
|
{::mf/register modal/components
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
(ns app.plugins.parser
|
(ns app.plugins.parser
|
||||||
(:require
|
(:require
|
||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
|
[app.common.geom.point :as gpt]
|
||||||
[app.common.json :as json]
|
[app.common.json :as json]
|
||||||
[app.common.types.path :as path]
|
[app.common.types.path :as path]
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
@ -26,10 +27,16 @@
|
|||||||
(if (string? color) (-> color str/lower) color))
|
(if (string? color) (-> color str/lower) color))
|
||||||
|
|
||||||
(defn parse-point
|
(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]
|
[^js point]
|
||||||
(when point
|
(when point
|
||||||
{:x (obj/get point "x")
|
(gpt/point (obj/get point "x")
|
||||||
:y (obj/get point "y")}))
|
(obj/get point "y"))))
|
||||||
|
|
||||||
(defn parse-shape-type
|
(defn parse-shape-type
|
||||||
[type]
|
[type]
|
||||||
|
|||||||
@ -54,7 +54,10 @@
|
|||||||
(conj "library:read")
|
(conj "library:read")
|
||||||
|
|
||||||
(contains? permissions "comment:write")
|
(contains? permissions "comment:write")
|
||||||
(conj "comment:read"))
|
(conj "comment:read")
|
||||||
|
|
||||||
|
(contains? permissions "clipboard:write")
|
||||||
|
(conj "clipboard:read"))
|
||||||
|
|
||||||
plugin-url
|
plugin-url
|
||||||
(u/uri plugin-url)
|
(u/uri plugin-url)
|
||||||
|
|||||||
@ -23,6 +23,22 @@
|
|||||||
|
|
||||||
(log/set-level! :warn)
|
(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
|
;; Upload changes batches size
|
||||||
(def ^:const change-batch-size 100)
|
(def ^:const change-batch-size 100)
|
||||||
|
|
||||||
@ -122,7 +138,7 @@
|
|||||||
:error (tr "dashboard.import.analyze-error")}))))
|
:error (tr "dashboard.import.analyze-error")}))))
|
||||||
|
|
||||||
(rx/catch (fn [cause]
|
(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))))))))
|
(rx/of (assoc file :error error :status :error))))))))
|
||||||
|
|
||||||
(defmethod impl/handler :analyze-import
|
(defmethod impl/handler :analyze-import
|
||||||
@ -178,7 +194,7 @@
|
|||||||
:project-id project-id
|
:project-id project-id
|
||||||
:cause cause)
|
:cause cause)
|
||||||
(rx/of {:status :error
|
(rx/of {:status :error
|
||||||
:error (ex-message cause)
|
:error (import-cause-message cause (tr "labels.error"))
|
||||||
:file-id (:file-id data)})))))))
|
:file-id (:file-id data)})))))))
|
||||||
|
|
||||||
(->> (rx/from binfile-v3)
|
(->> (rx/from binfile-v3)
|
||||||
@ -212,8 +228,9 @@
|
|||||||
:project-id project-id
|
:project-id project-id
|
||||||
::log/sync? true
|
::log/sync? true
|
||||||
:cause cause)
|
:cause cause)
|
||||||
(->> (rx/from entries)
|
(let [err (import-cause-message cause (tr "labels.error"))]
|
||||||
(rx/map (fn [entry]
|
(->> (rx/from entries)
|
||||||
{:status :error
|
(rx/map (fn [entry]
|
||||||
:error (ex-message cause)
|
{:status :error
|
||||||
:file-id (:file-id entry)}))))))))))))
|
:error err
|
||||||
|
:file-id (:file-id entry)})))))))))))))
|
||||||
|
|||||||
33
frontend/test/frontend_tests/plugins/parser_test.cljs
Normal file
33
frontend/test/frontend_tests/plugins/parser_test.cljs
Normal file
@ -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))))))
|
||||||
@ -20,6 +20,7 @@
|
|||||||
[frontend-tests.logic.pasting-in-containers-test]
|
[frontend-tests.logic.pasting-in-containers-test]
|
||||||
[frontend-tests.main-errors-test]
|
[frontend-tests.main-errors-test]
|
||||||
[frontend-tests.plugins.context-shapes-test]
|
[frontend-tests.plugins.context-shapes-test]
|
||||||
|
[frontend-tests.plugins.parser-test]
|
||||||
[frontend-tests.svg-fills-test]
|
[frontend-tests.svg-fills-test]
|
||||||
[frontend-tests.tokens.import-export-test]
|
[frontend-tests.tokens.import-export-test]
|
||||||
[frontend-tests.tokens.logic.token-actions-test]
|
[frontend-tests.tokens.logic.token-actions-test]
|
||||||
@ -63,6 +64,7 @@
|
|||||||
'frontend-tests.logic.groups-test
|
'frontend-tests.logic.groups-test
|
||||||
'frontend-tests.logic.pasting-in-containers-test
|
'frontend-tests.logic.pasting-in-containers-test
|
||||||
'frontend-tests.plugins.context-shapes-test
|
'frontend-tests.plugins.context-shapes-test
|
||||||
|
'frontend-tests.plugins.parser-test
|
||||||
'frontend-tests.svg-fills-test
|
'frontend-tests.svg-fills-test
|
||||||
'frontend-tests.tokens.import-export-test
|
'frontend-tests.tokens.import-export-test
|
||||||
'frontend-tests.tokens.logic.token-actions-test
|
'frontend-tests.tokens.logic.token-actions-test
|
||||||
|
|||||||
@ -7646,6 +7646,14 @@ msgstr ""
|
|||||||
msgid "workspace.plugins.permissions.allow-download"
|
msgid "workspace.plugins.permissions.allow-download"
|
||||||
msgstr "Start file downloads."
|
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
|
#: src/app/main/ui/workspace/plugins.cljs:287
|
||||||
msgid "workspace.plugins.permissions.allow-localstorage"
|
msgid "workspace.plugins.permissions.allow-localstorage"
|
||||||
msgstr "Store data in the browser."
|
msgstr "Store data in the browser."
|
||||||
|
|||||||
@ -10,7 +10,27 @@ export const openUIApi = z
|
|||||||
z.enum(['dark', 'light']),
|
z.enum(['dark', 'light']),
|
||||||
openUISchema.optional(),
|
openUISchema.optional(),
|
||||||
z.boolean().optional(),
|
z.boolean().optional(),
|
||||||
|
z.boolean().optional(),
|
||||||
|
z.boolean().optional(),
|
||||||
)
|
)
|
||||||
.implement((title, url, theme, options, allowDownloads) => {
|
.implement(
|
||||||
return createModal(title, url, theme, options, allowDownloads);
|
(
|
||||||
});
|
title,
|
||||||
|
url,
|
||||||
|
theme,
|
||||||
|
options,
|
||||||
|
allowDownloads,
|
||||||
|
allowClipboardRead,
|
||||||
|
allowClipboardWrite,
|
||||||
|
) => {
|
||||||
|
return createModal(
|
||||||
|
title,
|
||||||
|
url,
|
||||||
|
theme,
|
||||||
|
options,
|
||||||
|
allowDownloads,
|
||||||
|
allowClipboardRead,
|
||||||
|
allowClipboardWrite,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@ -104,4 +104,73 @@ describe('createModal', () => {
|
|||||||
expect(modal.wrapper.style.width).toEqual('200px');
|
expect(modal.wrapper.style.width).toEqual('200px');
|
||||||
expect(modal.wrapper.style.height).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',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,6 +10,8 @@ export function createModal(
|
|||||||
theme: Theme,
|
theme: Theme,
|
||||||
options?: OpenUIOptions,
|
options?: OpenUIOptions,
|
||||||
allowDownloads?: boolean,
|
allowDownloads?: boolean,
|
||||||
|
allowClipboardRead?: boolean,
|
||||||
|
allowClipboardWrite?: boolean,
|
||||||
) {
|
) {
|
||||||
const modal = document.createElement('plugin-modal') as PluginModalElement;
|
const modal = document.createElement('plugin-modal') as PluginModalElement;
|
||||||
|
|
||||||
@ -44,6 +46,14 @@ export function createModal(
|
|||||||
modal.setAttribute('allow-downloads', 'true');
|
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);
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
return modal;
|
return modal;
|
||||||
|
|||||||
@ -99,6 +99,35 @@ describe('PluginModalElement', () => {
|
|||||||
modal.remove();
|
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', () => {
|
it('should dispatch close event when close button is clicked', () => {
|
||||||
const modal = document.createElement('plugin-modal');
|
const modal = document.createElement('plugin-modal');
|
||||||
modal.setAttribute('title', 'Test modal');
|
modal.setAttribute('title', 'Test modal');
|
||||||
|
|||||||
@ -52,6 +52,10 @@ export class PluginModalElement extends HTMLElement {
|
|||||||
const title = this.getAttribute('title');
|
const title = this.getAttribute('title');
|
||||||
const iframeSrc = this.getAttribute('iframe-src');
|
const iframeSrc = this.getAttribute('iframe-src');
|
||||||
const allowDownloads = this.getAttribute('allow-downloads') || false;
|
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) {
|
if (!title || !iframeSrc) {
|
||||||
throw new Error('title and iframe-src attributes are required');
|
throw new Error('title and iframe-src attributes are required');
|
||||||
@ -95,7 +99,12 @@ export class PluginModalElement extends HTMLElement {
|
|||||||
|
|
||||||
const iframe = document.createElement('iframe');
|
const iframe = document.createElement('iframe');
|
||||||
iframe.src = iframeSrc;
|
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(
|
iframe.sandbox.add(
|
||||||
'allow-scripts',
|
'allow-scripts',
|
||||||
'allow-forms',
|
'allow-forms',
|
||||||
|
|||||||
@ -19,6 +19,8 @@ export const manifestSchema = z.object({
|
|||||||
'comment:write',
|
'comment:write',
|
||||||
'allow:downloads',
|
'allow:downloads',
|
||||||
'allow:localstorage',
|
'allow:localstorage',
|
||||||
|
'clipboard:read',
|
||||||
|
'clipboard:write',
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -123,6 +123,8 @@ describe('createPluginManager', () => {
|
|||||||
'light',
|
'light',
|
||||||
{ width: 400, height: 300 },
|
{ width: 400, height: 300 },
|
||||||
true,
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
expect(mockModal.setTheme).toHaveBeenCalledWith('light');
|
expect(mockModal.setTheme).toHaveBeenCalledWith('light');
|
||||||
expect(mockModal.addEventListener).toHaveBeenCalledWith(
|
expect(mockModal.addEventListener).toHaveBeenCalledWith(
|
||||||
|
|||||||
@ -27,6 +27,14 @@ export async function createPluginManager(
|
|||||||
(s) => s === 'allow:downloads',
|
(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) => {
|
const themeChangeId = context.addListener('themechange', (theme: Theme) => {
|
||||||
modal?.setTheme(theme);
|
modal?.setTheme(theme);
|
||||||
});
|
});
|
||||||
@ -91,7 +99,15 @@ export async function createPluginManager(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
modal = openUIApi(name, modalUrl, theme, options, allowDownloads);
|
modal = openUIApi(
|
||||||
|
name,
|
||||||
|
modalUrl,
|
||||||
|
theme,
|
||||||
|
options,
|
||||||
|
allowDownloads,
|
||||||
|
allowClipboardRead,
|
||||||
|
allowClipboardWrite,
|
||||||
|
);
|
||||||
|
|
||||||
modal.setTheme(theme);
|
modal.setTheme(theme);
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user