diff --git a/CHANGES.md b/CHANGES.md index 5f4a379a22..10962457e0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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 diff --git a/common/src/app/common/time.cljc b/common/src/app/common/time.cljc index ac68a7259f..3784cba8a5 100644 --- a/common/src/app/common/time.cljc +++ b/common/src/app/common/time.cljc @@ -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 diff --git a/common/src/app/common/types/container.cljc b/common/src/app/common/types/container.cljc index fa27ce5f07..324528854b 100644 --- a/common/src/app/common/types/container.cljc +++ b/common/src/app/common/types/container.cljc @@ -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." diff --git a/common/src/app/common/types/path/segment.cljc b/common/src/app/common/types/path/segment.cljc index fbe90b7c92..8ff37a8e81 100644 --- a/common/src/app/common/types/path/segment.cljc +++ b/common/src/app/common/types/path/segment.cljc @@ -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 diff --git a/common/src/app/common/types/path/shape_to_path.cljc b/common/src/app/common/types/path/shape_to_path.cljc index fc7a07f859..8641ee556e 100644 --- a/common/src/app/common/types/path/shape_to_path.cljc +++ b/common/src/app/common/types/path/shape_to_path.cljc @@ -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) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index 1fa9d63c14..58419240f3 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -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 diff --git a/common/test/common_tests/types/container_test.cljc b/common/test/common_tests/types/container_test.cljc new file mode 100644 index 0000000000..e4498db9b7 --- /dev/null +++ b/common/test/common_tests/types/container_test.cljc @@ -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)))))) diff --git a/common/test/common_tests/types/path_data_test.cljc b/common/test/common_tests/types/path_data_test.cljc index 7a37438252..5e6298191e 100644 --- a/common/test/common_tests/types/path_data_test.cljc +++ b/common/test/common_tests/types/path_data_test.cljc @@ -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" diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index 9c90643842..279666db33 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -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)) diff --git a/frontend/src/app/main/data/workspace/path/edition.cljs b/frontend/src/app/main/data/workspace/path/edition.cljs index a97947ea05..f5118f16ab 100644 --- a/frontend/src/app/main/data/workspace/path/edition.cljs +++ b/frontend/src/app/main/data/workspace/path/edition.cljs @@ -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)) diff --git a/frontend/src/app/main/data/workspace/versions.cljs b/frontend/src/app/main/data/workspace/versions.cljs index 9ecb38652c..11ed461366 100644 --- a/frontend/src/app/main/data/workspace/versions.cljs +++ b/frontend/src/app/main/data/workspace/versions.cljs @@ -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] diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 05f63d622e..3c769d0375 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -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) diff --git a/frontend/src/app/main/ui/components/portal.cljs b/frontend/src/app/main/ui/components/portal.cljs index 27ebaa205a..ff9f3558d4 100644 --- a/frontend/src/app/main/ui/components/portal.cljs +++ b/frontend/src/app/main/ui/components/portal.cljs @@ -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))) diff --git a/frontend/src/app/main/ui/dashboard/grid.cljs b/frontend/src/app/main/ui/dashboard/grid.cljs index ba3304305e..a24490af59 100644 --- a/frontend/src/app/main/ui/dashboard/grid.cljs +++ b/frontend/src/app/main/ui/dashboard/grid.cljs @@ -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 diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index c93e48e711..c0d9b46496 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -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] diff --git a/frontend/src/app/main/ui/ds/controls/select.cljs b/frontend/src/app/main/ui/ds/controls/select.cljs index 32676bcd36..d40d7275b8 100644 --- a/frontend/src/app/main/ui/ds/controls/select.cljs +++ b/frontend/src/app/main/ui/ds/controls/select.cljs @@ -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) diff --git a/frontend/src/app/main/ui/ds/layout/tab_switcher.cljs b/frontend/src/app/main/ui/ds/layout/tab_switcher.cljs index 9034d27e79..0d1e427c4f 100644 --- a/frontend/src/app/main/ui/ds/layout/tab_switcher.cljs +++ b/frontend/src/app/main/ui/ds/layout/tab_switcher.cljs @@ -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} diff --git a/frontend/src/app/main/ui/ds/layout/tab_switcher.scss b/frontend/src/app/main/ui/ds/layout/tab_switcher.scss index 56ecfe27f0..90af8cf778 100644 --- a/frontend/src/app/main/ui/ds/layout/tab_switcher.scss +++ b/frontend/src/app/main/ui/ds/layout/tab_switcher.scss @@ -114,5 +114,8 @@ width: 100%; height: 100%; outline: $b-1 solid var(--tab-panel-outline-color); +} + +.scrollable-panel { overflow-y: auto; } diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.cljs index f0b549dcac..3e5cfe9921 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.cljs @@ -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) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs index f5bc637567..8af6905212 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs @@ -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! diff --git a/frontend/src/app/main/ui/workspace/viewport/guides.cljs b/frontend/src/app/main/ui/workspace/viewport/guides.cljs index af21882f21..d542d983c9 100644 --- a/frontend/src/app/main/ui/workspace/viewport/guides.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/guides.cljs @@ -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 diff --git a/frontend/src/app/util/clipboard.js b/frontend/src/app/util/clipboard.js index 0322829e41..294652666a 100644 --- a/frontend/src/app/util/clipboard.js +++ b/frontend/src/app/util/clipboard.js @@ -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); } /** diff --git a/frontend/src/app/util/http.cljs b/frontend/src/app/util/http.cljs index 3a091e18e1..7ed68dbc7d 100644 --- a/frontend/src/app/util/http.cljs +++ b/frontend/src/app/util/http.cljs @@ -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] diff --git a/plugins/libs/plugins-runtime/src/lib/api/index.ts b/plugins/libs/plugins-runtime/src/lib/api/index.ts index c31298e1d8..2f8e9e58d9 100644 --- a/plugins/libs/plugins-runtime/src/lib/api/index.ts +++ b/plugins/libs/plugins-runtime/src/lib/api/index.ts @@ -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); 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 b9a9183c8b..3346175212 100644 --- a/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts +++ b/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts @@ -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);