diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index b57665d559..0000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "files.exclude": { - "**/.clj-kondo": true, - "**/.cpcache": true, - "**/.lsp": true, - "**/.shadow-cljs": true, - "**/node_modules": true - } -} diff --git a/common/src/app/common/buffer.cljc b/common/src/app/common/buffer.cljc index 07cf5e6853..7d23edeafe 100644 --- a/common/src/app/common/buffer.cljc +++ b/common/src/app/common/buffer.cljc @@ -217,10 +217,12 @@ (let [buffer (ByteBuffer/wrap dst)] (.order buffer ByteOrder/LITTLE_ENDIAN))) :cljs - (let [buffer' (.-buffer ^js/DataView buffer) - src-view (js/Uint32Array. buffer') - dst-buff (js/ArrayBuffer. (.-byteLength buffer')) - dst-view (js/Uint32Array. dst-buff)] + (let [src-off (.-byteOffset ^js/DataView buffer) + src-len (.-byteLength ^js/DataView buffer) + src-buf (.-buffer ^js/DataView buffer) + src-view (js/Uint8Array. src-buf src-off src-len) + dst-buff (js/ArrayBuffer. src-len) + dst-view (js/Uint8Array. dst-buff)] (.set dst-view src-view) (js/DataView. dst-buff)))) @@ -239,12 +241,15 @@ ^ByteBuffer buffer-b) :cljs - (let [buffer-a (.-buffer buffer-a) - buffer-b (.-buffer buffer-b)] - (if (= (.-byteLength buffer-a) - (.-byteLength buffer-b)) - (let [cb (js/Uint32Array. buffer-a) - ob (js/Uint32Array. buffer-b) + (let [len-a (.-byteLength ^js/DataView buffer-a) + len-b (.-byteLength ^js/DataView buffer-b)] + (if (= len-a len-b) + (let [cb (js/Uint8Array. (.-buffer ^js/DataView buffer-a) + (.-byteOffset ^js/DataView buffer-a) + len-a) + ob (js/Uint8Array. (.-buffer ^js/DataView buffer-b) + (.-byteOffset ^js/DataView buffer-b) + len-b) sz (alength cb)] (loop [i 0] (if (< i sz) diff --git a/common/src/app/common/i18n.cljc b/common/src/app/common/i18n.cljc index bdd80b9741..f363329f2d 100644 --- a/common/src/app/common/i18n.cljc +++ b/common/src/app/common/i18n.cljc @@ -13,3 +13,10 @@ unit tests or backend code for logs or error messages." [key & _args] key) + +(defn c + "This function will be monkeypatched at runtime with the real function in frontend i18n. + Here it just returns the key passed as argument. This way the result can be used in + unit tests or backend code for logs or error messages." + [x] + x) diff --git a/common/src/app/common/schema/messages.cljc b/common/src/app/common/schema/messages.cljc new file mode 100644 index 0000000000..93903c1b9c --- /dev/null +++ b/common/src/app/common/schema/messages.cljc @@ -0,0 +1,105 @@ +;; 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 app.common.schema.messages + (:require + [app.common.data :as d] + [app.common.i18n :as i18n :refer [tr]] + [app.common.schema :as sm] + [malli.core :as m])) + +;; --- Handlers Helpers + +(defn- translate-code + [code] + (if (vector? code) + (tr (nth code 0) (i18n/c (nth code 1))) + (tr code))) + +(defn- handle-error-fn + [props problem] + (let [v-fn (:error/fn props) + result (v-fn problem)] + (if (string? result) + {:message result} + {:message (or (some-> (get result :code) + (translate-code)) + (get result :message) + (tr "errors.invalid-data"))}))) + +(defn- handle-error-message + [props] + {:message (get props :error/message)}) + +(defn- handle-error-code + [props] + (let [code (get props :error/code)] + {:message (translate-code code)})) + +(defn interpret-schema-problem + [acc {:keys [schema in value type] :as problem}] + (let [props (m/properties schema) + tprops (m/type-properties schema) + field (or (:error/field props) + in) + field (if (vector? field) + field + [field])] + + (if (and (= 1 (count field)) + (contains? acc (first field))) + acc + (cond + (or (nil? field) + (empty? field)) + acc + + (or (= type :malli.core/missing-key) + (nil? value)) + (assoc-in acc field {:message (tr "errors.field-missing")}) + + ;; --- CHECK on schema props + (contains? props :error/fn) + (assoc-in acc field (handle-error-fn props problem)) + + (contains? props :error/message) + (assoc-in acc field (handle-error-message props)) + + (contains? props :error/code) + (assoc-in acc field (handle-error-code props)) + + ;; --- CHECK on type props + (contains? tprops :error/fn) + (assoc-in acc field (handle-error-fn tprops problem)) + + (contains? tprops :error/message) + (assoc-in acc field (handle-error-message tprops)) + + (contains? tprops :error/code) + (assoc-in acc field (handle-error-code tprops)) + + :else + (assoc-in acc field {:message (tr "errors.invalid-data")}))))) + + + +(defn- apply-validators + [validators state errors] + (reduce (fn [errors validator-fn] + (merge errors (validator-fn errors (:data state)))) + errors + validators)) + +(defn collect-schema-errors + [schema validators state] + (let [explain (sm/explain schema (:data state)) + errors (->> (reduce interpret-schema-problem {} (:errors explain)) + (apply-validators validators state))] + + (-> (:errors state) + (merge errors) + (d/without-nils) + (not-empty)))) diff --git a/common/src/app/common/types/path/impl.cljc b/common/src/app/common/types/path/impl.cljc index 959a11bf6f..2db3fcb2e9 100644 --- a/common/src/app/common/types/path/impl.cljc +++ b/common/src/app/common/types/path/impl.cljc @@ -128,11 +128,13 @@ x (buf/read-float buffer (+ offset 20)) y (buf/read-float buffer (+ offset 24)) type (case type - 1 :line-to - 2 :move-to + 1 :move-to + 2 :line-to 3 :curve-to - 4 :close-path) - res (f type c1x c1y c2x c2y x y)] + 4 :close-path + nil) + res (when (some? type) + (f type c1x c1y c2x c2y x y))] (recur (inc index) (if (some? res) (conj! result res) @@ -153,11 +155,14 @@ x (buf/read-float buffer (+ offset 20)) y (buf/read-float buffer (+ offset 24)) type (case type - 1 :line-to - 2 :move-to + 1 :move-to + 2 :line-to 3 :curve-to - 4 :close-path) - result (f result index type c1x c1y c2x c2y x y)] + 4 :close-path + nil) + result (if (some? type) + (f result index type c1x c1y c2x c2y x y) + result)] (if (reduced? result) result (recur (inc index) result))) @@ -174,12 +179,14 @@ x (buf/read-float buffer (+ offset 20)) y (buf/read-float buffer (+ offset 24)) type (case type - 1 :line-to - 2 :move-to + 1 :move-to + 2 :line-to 3 :curve-to - 4 :close-path)] - #?(:clj (f type c1x c1y c2x c2y x y) - :cljs (^function f type c1x c1y c2x c2y x y)))) + 4 :close-path + nil)] + (when (some? type) + #?(:clj (f type c1x c1y c2x c2y x y) + :cljs (^function f type c1x c1y c2x c2y x y))))) (defn- to-string-segment* [buffer offset type ^StringBuilder builder] @@ -219,7 +226,10 @@ (.append ",") (.append y))) 4 (doto builder - (.append "Z")))) + (.append "Z")) + + ;; Skip corrupted/unknown segment types + nil)) (defn- to-string "Format the path data structure to string" @@ -236,7 +246,8 @@ (.toString builder))) (defn- read-segment - "Read segment from binary buffer at specified index" + "Read segment from binary buffer at specified index. Returns nil for + corrupted/invalid segment types." [buffer index] (let [offset (* index SEGMENT-U8-SIZE) type (buf/read-short buffer offset)] @@ -268,7 +279,10 @@ :c2y (double c2y)}}) 4 {:command :close-path - :params {}}))) + :params {}} + + ;; Return nil for corrupted/unknown segment types + nil))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; TYPE: PATH-DATA @@ -320,8 +334,10 @@ (when (pos? size) ((fn next-seq [i] (when (< i size) - (cons (read-segment buffer i) - (lazy-seq (next-seq (inc i)))))) + (let [segment (read-segment buffer i)] + (if (some? segment) + (cons segment (lazy-seq (next-seq (inc i)))) + (next-seq (inc i)))))) 0))) clojure.lang.IReduceInit @@ -329,7 +345,10 @@ (loop [index 0 result start] (if (< index size) - (let [result (f result (read-segment buffer index))] + (let [segment (read-segment buffer index) + result (if (some? segment) + (f result segment) + result)] (if (reduced? result) @result (recur (inc index) result))) @@ -371,10 +390,10 @@ ;; NOTE: we still use u8 because until the heap refactor merge ;; we can't guarrantee the alignment of offset on 4 bytes (assert (instance? js/ArrayBuffer into-buffer)) - (let [buffer' (.-buffer ^js/DataView buffer) - size (.-byteLength buffer') + (let [size (.-byteLength ^js/DataView buffer) + src-off (.-byteOffset ^js/DataView buffer) mem (js/Uint8Array. into-buffer offset size)] - (.set mem (js/Uint8Array. buffer')))) + (.set mem (js/Uint8Array. (.-buffer ^js/DataView buffer) src-off size)))) ITransformable (-transform [this m] @@ -407,7 +426,10 @@ (read-segment buffer 0) nil)] (if (< index size) - (let [result (f result (read-segment buffer index))] + (let [segment (read-segment buffer index) + result (if (some? segment) + (f result segment) + result)] (if (reduced? result) @result (recur (inc index) result))) @@ -417,7 +439,10 @@ (loop [index 0 result start] (if (< index size) - (let [result (f result (read-segment buffer index))] + (let [segment (read-segment buffer index) + result (if (some? segment) + (f result segment) + result)] (if (reduced? result) @result (recur (inc index) result))) @@ -446,8 +471,10 @@ (when (pos? size) ((fn next-seq [i] (when (< i size) - (cons (read-segment buffer i) - (lazy-seq (next-seq (inc i)))))) + (let [segment (read-segment buffer i)] + (if (some? segment) + (cons segment (lazy-seq (next-seq (inc i)))) + (next-seq (inc i)))))) 0))) cljs.core/IPrintWithWriter @@ -608,19 +635,27 @@ nil)) (instance? js/DataView buffer) - (let [buffer' (.-buffer ^js/DataView buffer) - size (.-byteLength ^js/ArrayBuffer buffer') - count (long (/ size SEGMENT-U8-SIZE))] + (let [size (.-byteLength ^js/DataView buffer) + count (long (/ size SEGMENT-U8-SIZE))] (PathData. count buffer (weak/weak-value-map) nil)) (instance? js/Uint8Array buffer) - (from-bytes (.-buffer buffer)) + (let [ab (.-buffer buffer) + offset (.-byteOffset buffer) + size (.-byteLength buffer)] + (from-bytes (js/DataView. ab offset size))) (instance? js/Uint32Array buffer) - (from-bytes (.-buffer buffer)) + (let [ab (.-buffer buffer) + offset (.-byteOffset buffer) + size (.-byteLength buffer)] + (from-bytes (js/DataView. ab offset size))) (instance? js/Int8Array buffer) - (from-bytes (.-buffer buffer)) + (let [ab (.-buffer buffer) + offset (.-byteOffset buffer) + size (.-byteLength buffer)] + (from-bytes (js/DataView. ab offset size))) :else (throw (js/Error. "invalid data provided"))))) @@ -706,7 +741,9 @@ :class PathData :wfn (fn [^PathData pdata] (let [buffer (.-buffer pdata)] - #?(:cljs (js/Uint8Array. (.-buffer ^js/DataView buffer)) + #?(:cljs (js/Uint8Array. (.-buffer ^js/DataView buffer) + (.-byteOffset ^js/DataView buffer) + (.-byteLength ^js/DataView buffer)) :clj (.array ^ByteBuffer buffer)))) :rfn from-bytes}) diff --git a/common/test/common_tests/types/path_data_test.cljc b/common/test/common_tests/types/path_data_test.cljc index add7d873ad..252334b459 100644 --- a/common/test/common_tests/types/path_data_test.cljc +++ b/common/test/common_tests/types/path_data_test.cljc @@ -1272,3 +1272,149 @@ (let [segs (vec (:content result)) curve-segs (filter #(= :curve-to (:command %)) segs)] (t/is (pos? (count curve-segs)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; TYPE CODE CONSISTENCY TESTS (regression for move-to/line-to swap bug) +;; +;; These tests ensure that all binary reader code paths agree on the +;; mapping: 1=move-to, 2=line-to, 3=curve-to, 4=close-path. +;; +;; The bug was that `impl-walk`, `impl-reduce`, and `impl-lookup` had +;; type codes 1 and 2 swapped (1→:line-to, 2→:move-to) while +;; `read-segment`, `from-plain`, and `to-string-segment*` had the +;; correct mapping. This caused subtle mismatches in operations like +;; `get-subpaths`, `get-points`, `get-handlers`, etc. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest type-code-walk-consistency + (t/testing "impl-walk produces same command types as read-segment (via vec)" + (let [pdata (path/content sample-content) + ;; read-segment path: produces {:command :keyword ...} maps + seq-types (mapv :command (vec pdata)) + ;; impl-walk path: collects type keywords + walk-types (path.impl/-walk pdata + (fn [type _ _ _ _ _ _] type) + [])] + ;; Both paths must agree on the command types + (t/is (= seq-types walk-types)) + ;; Verify the actual expected types + (t/is (= [:move-to :line-to :curve-to :close-path] seq-types)) + (t/is (= [:move-to :line-to :curve-to :close-path] walk-types))))) + +(t/deftest type-code-reduce-consistency + (t/testing "impl-reduce produces same command types as read-segment (via vec)" + (let [pdata (path/content sample-content) + ;; read-segment path + seq-types (mapv :command (vec pdata)) + ;; impl-reduce path: collects [index type] pairs + reduce-types (path.impl/-reduce + pdata + (fn [acc index type _ _ _ _ _ _] + (conj acc type)) + [])] + (t/is (= seq-types reduce-types)) + (t/is (= [:move-to :line-to :curve-to :close-path] reduce-types))))) + +(t/deftest type-code-lookup-consistency + (t/testing "impl-lookup produces same command types as read-segment for each index" + (let [pdata (path/content sample-content) + seg-count (count pdata)] + (doseq [i (range seg-count)] + (let [;; read-segment path + seg-type (:command (nth pdata i)) + ;; impl-lookup path + lookup-type (path.impl/-lookup + pdata i + (fn [type _ _ _ _ _ _] type))] + (t/is (= seg-type lookup-type) + (str "Mismatch at index " i + ": read-segment=" seg-type + " lookup=" lookup-type))))))) + +(t/deftest type-code-get-points-uses-walk + (t/testing "get-points (via impl-walk) excludes close-path and includes move-to/line-to/curve-to" + (let [pdata (path/content sample-content) + points (path.segment/get-points pdata) + ;; Manually extract points from read-segment (via vec), + ;; skipping close-path + expected-points (->> (vec pdata) + (remove #(= :close-path (:command %))) + (mapv #(gpt/point + (get-in % [:params :x]) + (get-in % [:params :y]))))] + (t/is (= expected-points points)) + ;; Specifically: 3 points (move-to, line-to, curve-to) + (t/is (= 3 (count points)))))) + +(t/deftest type-code-get-subpaths-uses-reduce + (t/testing "get-subpaths (via reduce) correctly identifies move-to to start subpaths" + (let [;; Content with two subpaths: move-to + line-to + close-path, then move-to + line-to + two-subpath-content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :close-path :params {}} + {:command :move-to :params {:x 20.0 :y 20.0}} + {:command :line-to :params {:x 30.0 :y 30.0}}] + pdata (path/content two-subpath-content) + subpaths (path.subpath/get-subpaths pdata)] + ;; Must produce exactly 2 subpaths (one per move-to) + (t/is (= 2 (count subpaths))) + ;; First subpath starts at (0,0) + (t/is (= (gpt/point 0.0 0.0) (:from (first subpaths)))) + ;; Second subpath starts at (20,20) + (t/is (= (gpt/point 20.0 20.0) (:from (second subpaths))))))) + +(t/deftest type-code-get-handlers-uses-reduce + (t/testing "get-handlers (via impl-reduce) correctly identifies curve-to segments" + (let [pdata (path/content sample-content) + handlers (path.segment/get-handlers pdata)] + ;; sample-content has one curve-to at index 2 + ;; The curve-to's :c1 handler belongs to the previous point (line-to endpoint) + ;; The curve-to's :c2 handler belongs to the curve-to endpoint + (t/is (some? handlers)) + (let [line-to-point (gpt/point 439.0 802.0) + curve-to-point (gpt/point 264.0 634.0)] + ;; line-to endpoint should have [2 :c1] handler + (t/is (some #(= [2 :c1] %) (get handlers line-to-point))) + ;; curve-to endpoint should have [2 :c2] handler + (t/is (some #(= [2 :c2] %) (get handlers curve-to-point))))))) + +(t/deftest type-code-handler-point-uses-lookup + (t/testing "get-handler-point (via impl-lookup) returns correct values" + (let [pdata (path/content sample-content)] + ;; Index 0 is move-to (480, 839) — not a curve, so any prefix + ;; returns the segment point itself + (let [pt (path.segment/get-handler-point pdata 0 :c1)] + (t/is (= (gpt/point 480.0 839.0) pt))) + ;; Index 2 is curve-to with c1=(368,737), c2=(310,681), point=(264,634) + (let [c1-pt (path.segment/get-handler-point pdata 2 :c1) + c2-pt (path.segment/get-handler-point pdata 2 :c2)] + (t/is (= (gpt/point 368.0 737.0) c1-pt)) + (t/is (= (gpt/point 310.0 681.0) c2-pt)))))) + +(t/deftest type-code-all-readers-agree-large-content + (t/testing "all binary readers agree on types for a large multi-segment path" + (let [pdata (path/content sample-content-large) + seg-count (count pdata) + ;; Collect types from all four code paths + seq-types (mapv :command (vec pdata)) + walk-types (path.impl/-walk pdata + (fn [type _ _ _ _ _ _] type) + []) + reduce-types (path.impl/-reduce + pdata + (fn [acc _ type _ _ _ _ _ _] + (conj acc type)) + []) + lookup-types (mapv (fn [i] + (path.impl/-lookup + pdata i + (fn [type _ _ _ _ _ _] type))) + (range seg-count))] + ;; All four must be identical + (t/is (= seq-types walk-types)) + (t/is (= seq-types reduce-types)) + (t/is (= seq-types lookup-types)) + ;; Verify first and last entries specifically + (t/is (= :move-to (first seq-types))) + (t/is (= :close-path (last seq-types)))))) diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index b376df9245..2e7446c425 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -41,6 +41,7 @@ [app.main.ui.workspace.tokens.settings] [app.main.ui.workspace.tokens.themes.create-modal] [app.main.ui.workspace.viewport :refer [viewport*]] + [app.main.ui.workspace.webgl-unavailable-modal] [app.util.debug :as dbg] [app.util.dom :as dom] [app.util.globals :as globals] diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index a7cd731d5a..ab06a89709 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -16,7 +16,7 @@ [app.common.types.path :as path] [app.common.types.shape :as cts] [app.common.types.shape.layout :as ctl] - [app.main.data.common :as dcm] + [app.main.data.modal :as modal] [app.main.data.workspace.transforms :as dwt] [app.main.data.workspace.variants :as dwv] [app.main.features :as features] @@ -319,25 +319,50 @@ ;; harder to follow through. (mf/with-effect [page-id] (when-let [canvas (mf/ref-val canvas-ref)] - (->> wasm.api/module - (p/fmap (fn [ready?] - (when ready? - (let [init? (try - (wasm.api/init-canvas-context canvas) - (catch :default e - (js/console.error "Error initializing canvas context:" e) - false))] - (reset! canvas-init? init?) - (when init? - ;; Restore previous canvas pixels immediately after context initialization - ;; This happens before initialize-viewport is called - (wasm.api/apply-canvas-blur) - (wasm.api/restore-previous-canvas-pixels)) - (when-not init? - (js/alert "WebGL not supported") - (st/emit! (dcm/go-to-dashboard-recent)))))))) - (fn [] - (wasm.api/clear-canvas)))) + (let [timeout-id-ref (volatile! nil) + unmounted? (volatile! false) + modal-shown? (volatile! false) + + show-unavailable + (fn [] + (when-not (or @unmounted? @modal-shown?) + (vreset! modal-shown? true) + (reset! canvas-init? false) + (st/emit! (modal/show {:type :webgl-unavailable})))) + + try-init + (fn try-init [retries] + (when-not @unmounted? + (let [init? (try + (wasm.api/init-canvas-context canvas) + (catch :default e + (js/console.error "Error initializing canvas context:" e) + false))] + (cond + init? + (do + (reset! canvas-init? true) + ;; Restore previous canvas pixels immediately after context initialization + ;; This happens before initialize-viewport is called + (wasm.api/apply-canvas-blur) + (wasm.api/restore-previous-canvas-pixels)) + + (pos? retries) + (vreset! timeout-id-ref + (js/setTimeout #(try-init (dec retries)) 200)) + + :else + (show-unavailable)))))] + (reset! canvas-init? false) + (->> wasm.api/module + (p/fmap (fn [ready?] + (when ready? + (try-init 3))))) + (fn [] + (vreset! unmounted? true) + (when-let [timeout-id @timeout-id-ref] + (js/clearTimeout timeout-id)) + (wasm.api/clear-canvas))))) (mf/with-effect [show-text-editor? workspace-editor-state edition] (let [active-editor-state (get workspace-editor-state edition)] diff --git a/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.cljs b/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.cljs new file mode 100644 index 0000000000..f4092a4762 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.cljs @@ -0,0 +1,78 @@ +;; 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 app.main.ui.workspace.webgl-unavailable-modal + (:require-macros [app.main.style :as stl]) + (:require + [app.main.data.common :as dcm] + [app.main.data.modal :as modal] + [app.main.store :as st] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.foundations.typography :as t] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.ds.foundations.typography.text :refer [text*]] + [app.util.dom :as dom] + [app.util.i18n :refer [tr]] + [app.util.keyboard :as k] + [goog.events :as events] + [rumext.v2 :as mf]) + (:import goog.events.EventType)) + +(defn- close-and-go-dashboard + [] + (st/emit! (modal/hide) + (dcm/go-to-dashboard-recent))) + +(def ^:const webgl-troubleshooting-url "https://help.penpot.app/user-guide/first-steps/troubleshooting-webgl/") + +#_{:clojure-lsp/ignore [:clojure-lsp/unused-public-var]} +(mf/defc webgl-unavailable-modal* + {::mf/register modal/components + ::mf/register-as :webgl-unavailable} + [_] + + (let [handle-keydown (fn [event] + (when (k/esc? event) + (dom/stop-propagation event) + (close-and-go-dashboard)))] + (mf/with-effect [] + (let [key (events/listen js/document EventType.KEYDOWN handle-keydown)] + (fn [] + (events/unlistenByKey key)))) + + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-dialog)} + [:header {:class (stl/css :modal-header)} + [:> icon-button* {:on-click close-and-go-dashboard + :class (stl/css :modal-close-btn) + :icon i/close + :variant "action" + :size "medium" + :aria-label (tr "labels.close")}] + [:> heading* {:level 2 :typography t/title-medium} + (tr "webgl.modals.webgl-unavailable.title")]] + + [:section {:class (stl/css :modal-content)} + [:> text* {:as "p" :typography t/body-large} + (tr "webgl.modals.webgl-unavailable.message")] + [:hr {:class (stl/css :modal-divider)}] + [:> text* {:as "p" :typography t/body-medium} + (tr "webgl.modals.webgl-unavailable.troubleshooting.before") + [:a {:href webgl-troubleshooting-url + :target "_blank" + :rel "noopener noreferrer" + :class (stl/css :link)} + (tr "webgl.modals.webgl-unavailable.troubleshooting.link")] + (tr "webgl.modals.webgl-unavailable.troubleshooting.after")]] + + [:footer {:class (stl/css :modal-footer)} + [:> button* {:on-click close-and-go-dashboard + :variant "secondary"} + (tr "webgl.modals.webgl-unavailable.cta-dashboard")] + [:> button* {:to webgl-troubleshooting-url :target "_blank" :variant "primary"} + (tr "webgl.modals.webgl-unavailable.cta-troubleshooting")]]]])) diff --git a/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.scss b/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.scss new file mode 100644 index 0000000000..0ad86b0c29 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.scss @@ -0,0 +1,61 @@ +// 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 + +@use "ds/_utils.scss" as *; +@use "ds/_borders.scss" as *; + +@use "refactor/common-refactor.scss" as deprecated; + +.modal-overlay { + @extend .modal-overlay-base; +} + +.modal-dialog { + @extend .modal-container-base; + + color: var(--color-foreground-secondary); + display: grid; + gap: var(--sp-s); + padding: px2rem(72); // FIXME: This should be a token + max-width: px2rem(682); // FIXME: This should be a token +} + +.modal-header { + color: var(--color-foreground-primary); +} + +.modal-close-btn { + position: absolute; + top: px2rem(38); // FIXME: This should be a token + right: px2rem(38); // FIXME: This should be a token +} + +.modal-content { + display: grid; + grid-template-rows: auto; + gap: var(--sp-s); + + & > * { + margin: 0; // FIXME: This should be in reset styles + } +} + +.modal-divider { + border: $b-1 solid var(--color-background-quaternary); + margin: var(--sp-xs) 0; +} + +.modal-footer { + margin-block-start: var(--sp-l); + justify-self: end; + display: flex; + flex-direction: row; + gap: var(--sp-s); +} + +.link { + color: var(--color-accent-primary); +} diff --git a/frontend/src/app/plugins/utils.cljs b/frontend/src/app/plugins/utils.cljs index cbfc512323..19de73fcde 100644 --- a/frontend/src/app/plugins/utils.cljs +++ b/frontend/src/app/plugins/utils.cljs @@ -9,14 +9,17 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.i18n :as i18n :refer [tr]] [app.common.schema :as sm] + [app.common.schema.messages :as csm] [app.common.types.component :as ctk] [app.common.types.container :as ctn] [app.common.types.file :as ctf] [app.common.types.tokens-lib :as ctob] [app.main.data.helpers :as dsh] [app.main.store :as st] - [app.util.object :as obj])) + [app.util.object :as obj] + [cuerdas.core :as str])) (defn locate-file [id] @@ -262,6 +265,15 @@ (let [s (set values)] (if (= (count s) 1) (first s) "mixed"))) +(defn error-messages + [explain] + (->> (:errors explain) + (reduce csm/interpret-schema-problem {}) + (mapcat (comp seq val)) + (map (fn [[field {:keys [message]}]] + (tr "plugins.validation.message" (name field) message))) + (str/join ". "))) + (defn handle-error "Function to be used in plugin proxies methods to handle errors and print a readable message to the console." @@ -269,7 +281,9 @@ (fn [cause] (let [message (if-let [explain (-> cause ex-data ::sm/explain)] - (sm/humanize-explain explain) + (do + (js/console.error (sm/humanize-explain explain)) + (error-messages explain)) (ex-data cause))] (js/console.log (.-stack cause)) (not-valid plugin-id :error message)))) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 27dc63e5c6..0d2214acde 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -1541,35 +1541,43 @@ (defn shape-to-path [id] (use-shape id) - (let [offset (-> (h/call wasm/internal-module "_current_to_path") - (mem/->offset-32)) - heap (mem/get-heap-u32) - length (aget heap offset) - data (mem/slice heap - (+ offset 1) - (* length path.impl/SEGMENT-U32-SIZE)) - content (path/from-bytes data)] - (mem/free) - content)) + (try + (let [offset (-> (h/call wasm/internal-module "_current_to_path") + (mem/->offset-32)) + heap (mem/get-heap-u32) + length (aget heap offset) + data (mem/slice heap + (+ offset 1) + (* length path.impl/SEGMENT-U32-SIZE)) + content (path/from-bytes data)] + (mem/free) + content) + (catch :default cause + (mem/free) + (throw cause)))) (defn stroke-to-path "Converts a shape's stroke at the given index into a filled path. Returns the stroke outline as PathData content." [id stroke-index] (use-shape id) - (let [offset (-> (h/call wasm/internal-module "_convert_stroke_to_path" stroke-index) - (mem/->offset-32)) - heap (mem/get-heap-u32) - length (aget heap offset)] - (if (pos? length) - (let [data (mem/slice heap - (+ offset 1) - (* length path.impl/SEGMENT-U32-SIZE)) - content (path/from-bytes data)] - (mem/free) - content) - (do (mem/free) - nil)))) + (try + (let [offset (-> (h/call wasm/internal-module "_convert_stroke_to_path" stroke-index) + (mem/->offset-32)) + heap (mem/get-heap-u32) + length (aget heap offset)] + (if (pos? length) + (let [data (mem/slice heap + (+ offset 1) + (* length path.impl/SEGMENT-U32-SIZE)) + content (path/from-bytes data)] + (mem/free) + content) + (do (mem/free) + nil))) + (catch :default cause + (mem/free) + (throw cause)))) (defn calculate-bool* [bool-type ids] @@ -1582,17 +1590,21 @@ offset (rseq ids)) - (let [offset - (-> (h/call wasm/internal-module "_calculate_bool" (sr/translate-bool-type bool-type)) - (mem/->offset-32)) + (try + (let [offset + (-> (h/call wasm/internal-module "_calculate_bool" (sr/translate-bool-type bool-type)) + (mem/->offset-32)) - length (aget heap offset) - data (mem/slice heap - (+ offset 1) - (* length path.impl/SEGMENT-U32-SIZE)) - content (path/from-bytes data)] - (mem/free) - content))) + length (aget heap offset) + data (mem/slice heap + (+ offset 1) + (* length path.impl/SEGMENT-U32-SIZE)) + content (path/from-bytes data)] + (mem/free) + content) + (catch :default cause + (mem/free) + (throw cause))))) (defn calculate-bool [shape objects] diff --git a/frontend/src/app/render_wasm/shape.cljs b/frontend/src/app/render_wasm/shape.cljs index 7809d5ca0f..032f3d7926 100644 --- a/frontend/src/app/render_wasm/shape.cljs +++ b/frontend/src/app/render_wasm/shape.cljs @@ -332,9 +332,14 @@ (defn process-shape-changes! [objects shape-changes] - (->> (rx/from shape-changes) - (rx/mapcat (fn [[shape-id props]] (process-shape! (get objects shape-id) props))) - (rx/subs! #(api/request-render "set-wasm-attrs")))) + (let [shape-changes + (->> shape-changes + ;; We don't need to update the model for shapes not in the current page + (filter (fn [[shape-id _]] (shape-in-current-page? shape-id))))] + (when (d/not-empty? shape-changes) + (->> (rx/from shape-changes) + (rx/mapcat (fn [[shape-id props]] (process-shape! (get objects shape-id) props))) + (rx/subs! #(api/request-render "set-wasm-attrs")))))) ;; `conj` empty set initialization (def conj* (fnil conj (d/ordered-set))) diff --git a/frontend/src/app/util/forms.cljs b/frontend/src/app/util/forms.cljs index 253d32470c..f0e7e5466c 100644 --- a/frontend/src/app/util/forms.cljs +++ b/frontend/src/app/util/forms.cljs @@ -10,84 +10,10 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.schema :as sm] - [app.util.i18n :as i18n :refer [tr]] + [app.common.schema.messages :as csm] [cuerdas.core :as str] - [malli.core :as m] [rumext.v2 :as mf])) -;; --- Handlers Helpers - -(defn- translate-code - [code] - (if (vector? code) - (tr (nth code 0) (i18n/c (nth code 1))) - (tr code))) - -(defn- handle-error-fn - [props problem] - (let [v-fn (:error/fn props) - result (v-fn problem)] - (if (string? result) - {:message result} - {:message (or (some-> (get result :code) - (translate-code)) - (get result :message) - (tr "errors.invalid-data"))}))) - -(defn- handle-error-message - [props] - {:message (get props :error/message)}) - -(defn- handle-error-code - [props] - (let [code (get props :error/code)] - {:message (translate-code code)})) - -(defn- interpret-schema-problem - [acc {:keys [schema in value type] :as problem}] - (let [props (m/properties schema) - tprops (m/type-properties schema) - field (or (:error/field props) - in) - field (if (vector? field) - field - [field])] - - (if (and (= 1 (count field)) - (contains? acc (first field))) - acc - (cond - (or (nil? field) - (empty? field)) - acc - - (or (= type :malli.core/missing-key) - (nil? value)) - (assoc-in acc field {:message (tr "errors.field-missing")}) - - ;; --- CHECK on schema props - (contains? props :error/fn) - (assoc-in acc field (handle-error-fn props problem)) - - (contains? props :error/message) - (assoc-in acc field (handle-error-message props)) - - (contains? props :error/code) - (assoc-in acc field (handle-error-code props)) - - ;; --- CHECK on type props - (contains? tprops :error/fn) - (assoc-in acc field (handle-error-fn tprops problem)) - - (contains? tprops :error/message) - (assoc-in acc field (handle-error-message tprops)) - - (contains? tprops :error/code) - (assoc-in acc field (handle-error-code tprops)) - - :else - (assoc-in acc field {:message (tr "errors.invalid-data")}))))) - (defn- use-rerender-fn [] (let [state (mf/useState 0) @@ -97,24 +23,6 @@ (fn [] (render-fn inc))))) -(defn- apply-validators - [validators state errors] - (reduce (fn [errors validator-fn] - (merge errors (validator-fn errors (:data state)))) - errors - validators)) - -(defn- collect-schema-errors - [schema validators state] - (let [explain (sm/explain schema (:data state)) - errors (->> (reduce interpret-schema-problem {} (:errors explain)) - (apply-validators validators state))] - - (-> (:errors state) - (merge errors) - (d/without-nils) - (not-empty)))) - (defn- wrap-update-schema-fn [f {:keys [schema validators]}] (fn [& args] @@ -124,7 +32,7 @@ errors (when-not valid? - (collect-schema-errors schema validators state)) + (csm/collect-schema-errors schema validators state)) extra-errors (not-empty (:extra-errors state))] @@ -136,7 +44,6 @@ (not extra-errors) valid?))))) - (defn- make-initial-state [initial-data] (let [initial (if (fn? initial-data) (initial-data) initial-data) diff --git a/frontend/src/app/util/i18n.cljs b/frontend/src/app/util/i18n.cljs index 9bc5415de8..db8c2dccc3 100644 --- a/frontend/src/app/util/i18n.cljs +++ b/frontend/src/app/util/i18n.cljs @@ -219,3 +219,4 @@ ;; We set the real translation function in the common i18n namespace, ;; so that when common code calls (tr ...) it uses this function. (set! app.common.i18n/tr tr) +(set! app.common.i18n/c c) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index b73799b790..9b98da0cd0 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -8997,9 +8997,33 @@ msgstr "" msgid "workspace.versions.warning.text" msgstr "Autosaved versions will be kept for %s days." +msgid "webgl.modals.webgl-unavailable.title" +msgstr "Oops! WebGL is not available" + +msgid "webgl.modals.webgl-unavailable.message" +msgstr "WebGL is not available in your browser, which is required for Penpot to work. Please check your browser settings and/or close graphics-heavy tabs." + +msgid "webgl.modals.webgl-unavailable.troubleshooting.before" +msgstr "Follow our " + +msgid "webgl.modals.webgl-unavailable.troubleshooting.link" +msgstr "WebGL troubleshooting guide" + +msgid "webgl.modals.webgl-unavailable.troubleshooting.after" +msgstr " to check browser settings, GPU acceleration, drivers, and common system issues." + +msgid "webgl.modals.webgl-unavailable.cta-dashboard" +msgstr "Go to dashboard" + +msgid "webgl.modals.webgl-unavailable.cta-troubleshooting" +msgstr "Troubleshooting guide" + #, unused msgid "workspace.viewport.click-to-close-path" msgstr "Click to close the path" msgid "modals.delete-org-team-confirm.message" -msgstr "Are you sure you want to delete this team that is part of %s org?" \ No newline at end of file +msgstr "Are you sure you want to delete this team that is part of %s org?" + +msgid "plugins.validation.message" +msgstr "Field %s is invalid: %s" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index e56c446312..3381ae09bf 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -8834,6 +8834,27 @@ msgstr "Si quieres aumentar este límite, contáctanos en [support@penpot.app](% msgid "workspace.versions.warning.text" msgstr "Los autoguardados duran %s días." +msgid "webgl.modals.webgl-unavailable.title" +msgstr "Vaya, WebGL no está disponible" + +msgid "webgl.modals.webgl-unavailable.message" +msgstr "WebGL no está disponible en tu navegador, y es necesario para que Penpot funcione. Revisa la configuración de tu navegador y/o cierra pestañas con uso intensivo de gráficos." + +msgid "webgl.modals.webgl-unavailable.troubleshooting.before" +msgstr "Consulta nuestra " + +msgid "webgl.modals.webgl-unavailable.troubleshooting.link" +msgstr "guía de solución de problemas de WebGL" + +msgid "webgl.modals.webgl-unavailable.troubleshooting.after" +msgstr " para revisar la configuración del navegador, la aceleración por GPU, los drivers y problemas comunes del sistema." + +msgid "webgl.modals.webgl-unavailable.cta-dashboard" +msgstr "Ir al panel" + +msgid "webgl.modals.webgl-unavailable.cta-troubleshooting" +msgstr "Guía de solución de problemas" + #, unused msgid "workspace.viewport.click-to-close-path" msgstr "Pulsar para cerrar la ruta" diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 17e29f08d2..c66bcdf2b3 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -3004,3 +3004,9 @@ impl RenderState { self.viewbox.set_all(zoom, x, y); } } + +impl Drop for RenderState { + fn drop(&mut self) { + self.gpu_state.context.free_gpu_resources(); + } +} diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 2bcf2b23f8..40bdb6d2ec 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -18,6 +18,7 @@ use skia_safe::{ Contains, }; +use std::cell::Cell; use std::collections::HashSet; use super::FontFamily; @@ -177,10 +178,25 @@ pub struct TextContentLayoutResult( TextContentSize, ); +/// Cached extrect stored as offsets from the selrect origin, +/// keyed by the selrect dimensions (width, height) and vertical alignment +/// used to compute it. +#[derive(Debug, Clone, Copy)] +struct CachedExtrect { + selrect_width: f32, + selrect_height: f32, + valign: u8, + left: f32, + top: f32, + right: f32, + bottom: f32, +} + #[derive(Debug)] pub struct TextContentLayout { pub paragraph_builders: Vec, pub paragraphs: Vec>, + cached_extrect: Cell>, } impl Clone for TextContentLayout { @@ -188,6 +204,7 @@ impl Clone for TextContentLayout { Self { paragraph_builders: vec![], paragraphs: vec![], + cached_extrect: Cell::new(None), } } } @@ -203,6 +220,7 @@ impl TextContentLayout { Self { paragraph_builders: vec![], paragraphs: vec![], + cached_extrect: Cell::new(None), } } @@ -213,6 +231,7 @@ impl TextContentLayout { ) { self.paragraph_builders = paragraph_builders; self.paragraphs = paragraphs; + self.cached_extrect.set(None); } pub fn needs_update(&self) -> bool { @@ -231,11 +250,14 @@ pub struct TextDecorationSegment { pub width: f32, } -/* - * Check if the current x,y (in paragraph relative coordinates) is inside - * the paragraph - */ -#[allow(dead_code)] +fn vertical_align_offset(container_h: f32, content_h: f32, valign: VerticalAlign) -> f32 { + match valign { + VerticalAlign::Center => (container_h - content_h) / 2.0, + VerticalAlign::Bottom => container_h - content_h, + _ => 0.0, + } +} + fn intersects(paragraph: &skia_safe::textlayout::Paragraph, x: f32, y: f32) -> bool { if y < 0.0 || y > paragraph.height() { return false; @@ -250,6 +272,20 @@ fn intersects(paragraph: &skia_safe::textlayout::Paragraph, x: f32, y: f32) -> b rects.iter().any(|r| r.rect.contains(&Point::new(x, y))) } +fn paragraph_intersects<'a>( + paragraphs: impl Iterator, + x_pos: f32, + y_pos: f32, +) -> bool { + paragraphs + .scan(0.0_f32, |height, p| { + let prev_height = *height; + *height += p.height(); + Some((prev_height, p)) + }) + .any(|(height, p)| intersects(p, x_pos, y_pos - height)) +} + // Performs a text auto layout without width limits. // This should be the same as text_auto_layout. pub fn build_paragraphs_from_paragraph_builders( @@ -370,34 +406,133 @@ impl TextContent { self.grow_type = grow_type; } + /// Compute a tight text rect from laid-out Skia paragraphs using glyph + /// metrics (fm.top for overshoot, line descent for bottom, line left/width + /// for horizontal extent). + fn rect_from_paragraphs(&self, selrect: &Rect, valign: VerticalAlign) -> Option { + let paragraphs = &self.layout.paragraphs; + let x = selrect.x(); + let base_y = selrect.y(); + + let total_height: f32 = paragraphs + .iter() + .filter_map(|group| group.first()) + .map(|p| p.height()) + .sum(); + + let vertical_offset = vertical_align_offset(selrect.height(), total_height, valign); + + let mut min_x = f32::MAX; + let mut min_y = f32::MAX; + let mut max_x = f32::MIN; + let mut max_y = f32::MIN; + let mut has_lines = false; + let mut y_accum = base_y + vertical_offset; + + for group in paragraphs { + if let Some(paragraph) = group.first() { + let line_metrics = paragraph.get_line_metrics(); + for line in &line_metrics { + let line_baseline = y_accum + line.baseline as f32; + + // Use per-glyph fm.top for tighter vertical bounds when + // available; fall back to line-level ascent for empty lines + // (where get_style_metrics returns nothing). + let style_metrics = line.get_style_metrics(line.start_index..line.end_index); + if style_metrics.is_empty() { + min_y = min_y.min(line_baseline - line.ascent as f32); + } else { + for (_start, style_metric) in &style_metrics { + let fm = &style_metric.font_metrics; + min_y = min_y.min(line_baseline + fm.top); + } + } + + // Bottom uses line-level descent (includes descender space + // for the whole line, not just present glyphs). + max_y = max_y.max(line_baseline + line.descent as f32); + min_x = min_x.min(x + line.left as f32); + max_x = max_x.max(x + line.left as f32 + line.width as f32); + has_lines = true; + } + y_accum += paragraph.height(); + } + } + + if has_lines { + Some(Rect::from_ltrb(min_x, min_y, max_x, max_y)) + } else { + None + } + } + + fn compute_and_cache_extrect( + &self, + shape: &Shape, + selrect: &Rect, + valign: VerticalAlign, + ) -> Rect { + // AutoWidth paragraphs are laid out with f32::MAX, so line metrics + // (line.left) reflect alignment within that huge width and are + // unusable for tight bounds. Fall back to content_rect. + if self.grow_type() == GrowType::AutoWidth { + return self.content_rect(selrect, valign); + } + + let tight = if !self.layout.paragraphs.is_empty() { + self.rect_from_paragraphs(selrect, valign) + } else { + let mut text_content = self.clone(); + text_content.update_layout(shape.selrect); + text_content.rect_from_paragraphs(selrect, valign) + } + .unwrap_or_else(|| self.content_rect(selrect, valign)); + + // Cache as offsets from selrect origin so it's position-independent. + let sx = selrect.x(); + let sy = selrect.y(); + self.layout.cached_extrect.set(Some(CachedExtrect { + selrect_width: selrect.width(), + selrect_height: selrect.height(), + valign: valign as u8, + left: tight.left() - sx, + top: tight.top() - sy, + right: tight.right() - sx, + bottom: tight.bottom() - sy, + })); + + tight + } + pub fn calculate_bounds(&self, shape: &Shape, apply_transform: bool) -> Bounds { - let (x, mut y, transform, center) = ( - shape.selrect.x(), - shape.selrect.y(), - &shape.transform, - &shape.center(), - ); + let transform = &shape.transform; + let center = &shape.center(); + let selrect = shape.selrect(); + let valign = shape.vertical_align(); + let sw = selrect.width(); + let sh = selrect.height(); + let sx = selrect.x(); + let sy = selrect.y(); - let width = if self.grow_type() == GrowType::AutoWidth { - self.size.width + // Try the cache first: if dimensions and valign match, just apply position offset. + let text_rect = if let Some(cached) = self.layout.cached_extrect.get() { + if (cached.selrect_width - sw).abs() < 0.1 + && (cached.selrect_height - sh).abs() < 0.1 + && cached.valign == valign as u8 + { + Rect::from_ltrb( + sx + cached.left, + sy + cached.top, + sx + cached.right, + sy + cached.bottom, + ) + } else { + self.compute_and_cache_extrect(shape, &selrect, valign) + } } else { - shape.selrect().width() + self.compute_and_cache_extrect(shape, &selrect, valign) }; - let height = if self.size.width.round() != width.round() { - self.get_height(width) - } else { - self.size.height - }; - - let offset_y = match shape.vertical_align() { - VerticalAlign::Center => (shape.selrect().height() - height) / 2.0, - VerticalAlign::Bottom => shape.selrect().height() - height, - _ => 0.0, - }; - y += offset_y; - - let text_rect = Rect::from_xywh(x, y, width, height); let mut bounds = Bounds::new( Point::new(text_rect.x(), text_rect.y()), Point::new(text_rect.x() + text_rect.width(), text_rect.y()), @@ -434,11 +569,7 @@ impl TextContent { self.size.height }; - let offset_y = match valign { - VerticalAlign::Center => (selrect.height() - height) / 2.0, - VerticalAlign::Bottom => selrect.height() - height, - _ => 0.0, - }; + let offset_y = vertical_align_offset(selrect.height(), height, valign); y += offset_y; Rect::from_xywh(x, y, width, height) @@ -883,22 +1014,24 @@ impl TextContent { let x_pos = result.x - rect.x(); let y_pos = result.y - rect.y(); - let width = self.width(); - let mut paragraph_builders = self.paragraph_builder_group_from_text(None); - let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); - - paragraphs - .iter() - .flatten() - .scan( - (0 as f32, None::), - |(height, _), p| { - let prev_height = *height; - *height += p.height(); - Some((prev_height, p)) - }, + if !self.layout.paragraphs.is_empty() { + // Reuse stored laid-out paragraphs + paragraph_intersects( + self.layout + .paragraphs + .iter() + .flat_map(|group| group.first()), + x_pos, + y_pos, ) - .any(|(height, p)| intersects(p, x_pos, y_pos - height)) + } else { + let width = self.width(); + let mut paragraph_builders = self.paragraph_builder_group_from_text(None); + let paragraphs = + build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); + + paragraph_intersects(paragraphs.iter().flatten(), x_pos, y_pos) + } } }