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

This commit is contained in:
Andrey Antukh 2026-03-13 11:41:56 +01:00
commit cf94b56154
25 changed files with 499 additions and 207 deletions

View File

@ -64,6 +64,11 @@
- Fix component "broken" after variant switch [Taiga #12984](https://tree.taiga.io/project/penpot/issue/12984)
- Fix incorrect query for file versions [Github #8463](https://github.com/penpot/penpot/pull/8463)
- Fix warning when clicking on number token pills [Taiga #13661](https://tree.taiga.io/project/penpot/issue/13661)
- Fix 'not ISeqable' error when entering float values in layout item and opacity inputs [Github #8569](https://github.com/penpot/penpot/pull/8569)
- Fix crash in select component when options vector is empty [Github #8578](https://github.com/penpot/penpot/pull/8578)
- Fix scroll on colorpicker [Taiga #13623](https://tree.taiga.io/project/penpot/issue/13623)
- Fix crash when pasting non-map transit clipboard data [Github #8580](https://github.com/penpot/penpot/pull/8580)
- Fix `penpot.openPage()` plugin API not navigating in the same tab; change default to same-tab navigation and allow passing a UUID string instead of a Page object [Github #8520](https://github.com/penpot/penpot/issues/8520)
## 2.13.3

View File

@ -205,40 +205,41 @@
(defn format-inst
([v] (format-inst v :iso))
([v fmt]
(case fmt
(:iso :iso8601)
#?(:clj (.format DateTimeFormatter/ISO_INSTANT ^Instant v)
:cljs (dfn-format-iso v))
(when (some? v)
(case fmt
(:iso :iso8601)
#?(:clj (.format DateTimeFormatter/ISO_INSTANT ^Instant v)
:cljs (dfn-format-iso v))
:iso-date
#?(:clj (.format DateTimeFormatter/ISO_LOCAL_DATE
^ZonedDateTime (ZonedDateTime/ofInstant v (ZoneId/of "UTC")))
:cljs (dfn-format-iso v #js {:representation "date"}))
:iso-date
#?(:clj (.format DateTimeFormatter/ISO_LOCAL_DATE
^ZonedDateTime (ZonedDateTime/ofInstant v (ZoneId/of "UTC")))
:cljs (dfn-format-iso v #js {:representation "date"}))
(:rfc1123 :http)
#?(:clj (.format DateTimeFormatter/RFC_1123_DATE_TIME
^ZonedDateTime (ZonedDateTime/ofInstant v (ZoneId/of "UTC")))
:cljs (dfn-format v "EEE, dd LLL yyyy HH:mm:ss 'GMT'"))
(:rfc1123 :http)
#?(:clj (.format DateTimeFormatter/RFC_1123_DATE_TIME
^ZonedDateTime (ZonedDateTime/ofInstant v (ZoneId/of "UTC")))
:cljs (dfn-format v "EEE, dd LLL yyyy HH:mm:ss 'GMT'"))
#?@(:cljs [:time-24-simple
(dfn-format v "HH:mm")
#?@(:cljs [:time-24-simple
(dfn-format v "HH:mm")
;; DEPRECATED
:date-full
(dfn-format v "PPP")
;; DEPRECATED
:date-full
(dfn-format v "PPP")
:localized-date
(dfn-format v "PPP")
:localized-date
(dfn-format v "PPP")
:localized-time
(dfn-format v "p")
:localized-time
(dfn-format v "p")
:localized-date-time
(dfn-format v "PPP . p")
:localized-date-time
(dfn-format v "PPP . p")
(if (string? fmt)
(dfn-format v fmt)
(throw (js/Error. "unpexted format")))]))))
(if (string? fmt)
(dfn-format v fmt)
(throw (js/Error. "unpexted format")))])))))
#?(:cljs
(def locales

View File

@ -212,25 +212,39 @@
(get-instance-root objects (get objects (:parent-id shape)))))
(defn find-component-main
"If the shape is a component main instance or is inside one, return that instance"
"If the shape is a component main instance or is inside one, return that instance.
Uses an iterative loop with cycle detection to prevent stack overflow on circular
parent references or malformed data structures."
([objects shape]
(find-component-main objects shape true))
([objects shape only-direct-child?]
(cond
(or (nil? shape) (cfh/root? shape))
nil
(nil? (:parent-id shape)) ; This occurs in the root of components v1
shape
(ctk/main-instance? shape)
shape
(and only-direct-child? ;; If we are asking only for direct childs of a component-main,
(ctk/instance-head? shape)) ;; stop when we found a instance-head that isn't main-instance
nil
(and (not only-direct-child?)
(ctk/instance-root? shape))
nil
:else
(find-component-main objects (get objects (:parent-id shape))))))
(loop [shape shape
visited #{}]
(cond
(or (nil? shape) (cfh/root? shape))
nil
;; Cycle detected: we have already visited this shape id
(contains? visited (:id shape))
nil
(nil? (:parent-id shape)) ; This occurs in the root of components v1
shape
(ctk/main-instance? shape)
shape
(and only-direct-child? ;; If we are asking only for direct childs of a component-main,
(ctk/instance-head? shape)) ;; stop when we found a instance-head that isn't main-instance
nil
(and (not only-direct-child?)
(ctk/instance-root? shape))
nil
:else
(recur (get objects (:parent-id shape))
(conj visited (:id shape)))))))
(defn inside-component-main?
"Check if the shape is a component main instance or is inside one."

View File

@ -777,7 +777,7 @@
content as PathData instance."
[content transform]
(if (some? transform)
(impl/-transform content transform)
(impl/-transform (impl/path-data content) transform)
content))
(defn move-content

View File

@ -168,7 +168,7 @@
child-as-paths)]
(-> group
(assoc :type :path)
(assoc :content content)
(assoc :content (path.impl/path-data content))
(merge head-data)
(d/without-keys dissoc-attrs))))
@ -184,7 +184,8 @@
(:bool-type shape)
content
(bool/calculate-content bool-type (map :content children))]
(-> (bool/calculate-content bool-type (map :content children))
(path.impl/path-data))]
(-> shape
(assoc :type :path)

View File

@ -39,6 +39,7 @@
[common-tests.time-test]
[common-tests.types.absorb-assets-test]
[common-tests.types.components-test]
[common-tests.types.container-test]
[common-tests.types.fill-test]
[common-tests.types.modifiers-test]
[common-tests.types.objects-map-test]
@ -92,6 +93,7 @@
'common-tests.time-test
'common-tests.types.absorb-assets-test
'common-tests.types.components-test
'common-tests.types.container-test
'common-tests.types.fill-test
'common-tests.types.modifiers-test
'common-tests.types.objects-map-test

View File

@ -0,0 +1,156 @@
;; 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 common-tests.types.container-test
(:require
[app.common.types.container :as ctc]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
[clojure.test :as t]))
;; ---------------------------------------------------------------------------
;; Helpers
;; ---------------------------------------------------------------------------
(defn- make-shape
"Build a realistic shape using setup-shape, so it has proper geometric
data (selrect, points, transform, …) and follows project data standards."
[id & {:as attrs}]
(cts/setup-shape (merge {:type :rect
:x 0
:y 0
:width 100
:height 100}
attrs
{:id id})))
(defn- objects-map
"Build an objects map from a seq of shapes."
[& shapes]
(into {} (map (juxt :id identity) shapes)))
;; The sentinel root shape (uuid/zero) recognised by cfh/root?
(def root-id uuid/zero)
(defn- root-shape
"Create the page-root frame shape (id = uuid/zero, type :frame)."
[]
(cts/setup-shape {:id root-id
:type :frame
:x 0
:y 0
:width 100
:height 100}))
;; ---------------------------------------------------------------------------
;; Tests base cases
;; ---------------------------------------------------------------------------
(t/deftest find-component-main-nil-shape
(t/testing "returns nil when shape is nil"
(t/is (nil? (ctc/find-component-main {} nil)))))
(t/deftest find-component-main-root-shape
(t/testing "returns nil when shape is the page root (uuid/zero)"
(let [root (root-shape)
objects (objects-map root)]
(t/is (nil? (ctc/find-component-main objects root))))))
(t/deftest find-component-main-no-parent-id
(t/testing "returns the shape itself when parent-id is nil (v1 component root)"
(let [id (uuid/next)
;; Simulate a v1 component root: setup-shape produces a full shape,
;; then we explicitly clear :parent-id to nil, which is how legacy
;; component roots appear in deserialized data.
shape (assoc (make-shape id) :parent-id nil)
objects (objects-map shape)]
(t/is (= shape (ctc/find-component-main objects shape))))))
(t/deftest find-component-main-main-instance
(t/testing "returns the shape when it is a main-instance"
(let [parent-id (uuid/next)
id (uuid/next)
parent (make-shape parent-id)
shape (make-shape id :parent-id parent-id :main-instance true)
objects (objects-map parent shape)]
(t/is (= shape (ctc/find-component-main objects shape))))))
(t/deftest find-component-main-instance-head-stops-when-only-direct-child
(t/testing "returns nil when hitting an instance-head that is not main (only-direct-child? true)"
(let [parent-id (uuid/next)
id (uuid/next)
;; instance-head? ← has :component-id but NOT :main-instance
shape (make-shape id
:parent-id parent-id
:component-id (uuid/next))
parent (make-shape parent-id)
objects (objects-map parent shape)]
(t/is (nil? (ctc/find-component-main objects shape true))))))
(t/deftest find-component-main-instance-root-stops-when-not-only-direct-child
(t/testing "returns nil when hitting an instance-root and only-direct-child? is false"
(let [parent-id (uuid/next)
id (uuid/next)
;; instance-root? ← has :component-root true
shape (make-shape id
:parent-id parent-id
:component-id (uuid/next)
:component-root true)
parent (make-shape parent-id)
objects (objects-map parent shape)]
(t/is (nil? (ctc/find-component-main objects shape false))))))
(t/deftest find-component-main-walks-to-main-ancestor
(t/testing "traverses ancestors and returns the first main-instance found"
(let [gp-id (uuid/next)
p-id (uuid/next)
child-id (uuid/next)
grandparent (make-shape gp-id :parent-id nil :main-instance true)
parent (make-shape p-id :parent-id gp-id)
child (make-shape child-id :parent-id p-id)
objects (objects-map grandparent parent child)]
(t/is (= grandparent (ctc/find-component-main objects child))))))
;; ---------------------------------------------------------------------------
;; Tests cycle detection (the bug fix)
;; ---------------------------------------------------------------------------
(t/deftest find-component-main-direct-self-loop
(t/testing "returns nil (no crash) when a shape's parent-id points to itself"
(let [id (uuid/next)
;; deliberately malformed: parent-id == id (self-loop)
shape (make-shape id :parent-id id)
objects (objects-map shape)]
(t/is (nil? (ctc/find-component-main objects shape))))))
(t/deftest find-component-main-two-node-cycle
(t/testing "returns nil (no crash) for a two-node circular reference A→B→A"
(let [id-a (uuid/next)
id-b (uuid/next)
shape-a (make-shape id-a :parent-id id-b)
shape-b (make-shape id-b :parent-id id-a)
objects (objects-map shape-a shape-b)]
(t/is (nil? (ctc/find-component-main objects shape-a))))))
(t/deftest find-component-main-multi-node-cycle
(t/testing "returns nil (no crash) for a longer cycle A→B→C→A"
(let [id-a (uuid/next)
id-b (uuid/next)
id-c (uuid/next)
shape-a (make-shape id-a :parent-id id-b)
shape-b (make-shape id-b :parent-id id-c)
shape-c (make-shape id-c :parent-id id-a)
objects (objects-map shape-a shape-b shape-c)]
(t/is (nil? (ctc/find-component-main objects shape-a))))))
(t/deftest find-component-main-only-direct-child-with-cycle
(t/testing "cycle detection works correctly with only-direct-child? false as well"
(let [id-a (uuid/next)
id-b (uuid/next)
shape-a (make-shape id-a :parent-id id-b)
shape-b (make-shape id-b :parent-id id-a)
objects (objects-map shape-a shape-b)]
(t/is (nil? (ctc/find-component-main objects shape-a false))))))

View File

@ -270,6 +270,15 @@
(t/is (= result1 result2))
(t/is (= result2 result3))))
(t/deftest path-get-points-nil-safe
(t/testing "path/get-points returns nil for nil content without throwing"
(t/is (nil? (path/get-points nil))))
(t/testing "path/get-points returns correct points for valid content"
(let [content (path/content sample-content)
points (path/get-points content)]
(t/is (some? points))
(t/is (= 3 (count points))))))
(defn calculate-extremities
"Calculate extremities for the provided content.
A legacy implementation used mainly as reference for testing"

View File

@ -258,33 +258,39 @@
#js {:decodeTransit t/decode-str
:allowHTMLPaste (features/active-feature? @st/state "text-editor/v2-html-paste")})
(defn create-paste-from-blob
(defn- create-paste-from-blob
[in-viewport?]
(fn [blob]
(let [type (.-type blob)
result (cond
(= type "image/svg+xml")
(->> (rx/from (.text blob))
(rx/map paste-svg-text))
(let [type (.-type blob)]
(cond
(= type "image/svg+xml")
(->> (rx/from (.text blob))
(rx/map paste-svg-text))
(some #(= type %) clipboard/image-types)
(rx/of (paste-image blob))
(some #(= type %) clipboard/image-types)
(rx/of (paste-image blob))
(= type "text/html")
(->> (rx/from (.text blob))
(rx/map paste-html-text))
(= type "text/html")
(->> (rx/from (.text blob))
(rx/map paste-html-text))
(= type "application/transit+json")
(->> (rx/from (.text blob))
(rx/map (fn [text]
(let [transit-data (t/decode-str text)]
(assoc transit-data :in-viewport in-viewport?))))
(rx/map paste-transit-shapes))
(= type "application/transit+json")
(->> (rx/from (.text blob))
(rx/map t/decode-str)
(rx/filter map?)
(rx/map
(fn [pdata]
(assoc pdata :in-viewport in-viewport?)))
(rx/mapcat
(fn [pdata]
(case (:type pdata)
:copied-props (rx/of (paste-transit-props pdata))
:copied-shapes (rx/of (paste-transit-shapes pdata))
(rx/empty)))))
:else
(->> (rx/from (.text blob))
(rx/map paste-text)))]
result)))
:else
(->> (rx/from (.text blob))
(rx/map paste-text))))))
(def default-paste-from-blob (create-paste-from-blob false))

View File

@ -59,8 +59,8 @@
content (get shape :content)
new-content (path/apply-content-modifiers content content-modifiers)
old-points (path.segment/get-points content)
new-points (path.segment/get-points new-content)
old-points (path/get-points content)
new-points (path/get-points new-content)
point-change (->> (map hash-map old-points new-points) (reduce merge))]
(when (and (some? new-content) (some? shape))
@ -162,7 +162,7 @@
start-position (apply min-key #(gpt/distance start-position %) selected-points)
content (st/get-path state :content)
points (path.segment/get-points content)]
points (path/get-points content)]
(rx/concat
;; This stream checks the consecutive mouse positions to do the dragging
@ -255,7 +255,7 @@
start-delta-y (dm/get-in modifiers [index cy] 0)
content (st/get-path state :content)
points (path.segment/get-points content)
points (path/get-points content)
point (-> content (nth (if (= prefix :c1) (dec index) index)) (path.helpers/segment->point))
handler (-> content (nth index) (path.segment/get-handler prefix))

View File

@ -137,16 +137,16 @@
(ptk/reify ::pin-version
ptk/WatchEvent
(watch [_ state _]
(let [version (->> (dm/get-in state [:workspace-versions :data])
(d/seek #(= (:id %) id)))
params {:id id
:label (ct/format-inst (:created-at version) :localized-date)}]
(when-let [version (->> (dm/get-in state [:workspace-versions :data])
(d/seek #(= (:id %) id)))]
(let [params {:id id
:label (ct/format-inst (:created-at version) :localized-date)}]
(->> (rp/cmd! :update-file-snapshot params)
(rx/mapcat (fn [_]
(rx/of (update-versions-state {:editing id})
(fetch-versions)
(ptk/event ::ev/event {::ev/name "pin-version"})))))))))
(->> (rp/cmd! :update-file-snapshot params)
(rx/mapcat (fn [_]
(rx/of (update-versions-state {:editing id})
(fetch-versions)
(ptk/event ::ev/event {::ev/name "pin-version"}))))))))))
(defn lock-version
[id]

View File

@ -350,20 +350,32 @@
(st/async-emit! (rt/assign-exception error)))
(defonce uncaught-error-handler
(letfn [(is-ignorable-exception? [cause]
(letfn [(from-extension? [cause]
(let [stack (.-stack cause)]
(and (string? stack)
(or (str/includes? stack "chrome-extension://")
(str/includes? stack "moz-extension://")))))
(is-ignorable-exception? [cause]
(let [message (ex-message cause)]
(or (= message "Possible side-effect in debug-evaluate")
(or (from-extension? cause)
(= message "Possible side-effect in debug-evaluate")
(= message "Unexpected end of input")
(str/starts-with? message "invalid props on component")
(str/starts-with? message "Unexpected token "))))
(str/starts-with? message "Unexpected token ")
;; Abort errors are expected when an in-flight HTTP request is
;; cancelled (e.g. via RxJS unsubscription / take-until). They
;; are handled gracefully inside app.util.http/fetch and must
;; NOT be surfaced as application errors.
(= (.-name ^js cause) "AbortError"))))
(on-unhandled-error [event]
(.preventDefault ^js event)
(when-let [cause (unchecked-get event "error")]
(set! last-exception cause)
(when-not (is-ignorable-exception? cause)
(let [data (ex-data cause)
type (get data :type)]
(set! last-exception cause)
(if (#{:wasm-critical :wasm-non-blocking :wasm-exception} type)
(on-error cause)
(do
@ -373,9 +385,10 @@
(on-unhandled-rejection [event]
(.preventDefault ^js event)
(when-let [cause (unchecked-get event "reason")]
(set! last-exception cause)
(ex/print-throwable cause :prefix "Uncaught Rejection")
(ts/schedule #(flash :cause cause :type :unhandled))))]
(when-not (is-ignorable-exception? cause)
(set! last-exception cause)
(ex/print-throwable cause :prefix "Uncaught Rejection")
(ts/schedule #(flash :cause cause :type :unhandled)))))]
(.addEventListener g/window "error" on-unhandled-error)
(.addEventListener g/window "unhandledrejection" on-unhandled-rejection)

View File

@ -11,6 +11,11 @@
(mf/defc portal-on-document*
[{:keys [children]}]
(mf/portal
(mf/html [:* children])
(dom/get-body)))
(let [container (mf/use-memo #(dom/create-element "div"))]
(mf/with-effect []
(let [body (dom/get-body)]
(dom/append-child! body container)
#(dom/remove-child! body container)))
(mf/portal
(mf/html [:* children])
container)))

View File

@ -328,7 +328,11 @@
;; it right afterwards, in the next render cycle.
(dom/append-child! item-el counter-el)
(dnd/set-drag-image! event item-el (:x offset) (:y offset))
(ts/raf #(dom/remove-child! item-el counter-el))))))
;; Guard against race condition: if the user navigates away
;; before the RAF fires, item-el may have been unmounted and
;; counter-el is no longer a child — removeChild would throw.
(ts/raf #(when (dom/child? counter-el item-el)
(dom/remove-child! item-el counter-el)))))))
on-menu-click
(mf/use-fn

View File

@ -116,13 +116,13 @@
(fn [event]
(when (kbd/enter? event)
(st/emit!
(dcm/go-to-dashboard-files :project-id project-id)
(ts/schedule-on-idle
(fn []
(when-let [title (dom/get-element (str project-id))]
(dom/set-attribute! title "tabindex" "0")
(dom/focus! title)
(dom/set-attribute! title "tabindex" "-1"))))))))
(dcm/go-to-dashboard-files :project-id project-id))
(ts/schedule
(fn []
(when-let [title (dom/get-element (str project-id))]
(dom/set-attribute! title "tabindex" "0")
(dom/focus! title)
(dom/set-attribute! title "tabindex" "-1")))))))
on-menu-click
(mf/use-fn
@ -246,7 +246,7 @@
(mf/use-fn
(fn [e]
(when (kbd/enter? e)
(ts/schedule-on-idle
(ts/schedule
(fn []
(let [search-title (dom/get-element (str "dashboard-search-title"))]
(when search-title
@ -820,13 +820,13 @@
(mf/deps team-id)
(fn []
(st/emit!
(dcm/go-to-dashboard-recent :team-id team-id)
(ts/schedule-on-idle
(fn []
(when-let [projects-title (dom/get-element "dashboard-projects-title")]
(dom/set-attribute! projects-title "tabindex" "0")
(dom/focus! projects-title)
(dom/set-attribute! projects-title "tabindex" "-1")))))))
(dcm/go-to-dashboard-recent :team-id team-id))
(ts/schedule
(fn []
(when-let [projects-title (dom/get-element "dashboard-projects-title")]
(dom/set-attribute! projects-title "tabindex" "0")
(dom/focus! projects-title)
(dom/set-attribute! projects-title "tabindex" "-1"))))))
go-fonts
(mf/use-fn
@ -838,14 +838,14 @@
(mf/deps team)
(fn []
(st/emit!
(dcm/go-to-dashboard-fonts :team-id team-id)
(ts/schedule-on-idle
(fn []
(let [font-title (dom/get-element "dashboard-fonts-title")]
(when font-title
(dom/set-attribute! font-title "tabindex" "0")
(dom/focus! font-title)
(dom/set-attribute! font-title "tabindex" "-1"))))))))
(dcm/go-to-dashboard-fonts :team-id team-id))
(ts/schedule
(fn []
(let [font-title (dom/get-element "dashboard-fonts-title")]
(when font-title
(dom/set-attribute! font-title "tabindex" "0")
(dom/focus! font-title)
(dom/set-attribute! font-title "tabindex" "-1")))))))
go-drafts
(mf/use-fn
@ -858,7 +858,7 @@
(mf/deps team-id default-project-id)
(fn []
(st/emit! (dcm/go-to-dashboard-files :team-id team-id :project-id default-project-id))
(ts/schedule-on-idle
(ts/schedule
(fn []
(when-let [title (dom/get-element "dashboard-drafts-title")]
(dom/set-attribute! title "tabindex" "0")
@ -875,14 +875,14 @@
(mf/deps team-id)
(fn []
(st/emit!
(dcm/go-to-dashboard-libraries :team-id team-id)
(ts/schedule-on-idle
(fn []
(let [libs-title (dom/get-element "dashboard-libraries-title")]
(when libs-title
(dom/set-attribute! libs-title "tabindex" "0")
(dom/focus! libs-title)
(dom/set-attribute! libs-title "tabindex" "-1"))))))))
(dcm/go-to-dashboard-libraries :team-id team-id))
(ts/schedule
(fn []
(let [libs-title (dom/get-element "dashboard-libraries-title")]
(when libs-title
(dom/set-attribute! libs-title "tabindex" "0")
(dom/focus! libs-title)
(dom/set-attribute! libs-title "tabindex" "-1")))))))
pinned-projects
(mf/with-memo [projects]

View File

@ -22,7 +22,8 @@
[options id]
(let [options (if (delay? options) @options options)]
(or (d/seek #(= id (get % :id)) options)
(nth options 0))))
(when (seq options)
(nth options 0)))))
(defn- get-selected-option-id
[options default]
@ -178,7 +179,8 @@
selected-option
(mf/with-memo [options selected-id]
(get-option options selected-id))
(when (d/not-empty? options)
(get-option options selected-id)))
label
(get selected-option :label)

View File

@ -103,6 +103,7 @@
[:map
[:tabs [:vector {:min 1} schema:tab]]
[:class {:optional true} :string]
[:scrollable-panel {:optional true} :boolean]
[:on-change fn?]
[:selected :string]
[:action-button {:optional true} some?]
@ -111,15 +112,15 @@
(mf/defc tab-switcher*
{::mf/schema schema:tab-switcher}
[{:keys [tabs class on-change selected action-button-position action-button children] :rest props}]
[{:keys [tabs class on-change selected action-button-position action-button children scrollable-panel] :rest props}]
(let [nodes-ref (mf/use-ref nil)
scrollable-panel (d/nilv scrollable-panel false)
tabs
(if (array? tabs)
(mfu/bean tabs)
tabs)
on-click
(mf/use-fn
(mf/deps on-change)
@ -186,7 +187,8 @@
:on-key-down on-key-down
:on-click on-click}]]
[:section {:class (stl/css :tab-panel)
[:section {:class (stl/css-case :tab-panel true
:scrollable-panel scrollable-panel)
:tab-index 0
:role "tabpanel"
:aria-labelledby selected}

View File

@ -114,5 +114,8 @@
width: 100%;
height: 100%;
outline: $b-1 solid var(--tab-panel-outline-color);
}
.scrollable-panel {
overflow-y: auto;
}

View File

@ -140,7 +140,7 @@
(mf/use-fn
(mf/deps on-change handle-opacity-change)
(fn [value]
(if (or (string? value) (int? value))
(if (or (string? value) (number? value))
(handle-opacity-change value)
(do
(st/emit! (dwta/toggle-token {:token (first value)

View File

@ -117,7 +117,7 @@
(mf/use-fn
(mf/deps on-change ids)
(fn [value attr]
(if (or (string? value) (int? value))
(if (or (string? value) (number? value))
(on-change :simple attr value)
(do
(st/emit!
@ -247,7 +247,7 @@
(mf/use-fn
(mf/deps on-change ids)
(fn [value attr]
(if (or (string? value) (int? value))
(if (or (string? value) (number? value))
(on-change :multiple attr value)
(do
(st/emit!
@ -577,7 +577,7 @@
(mf/use-fn
(mf/deps ids)
(fn [value attr]
(if (or (string? value) (int? value))
(if (or (string? value) (number? value))
(st/emit! (dwsl/update-layout-child ids {attr value}))
(do
(st/emit!

View File

@ -25,60 +25,68 @@
[app.util.dom :as dom]
[rumext.v2 :as mf]))
(def guide-width 1)
(def guide-opacity 0.7)
(def guide-opacity-hover 1)
(def guide-color colors/new-danger)
(def guide-pill-width 34)
(def guide-pill-height 20)
(def guide-pill-corner-radius 4)
(def guide-active-area 16)
(def ^:const guide-width 1)
(def ^:const guide-opacity 0.7)
(def ^:const guide-opacity-hover 1)
(def ^:const guide-color colors/new-danger)
(def ^:const guide-pill-width 34)
(def ^:const guide-pill-height 20)
(def ^:const guide-pill-corner-radius 4)
(def ^:const guide-active-area 16)
(def guide-creation-margin-left 8)
(def guide-creation-margin-top 28)
(def guide-creation-width 16)
(def guide-creation-height 24)
(def ^:const guide-creation-margin-left 8)
(def ^:const guide-creation-margin-top 28)
(def ^:const guide-creation-width 16)
(def ^:const guide-creation-height 24)
(defn use-guide
"Hooks to support drag/drop for existing guides and new guides"
[on-guide-change get-hover-frame zoom {:keys [id position axis frame-id]}]
(let [dragging-ref (mf/use-ref false)
start-ref (mf/use-ref nil)
(let [dragging-ref (mf/use-ref false)
start-ref (mf/use-ref nil)
start-pos-ref (mf/use-ref nil)
state (mf/use-state {:hover false
state (mf/use-state
#(do {:hover false
:new-position nil
:new-frame-id frame-id})
:new-frame-id frame-id}))
frame-id (:new-frame-id @state)
frame-id
(:new-frame-id @state)
frame-ref (mf/use-memo (mf/deps frame-id) #(refs/object-by-id frame-id))
frame (mf/deref frame-ref)
frame-ref
(mf/with-memo [frame-id]
(refs/object-by-id frame-id))
snap-pixel? (mf/deref refs/snap-pixel?)
frame
(mf/deref frame-ref)
workspace-read-only? (mf/use-ctx ctx/workspace-read-only?)
snap-pixel?
(mf/deref refs/snap-pixel?)
read-only?
(mf/use-ctx ctx/workspace-read-only?)
on-pointer-enter
(mf/use-callback
(mf/deps workspace-read-only?)
(mf/use-fn
(mf/deps read-only?)
(fn []
(when-not workspace-read-only?
(when-not read-only?
(st/emit! (dw/set-hover-guide id true))
(swap! state assoc :hover true))))
on-pointer-leave
(mf/use-callback
(mf/deps workspace-read-only?)
(mf/use-fn
(mf/deps read-only?)
(fn []
(when-not workspace-read-only?
(when-not read-only?
(st/emit! (dw/set-hover-guide id false))
(swap! state assoc :hover false))))
on-pointer-down
(mf/use-callback
(mf/deps workspace-read-only?)
(mf/use-fn
(mf/deps read-only?)
(fn [event]
(when-not workspace-read-only?
(when-not read-only?
(when (= 0 (.-button event))
(dom/capture-pointer event)
(mf/set-ref-val! dragging-ref true)
@ -86,20 +94,20 @@
(mf/set-ref-val! start-pos-ref (get @ms/mouse-position axis))))))
on-pointer-up
(mf/use-callback
(mf/deps (select-keys @state [:new-position :new-frame-id]) on-guide-change workspace-read-only?)
(mf/use-fn
(mf/deps (select-keys @state [:new-position :new-frame-id]) on-guide-change read-only?)
(fn []
(when-not workspace-read-only?
(when-not read-only?
(when (some? on-guide-change)
(when (some? (:new-position @state))
(on-guide-change {:position (:new-position @state)
:frame-id (:new-frame-id @state)}))))))
on-lost-pointer-capture
(mf/use-callback
(mf/deps workspace-read-only?)
(mf/use-fn
(mf/deps read-only?)
(fn [event]
(when-not workspace-read-only?
(when-not read-only?
(dom/release-pointer event)
(mf/set-ref-val! dragging-ref false)
(mf/set-ref-val! start-ref nil)
@ -107,27 +115,29 @@
(swap! state assoc :new-position nil))))
on-pointer-move
(mf/use-callback
(mf/deps position zoom snap-pixel? workspace-read-only?)
(mf/use-fn
(mf/deps position zoom snap-pixel? read-only? get-hover-frame)
(fn [event]
(when-not workspace-read-only?
(when-let [_ (mf/ref-val dragging-ref)]
(let [start-pt (mf/ref-val start-ref)
start-pos (mf/ref-val start-pos-ref)
current-pt (dom/get-client-position event)
delta (/ (- (get current-pt axis) (get start-pt axis)) zoom)
(when-not read-only?
(when (mf/ref-val dragging-ref)
(let [start-pt (mf/ref-val start-ref)
start-pos (mf/ref-val start-pos-ref)
current-pt (dom/get-client-position event)
delta (/ (- (get current-pt axis) (get start-pt axis)) zoom)
new-position (if (some? position)
(+ position delta)
(+ start-pos delta))
new-position (if snap-pixel?
(mth/round new-position)
new-position)
new-frame-id (:id (get-hover-frame))]
new-frame-id (-> (get-hover-frame)
(get :id))]
(swap! state assoc
:new-position new-position
:new-frame-id new-frame-id))))))]
{:on-pointer-enter on-pointer-enter
:on-pointer-leave on-pointer-leave
:on-pointer-down on-pointer-down
@ -137,8 +147,8 @@
:state state
:frame frame}))
;; This functions are auxiliary to get the coords of components depending on the axis
;; we're handling
;; This functions are auxiliary to get the coords of components
;; depending on the axis we're handling
(defn guide-area-axis
[pos vbox zoom frame axis]
@ -270,11 +280,11 @@
(<= (:position guide) (+ (:y frame) (:height frame))))))
(mf/defc guide*
{::mf/wrap [mf/memo]
::mf/props :obj}
{::mf/wrap [mf/memo]}
[{:keys [guide is-hover on-guide-change get-hover-frame vbox zoom
hover-frame disabled-guides frame-modifier frame-transform]}]
(let [axis (:axis guide)
(let [axis
(get guide :axis)
handle-change-position
(mf/use-fn
@ -290,9 +300,11 @@
on-lost-pointer-capture
on-pointer-move
state
frame]} (use-guide handle-change-position get-hover-frame zoom guide)
frame]}
(use-guide handle-change-position get-hover-frame zoom guide)
base-frame (or frame hover-frame)
base-frame
(or frame hover-frame)
frame
(cond-> base-frame
@ -302,12 +314,18 @@
(some? frame-transform)
(gsh/apply-transform frame-transform))
move-vec (gpt/to-vec (gpt/point (:x base-frame) (:y base-frame))
(gpt/point (:x frame) (:y frame)))
move-vec
(gpt/to-vec (gpt/point (:x base-frame) (:y base-frame))
(gpt/point (:x frame) (:y frame)))
pos (+ (or (:new-position @state) (:position guide)) (get move-vec axis))
guide-width (/ guide-width zoom)
guide-pill-corner-radius (/ guide-pill-corner-radius zoom)
pos
(+ (or (:new-position @state) (:position guide)) (get move-vec axis))
guide-width
(/ guide-width zoom)
guide-pill-corner-radius
(/ guide-pill-corner-radius zoom)
frame-guide-outside?
(and (some? frame)
@ -404,9 +422,7 @@
(fmt/format-number (- pos (if (= axis :x) (:x frame) (:y frame))))]]))])))
(mf/defc new-guide-area*
{::mf/props :obj}
[{:keys [vbox zoom axis get-hover-frame disabled-guides]}]
(let [on-guide-change
(mf/use-fn
(mf/deps vbox)
@ -426,7 +442,9 @@
state
frame]}
(use-guide on-guide-change get-hover-frame zoom {:axis axis})
workspace-read-only? (mf/use-ctx ctx/workspace-read-only?)]
read-only?
(mf/use-ctx ctx/workspace-read-only?)]
[:g.new-guides
(when-not disabled-guides
@ -441,13 +459,15 @@
:on-pointer-up on-pointer-up
:on-lost-pointer-capture on-lost-pointer-capture
:on-pointer-move on-pointer-move
:class (when-not workspace-read-only?
(if (= axis :x) (cur/get-dynamic "resize-ew" 0) (cur/get-dynamic "resize-ns" 0)))
:class (when-not read-only?
(if (= axis :x)
(cur/get-dynamic "resize-ew" 0)
(cur/get-dynamic "resize-ns" 0)))
:style {:fill "none"
:pointer-events "fill"}}]))
(when (:new-position @state)
[:& guide* {:guide {:axis axis :position (:new-position @state)}
[:> guide* {:guide {:axis axis :position (:new-position @state)}
:get-hover-frame get-hover-frame
:vbox vbox
:zoom zoom
@ -455,17 +475,18 @@
:hover-frame frame}])]))
(mf/defc viewport-guides*
{::mf/wrap [mf/memo]
::mf/props :obj}
{::mf/wrap [mf/memo]}
[{:keys [zoom vbox hover-frame disabled-guides modifiers guides]}]
(let [guides
(mf/with-memo [guides vbox]
(->> (vals guides)
(filter (partial guide-inside-vbox? zoom vbox))))
focus (mf/deref refs/workspace-focus-selected)
focus
(mf/deref refs/workspace-focus-selected)
hover-frame-ref (mf/use-ref nil)
hover-frame-ref
(mf/use-ref nil)
;; We use the ref to not redraw every guide everytime the hovering frame change
;; we're only interested to get the frame in the guide we're moving

View File

@ -30,6 +30,15 @@ const exclusiveTypes = [
* @property {boolean} [allowHTMLPaste]
*/
const looksLikeJSON = (str) => {
if (typeof str !== 'string') return false;
const trimmed = str.trim();
return (
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'))
);
};
/**
*
* @param {string} text
@ -39,13 +48,14 @@ const exclusiveTypes = [
*/
function parseText(text, options) {
options = options || {};
const decodeTransit = options["decodeTransit"];
if (decodeTransit) {
if (decodeTransit && looksLikeJSON(text)) {
try {
decodeTransit(text);
return new Blob([text], { type: "application/transit+json" });
} catch (_error) {
// NOOP
return new Blob([text], { type: "text/plain" });
}
}
@ -135,7 +145,7 @@ function sortItems(a, b) {
export async function fromNavigator(options) {
options = options || {};
const items = await navigator.clipboard.read();
return Promise.all(
const result = await Promise.all(
Array.from(items).map(async (item) => {
const itemAllowedTypes = Array.from(item.types)
.filter(filterAllowedTypes(options))
@ -155,9 +165,15 @@ export async function fromNavigator(options) {
}
const type = itemAllowedTypes.at(0);
if (type == null) {
return null;
}
return item.getType(type);
}),
);
return result.filter((item) => !!item);
}
/**

View File

@ -104,10 +104,17 @@
(.next ^js subscriber response)
(.complete ^js subscriber)))
(p/catch
(fn [err]
(fn [cause]
(vreset! abortable? false)
(when-not @unsubscribed?
(.error ^js subscriber err))))
(let [error (ex-info (ex-message cause)
{:type :internal
:code :unable-to-fetch
:hint "unable to perform fetch operation"
:uri uri
:headers headers}
cause)]
(.error ^js subscriber error)))))
(p/finally
(fn []
(let [{:keys [count average] :or {count 0 average 0}} (get @network-averages (:path uri))
@ -116,10 +123,15 @@
(/ current-time (inc count)))
count (inc count)]
(swap! network-averages assoc (:path uri) {:count count :average average})))))
(fn []
(vreset! unsubscribed? true)
(when @abortable?
(.abort ^js controller)))))))
;; Provide an explicit reason so that the resulting AbortError carries
;; a meaningful message instead of the browser default
;; "signal is aborted without reason".
(.abort ^js controller (ex-info (str "fetch to '" uri "' is aborted")
{:uri uri}))))))))
(defn response->map
[response]

View File

@ -68,8 +68,21 @@ export function createApi(
},
sendMessage(message: unknown) {
let cloneableMessage: unknown;
try {
cloneableMessage = structuredClone(message);
} catch (err) {
console.error(
'plugin sendMessage: the message could not be cloned. ' +
'Ensure the message does not contain functions, DOM nodes, or other non-serializable values.',
err,
);
return;
}
const event = new CustomEvent('message', {
detail: message,
detail: cloneableMessage,
});
plugin.getModal()?.dispatchEvent(event);

View File

@ -129,7 +129,14 @@ export class PluginModalElement extends HTMLElement {
return;
}
iframe.contentWindow.postMessage((e as CustomEvent).detail, '*');
try {
iframe.contentWindow.postMessage((e as CustomEvent).detail, '*');
} catch (err) {
console.error(
'plugin modal: failed to send message to iframe via postMessage.',
err,
);
}
});
this.shadowRoot.appendChild(this.wrapper);