mirror of
https://github.com/penpot/penpot.git
synced 2026-08-01 19:06:18 +00:00
Merge remote-tracking branch 'origin/staging' into develop
This commit is contained in:
commit
508c67c930
@ -139,6 +139,15 @@
|
|||||||
:level :error
|
:level :error
|
||||||
:timeout 5000})))
|
:timeout 5000})))
|
||||||
|
|
||||||
|
(defmethod ptk/handle-error :network
|
||||||
|
[error]
|
||||||
|
;; Transient network errors (e.g. lost connectivity, DNS failure)
|
||||||
|
;; should not replace the entire page with an error screen. Show a
|
||||||
|
;; non-intrusive toast instead and let the user continue working.
|
||||||
|
(when-let [cause (::instance error)]
|
||||||
|
(ex/print-throwable cause :prefix "Network Error"))
|
||||||
|
(flash :cause (::instance error) :type :handled))
|
||||||
|
|
||||||
(defmethod ptk/handle-error :internal
|
(defmethod ptk/handle-error :internal
|
||||||
[error]
|
[error]
|
||||||
(st/emit! (rt/assign-exception error))
|
(st/emit! (rt/assign-exception error))
|
||||||
|
|||||||
@ -21,6 +21,61 @@
|
|||||||
|
|
||||||
(log/set-level! :info)
|
(log/set-level! :info)
|
||||||
|
|
||||||
|
;; -- Retry helpers -----------------------------------------------------------
|
||||||
|
|
||||||
|
(def ^:private retryable-types
|
||||||
|
"Set of error types that are considered transient and safe to retry
|
||||||
|
for idempotent (GET) requests."
|
||||||
|
#{:network ; js/fetch network-level failure
|
||||||
|
:bad-gateway ; 502
|
||||||
|
:service-unavailable ; 503
|
||||||
|
:offline}) ; status 0 (browser offline)
|
||||||
|
|
||||||
|
(defn retryable-error?
|
||||||
|
"Return true when `error` represents a transient failure that is safe
|
||||||
|
to retry. Only errors whose `ex-data` `:type` belongs to
|
||||||
|
`retryable-types` qualify."
|
||||||
|
[error]
|
||||||
|
(contains? retryable-types (:type (ex-data error))))
|
||||||
|
|
||||||
|
(def default-retry-config
|
||||||
|
"Default configuration for the retry mechanism on idempotent requests."
|
||||||
|
{:max-retries 3
|
||||||
|
:base-delay-ms 1000})
|
||||||
|
|
||||||
|
(defn with-retry
|
||||||
|
"Wrap `observable-fn` (a zero-arg function returning an Observable) so
|
||||||
|
that retryable errors are retried up to `:max-retries` times with
|
||||||
|
exponential back-off. Non-retryable errors propagate immediately.
|
||||||
|
|
||||||
|
Accepts an optional `config` map with:
|
||||||
|
:max-retries – maximum number of retries (default 3)
|
||||||
|
:base-delay-ms – base delay in ms; doubles each attempt (default 1000)"
|
||||||
|
([observable-fn]
|
||||||
|
(with-retry observable-fn default-retry-config))
|
||||||
|
([observable-fn config]
|
||||||
|
(with-retry observable-fn config 0))
|
||||||
|
([observable-fn config attempt]
|
||||||
|
(let [{:keys [max-retries base-delay-ms]} (merge default-retry-config config)]
|
||||||
|
(->> (observable-fn)
|
||||||
|
(rx/catch
|
||||||
|
(fn [cause]
|
||||||
|
(if (and (retryable-error? cause)
|
||||||
|
(< attempt max-retries))
|
||||||
|
;; bit-shift-left 1 N is equivalent to 2^N: shift the bits of the
|
||||||
|
;; number 1 to the left N positions (e.g. 1 -> 2 -> 4 -> 8 -> 16),
|
||||||
|
;; producing exponential backoff delays of 1x, 2x, 4x, 8x, 16x.
|
||||||
|
(let [delay-ms (* base-delay-ms (bit-shift-left 1 attempt))]
|
||||||
|
(log/wrn :hint "retrying request"
|
||||||
|
:attempt (inc attempt)
|
||||||
|
:delay delay-ms
|
||||||
|
:error (ex-message cause))
|
||||||
|
(->> (rx/timer delay-ms)
|
||||||
|
(rx/mapcat (fn [_] (with-retry observable-fn config (inc attempt))))))
|
||||||
|
(rx/throw cause))))))))
|
||||||
|
|
||||||
|
;; -- Response handling -------------------------------------------------------
|
||||||
|
|
||||||
(defn handle-response
|
(defn handle-response
|
||||||
[{:keys [status body headers uri] :as response}]
|
[{:keys [status body headers uri] :as response}]
|
||||||
(cond
|
(cond
|
||||||
@ -146,32 +201,41 @@
|
|||||||
|
|
||||||
(log/trc :hint "make request" :id id)
|
(log/trc :hint "make request" :id id)
|
||||||
|
|
||||||
(->> (http/fetch request)
|
(let [make-request
|
||||||
(rx/map http/response->map)
|
(fn []
|
||||||
(rx/mapcat (fn [{:keys [headers body] :as response}]
|
(->> (http/fetch request)
|
||||||
(log/trc :hint "response received" :id id :elapsed (tpoint))
|
(rx/map http/response->map)
|
||||||
|
(rx/mapcat (fn [{:keys [headers body] :as response}]
|
||||||
|
(log/trc :hint "response received" :id id :elapsed (tpoint))
|
||||||
|
|
||||||
(let [ctype (get headers "content-type")
|
(let [ctype (get headers "content-type")
|
||||||
response-stream? (str/starts-with? ctype "text/event-stream")
|
response-stream? (str/starts-with? ctype "text/event-stream")
|
||||||
tpoint (ct/tpoint-ms)]
|
tpoint (ct/tpoint-ms)]
|
||||||
|
|
||||||
(when (and response-stream? (not stream?))
|
(when (and response-stream? (not stream?))
|
||||||
(ex/raise :type :assertion
|
(ex/raise :type :assertion
|
||||||
:code :unexpected-response
|
:code :unexpected-response
|
||||||
:hint "expected normal response, received sse stream"
|
:hint "expected normal response, received sse stream"
|
||||||
:uri (:uri response)
|
:uri (:uri response)
|
||||||
:status (:status response)))
|
:status (:status response)))
|
||||||
|
|
||||||
(if response-stream?
|
(if response-stream?
|
||||||
(-> (sse/create-stream body)
|
(-> (sse/create-stream body)
|
||||||
(sse/read-stream t/decode-str))
|
(sse/read-stream t/decode-str))
|
||||||
|
|
||||||
(->> response
|
(->> response
|
||||||
(http/process-response-type response-type)
|
(http/process-response-type response-type)
|
||||||
(rx/map decode-fn)
|
(rx/map decode-fn)
|
||||||
(rx/tap (fn [_]
|
(rx/tap (fn [_]
|
||||||
(log/trc :hint "response decoded" :id id :elapsed (tpoint))))
|
(log/trc :hint "response decoded" :id id :elapsed (tpoint))))
|
||||||
(rx/mapcat handle-response)))))))))
|
(rx/mapcat handle-response))))))))]
|
||||||
|
|
||||||
|
;; Idempotent (GET) requests are automatically retried on
|
||||||
|
;; transient network / server errors. Mutations are never
|
||||||
|
;; retried to avoid unintended side-effects.
|
||||||
|
(if (= :get method)
|
||||||
|
(with-retry make-request)
|
||||||
|
(make-request)))))
|
||||||
|
|
||||||
(defmulti cmd! (fn [id _] id))
|
(defmulti cmd! (fn [id _] id))
|
||||||
|
|
||||||
|
|||||||
@ -1643,45 +1643,42 @@
|
|||||||
(+ offset POSITION-DATA-U32-SIZE)))
|
(+ offset POSITION-DATA-U32-SIZE)))
|
||||||
(persistent! result)))
|
(persistent! result)))
|
||||||
|
|
||||||
result
|
content (:content shape)]
|
||||||
(into []
|
|
||||||
(keep
|
|
||||||
(fn [{:keys [paragraph span start-pos end-pos direction x y width height]}]
|
|
||||||
(let [content (:content shape)
|
|
||||||
element (-> content :children
|
|
||||||
(get 0) :children ;; paragraph-set
|
|
||||||
(get paragraph) :children ;; paragraph
|
|
||||||
(get span))
|
|
||||||
element-text (:text element)]
|
|
||||||
|
|
||||||
;; Add comprehensive nil-safety checks
|
|
||||||
(when (and element
|
|
||||||
element-text
|
|
||||||
(>= start-pos 0)
|
|
||||||
(<= end-pos (count element-text))
|
|
||||||
(<= start-pos end-pos))
|
|
||||||
(let [text (subs element-text start-pos end-pos)]
|
|
||||||
(d/patch-object
|
|
||||||
txt/default-text-attrs
|
|
||||||
(d/without-nils
|
|
||||||
{:x x
|
|
||||||
:y (+ y height)
|
|
||||||
:width width
|
|
||||||
:height height
|
|
||||||
:direction (dr/translate-direction direction)
|
|
||||||
:font-family (get element :font-family)
|
|
||||||
:font-size (get element :font-size)
|
|
||||||
:font-weight (get element :font-weight)
|
|
||||||
:text-transform (get element :text-transform)
|
|
||||||
:text-decoration (get element :text-decoration)
|
|
||||||
:letter-spacing (get element :letter-spacing)
|
|
||||||
:font-style (get element :font-style)
|
|
||||||
:fills (get element :fills)
|
|
||||||
:text text})))))))
|
|
||||||
result)]
|
|
||||||
(mem/free)
|
(mem/free)
|
||||||
|
|
||||||
result)))
|
(into []
|
||||||
|
(keep
|
||||||
|
(fn [{:keys [paragraph span start-pos end-pos direction x y width height]}]
|
||||||
|
(let [element (-> content :children
|
||||||
|
(get 0) :children ;; paragraph-set
|
||||||
|
(get paragraph) :children ;; paragraph
|
||||||
|
(get span))
|
||||||
|
element-text (:text element)]
|
||||||
|
|
||||||
|
;; Add comprehensive nil-safety checks
|
||||||
|
;; Be aware that for RTL texts `start-pos` can be greatert han `end-pos`
|
||||||
|
(when (and element element-text)
|
||||||
|
(let [text (subs element-text start-pos end-pos)]
|
||||||
|
(d/patch-object
|
||||||
|
txt/default-text-attrs
|
||||||
|
(d/without-nils
|
||||||
|
{:x x
|
||||||
|
:y (+ y height)
|
||||||
|
:width width
|
||||||
|
:height height
|
||||||
|
:direction (dr/translate-direction direction)
|
||||||
|
:font-id (get element :font-id)
|
||||||
|
:font-family (get element :font-family)
|
||||||
|
:font-size (get element :font-size)
|
||||||
|
:font-weight (get element :font-weight)
|
||||||
|
:text-transform (get element :text-transform)
|
||||||
|
:text-decoration (get element :text-decoration)
|
||||||
|
:letter-spacing (get element :letter-spacing)
|
||||||
|
:font-style (get element :font-style)
|
||||||
|
:fills (get element :fills)
|
||||||
|
:text text})))))))
|
||||||
|
result))))
|
||||||
|
|
||||||
(defn apply-canvas-blur
|
(defn apply-canvas-blur
|
||||||
[]
|
[]
|
||||||
|
|||||||
@ -108,8 +108,7 @@
|
|||||||
(vreset! abortable? false)
|
(vreset! abortable? false)
|
||||||
(when-not (or @unsubscribed? (= (.-name ^js cause) "AbortError"))
|
(when-not (or @unsubscribed? (= (.-name ^js cause) "AbortError"))
|
||||||
(let [error (ex-info (ex-message cause)
|
(let [error (ex-info (ex-message cause)
|
||||||
{:type :internal
|
{:type :network
|
||||||
:code :fetch-error
|
|
||||||
:hint "unable to perform fetch operation"
|
:hint "unable to perform fetch operation"
|
||||||
:uri uri
|
:uri uri
|
||||||
:headers headers}
|
:headers headers}
|
||||||
|
|||||||
217
frontend/test/frontend_tests/data/repo_test.cljs
Normal file
217
frontend/test/frontend_tests/data/repo_test.cljs
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
;;
|
||||||
|
;; Copyright (c) KALEIDOS INC
|
||||||
|
|
||||||
|
(ns frontend-tests.data.repo-test
|
||||||
|
(:require
|
||||||
|
[app.main.repo :as repo]
|
||||||
|
[beicon.v2.core :as rx]
|
||||||
|
[cljs.test :as t :include-macros true]))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; retryable-error? tests (synchronous)
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(t/deftest retryable-error-network
|
||||||
|
(t/testing "network error (js/fetch failure) is retryable"
|
||||||
|
(let [err (ex-info "network" {:type :network})]
|
||||||
|
(t/is (true? (repo/retryable-error? err))))))
|
||||||
|
|
||||||
|
(t/deftest retryable-error-bad-gateway
|
||||||
|
(t/testing "502 bad-gateway is retryable"
|
||||||
|
(let [err (ex-info "bad gateway" {:type :bad-gateway})]
|
||||||
|
(t/is (true? (repo/retryable-error? err))))))
|
||||||
|
|
||||||
|
(t/deftest retryable-error-service-unavailable
|
||||||
|
(t/testing "503 service-unavailable is retryable"
|
||||||
|
(let [err (ex-info "service unavailable" {:type :service-unavailable})]
|
||||||
|
(t/is (true? (repo/retryable-error? err))))))
|
||||||
|
|
||||||
|
(t/deftest retryable-error-offline
|
||||||
|
(t/testing "offline (status 0) is retryable"
|
||||||
|
(let [err (ex-info "offline" {:type :offline})]
|
||||||
|
(t/is (true? (repo/retryable-error? err))))))
|
||||||
|
|
||||||
|
(t/deftest retryable-error-internal
|
||||||
|
(t/testing "internal error (genuine bug) is NOT retryable"
|
||||||
|
(let [err (ex-info "internal" {:type :internal :code :something})]
|
||||||
|
(t/is (not (repo/retryable-error? err))))))
|
||||||
|
|
||||||
|
(t/deftest retryable-error-validation
|
||||||
|
(t/testing "validation error is NOT retryable"
|
||||||
|
(let [err (ex-info "validation" {:type :validation :code :request-body-too-large})]
|
||||||
|
(t/is (not (repo/retryable-error? err))))))
|
||||||
|
|
||||||
|
(t/deftest retryable-error-authentication
|
||||||
|
(t/testing "authentication error is NOT retryable"
|
||||||
|
(let [err (ex-info "auth" {:type :authentication})]
|
||||||
|
(t/is (not (repo/retryable-error? err))))))
|
||||||
|
|
||||||
|
(t/deftest retryable-error-authorization
|
||||||
|
(t/testing "authorization/challenge error is NOT retryable"
|
||||||
|
(let [err (ex-info "auth" {:type :authorization :code :challenge-required})]
|
||||||
|
(t/is (not (repo/retryable-error? err))))))
|
||||||
|
|
||||||
|
(t/deftest retryable-error-no-ex-data
|
||||||
|
(t/testing "plain error without ex-data is NOT retryable"
|
||||||
|
(let [err (js/Error. "plain")]
|
||||||
|
(t/is (not (repo/retryable-error? err))))))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; with-retry tests (async, using zero-delay config for speed)
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(def ^:private fast-config
|
||||||
|
"Retry config with zero delay for fast tests."
|
||||||
|
{:max-retries 3 :base-delay-ms 0})
|
||||||
|
|
||||||
|
(t/deftest with-retry-succeeds-immediately
|
||||||
|
(t/testing "returns value when observable succeeds on first try"
|
||||||
|
(t/async done
|
||||||
|
(let [call-count (atom 0)
|
||||||
|
obs-fn (fn []
|
||||||
|
(swap! call-count inc)
|
||||||
|
(rx/of :ok))]
|
||||||
|
(->> (repo/with-retry obs-fn fast-config)
|
||||||
|
(rx/subs!
|
||||||
|
(fn [val]
|
||||||
|
(t/is (= :ok val))
|
||||||
|
(t/is (= 1 @call-count))
|
||||||
|
(done))
|
||||||
|
(fn [err]
|
||||||
|
(t/is false (str "unexpected error: " (ex-message err)))
|
||||||
|
(done))))))))
|
||||||
|
|
||||||
|
(t/deftest with-retry-retries-on-retryable-error
|
||||||
|
(t/testing "retries and eventually succeeds after transient failures"
|
||||||
|
(t/async done
|
||||||
|
(let [call-count (atom 0)
|
||||||
|
obs-fn (fn []
|
||||||
|
(let [n (swap! call-count inc)]
|
||||||
|
(if (< n 3)
|
||||||
|
;; First two calls fail with retryable error
|
||||||
|
(rx/throw (ex-info "bad gateway" {:type :bad-gateway}))
|
||||||
|
;; Third call succeeds
|
||||||
|
(rx/of :recovered))))]
|
||||||
|
(->> (repo/with-retry obs-fn fast-config)
|
||||||
|
(rx/subs!
|
||||||
|
(fn [val]
|
||||||
|
(t/is (= :recovered val))
|
||||||
|
(t/is (= 3 @call-count))
|
||||||
|
(done))
|
||||||
|
(fn [err]
|
||||||
|
(t/is false (str "unexpected error: " (ex-message err)))
|
||||||
|
(done))))))))
|
||||||
|
|
||||||
|
(t/deftest with-retry-exhausts-retries
|
||||||
|
(t/testing "propagates error after max retries exhausted"
|
||||||
|
(t/async done
|
||||||
|
(let [call-count (atom 0)
|
||||||
|
obs-fn (fn []
|
||||||
|
(swap! call-count inc)
|
||||||
|
(rx/throw (ex-info "offline" {:type :offline})))]
|
||||||
|
(->> (repo/with-retry obs-fn fast-config)
|
||||||
|
(rx/subs!
|
||||||
|
(fn [_val]
|
||||||
|
(t/is false "should not succeed")
|
||||||
|
(done))
|
||||||
|
(fn [err]
|
||||||
|
;; 1 initial + 3 retries = 4 total calls
|
||||||
|
(t/is (= 4 @call-count))
|
||||||
|
(t/is (= :offline (:type (ex-data err))))
|
||||||
|
(done))))))))
|
||||||
|
|
||||||
|
(t/deftest with-retry-no-retry-on-non-retryable
|
||||||
|
(t/testing "non-retryable errors propagate immediately without retry"
|
||||||
|
(t/async done
|
||||||
|
(let [call-count (atom 0)
|
||||||
|
obs-fn (fn []
|
||||||
|
(swap! call-count inc)
|
||||||
|
(rx/throw (ex-info "auth" {:type :authentication})))]
|
||||||
|
(->> (repo/with-retry obs-fn fast-config)
|
||||||
|
(rx/subs!
|
||||||
|
(fn [_val]
|
||||||
|
(t/is false "should not succeed")
|
||||||
|
(done))
|
||||||
|
(fn [err]
|
||||||
|
(t/is (= 1 @call-count))
|
||||||
|
(t/is (= :authentication (:type (ex-data err))))
|
||||||
|
(done))))))))
|
||||||
|
|
||||||
|
(t/deftest with-retry-network-error-retried
|
||||||
|
(t/testing "network error (js/fetch failure) is retried"
|
||||||
|
(t/async done
|
||||||
|
(let [call-count (atom 0)
|
||||||
|
obs-fn (fn []
|
||||||
|
(let [n (swap! call-count inc)]
|
||||||
|
(if (= n 1)
|
||||||
|
(rx/throw (ex-info "net" {:type :network}))
|
||||||
|
(rx/of :ok))))]
|
||||||
|
(->> (repo/with-retry obs-fn fast-config)
|
||||||
|
(rx/subs!
|
||||||
|
(fn [val]
|
||||||
|
(t/is (= :ok val))
|
||||||
|
(t/is (= 2 @call-count))
|
||||||
|
(done))
|
||||||
|
(fn [err]
|
||||||
|
(t/is false (str "unexpected error: " (ex-message err)))
|
||||||
|
(done))))))))
|
||||||
|
|
||||||
|
(t/deftest with-retry-internal-not-retried
|
||||||
|
(t/testing "internal error (genuine bug) is not retried"
|
||||||
|
(t/async done
|
||||||
|
(let [call-count (atom 0)
|
||||||
|
obs-fn (fn []
|
||||||
|
(swap! call-count inc)
|
||||||
|
(rx/throw (ex-info "bug" {:type :internal
|
||||||
|
:code :something})))]
|
||||||
|
(->> (repo/with-retry obs-fn fast-config)
|
||||||
|
(rx/subs!
|
||||||
|
(fn [_val]
|
||||||
|
(t/is false "should not succeed")
|
||||||
|
(done))
|
||||||
|
(fn [err]
|
||||||
|
(t/is (= 1 @call-count))
|
||||||
|
(t/is (= :internal (:type (ex-data err))))
|
||||||
|
(done))))))))
|
||||||
|
|
||||||
|
(t/deftest with-retry-respects-max-retries-config
|
||||||
|
(t/testing "respects custom max-retries setting"
|
||||||
|
(t/async done
|
||||||
|
(let [call-count (atom 0)
|
||||||
|
config {:max-retries 1 :base-delay-ms 0}
|
||||||
|
obs-fn (fn []
|
||||||
|
(swap! call-count inc)
|
||||||
|
(rx/throw (ex-info "offline" {:type :offline})))]
|
||||||
|
(->> (repo/with-retry obs-fn config)
|
||||||
|
(rx/subs!
|
||||||
|
(fn [_val]
|
||||||
|
(t/is false "should not succeed")
|
||||||
|
(done))
|
||||||
|
(fn [err]
|
||||||
|
;; 1 initial + 1 retry = 2 total
|
||||||
|
(t/is (= 2 @call-count))
|
||||||
|
(t/is (= :offline (:type (ex-data err))))
|
||||||
|
(done))))))))
|
||||||
|
|
||||||
|
(t/deftest with-retry-mixed-errors
|
||||||
|
(t/testing "retries retryable errors, then stops on non-retryable"
|
||||||
|
(t/async done
|
||||||
|
(let [call-count (atom 0)
|
||||||
|
obs-fn (fn []
|
||||||
|
(let [n (swap! call-count inc)]
|
||||||
|
(case n
|
||||||
|
1 (rx/throw (ex-info "gw" {:type :bad-gateway}))
|
||||||
|
2 (rx/throw (ex-info "auth" {:type :authentication}))
|
||||||
|
(rx/of :should-not-reach))))]
|
||||||
|
(->> (repo/with-retry obs-fn fast-config)
|
||||||
|
(rx/subs!
|
||||||
|
(fn [_val]
|
||||||
|
(t/is false "should not succeed")
|
||||||
|
(done))
|
||||||
|
(fn [err]
|
||||||
|
(t/is (= 2 @call-count))
|
||||||
|
(t/is (= :authentication (:type (ex-data err))))
|
||||||
|
(done))))))))
|
||||||
@ -2,6 +2,7 @@
|
|||||||
(:require
|
(:require
|
||||||
[cljs.test :as t]
|
[cljs.test :as t]
|
||||||
[frontend-tests.basic-shapes-test]
|
[frontend-tests.basic-shapes-test]
|
||||||
|
[frontend-tests.data.repo-test]
|
||||||
[frontend-tests.data.workspace-colors-test]
|
[frontend-tests.data.workspace-colors-test]
|
||||||
[frontend-tests.helpers-shapes-test]
|
[frontend-tests.helpers-shapes-test]
|
||||||
[frontend-tests.logic.comp-remove-swap-slots-test]
|
[frontend-tests.logic.comp-remove-swap-slots-test]
|
||||||
@ -35,6 +36,7 @@
|
|||||||
[]
|
[]
|
||||||
(t/run-tests
|
(t/run-tests
|
||||||
'frontend-tests.basic-shapes-test
|
'frontend-tests.basic-shapes-test
|
||||||
|
'frontend-tests.data.repo-test
|
||||||
'frontend-tests.data.workspace-colors-test
|
'frontend-tests.data.workspace-colors-test
|
||||||
'frontend-tests.helpers-shapes-test
|
'frontend-tests.helpers-shapes-test
|
||||||
'frontend-tests.logic.comp-remove-swap-slots-test
|
'frontend-tests.logic.comp-remove-swap-slots-test
|
||||||
|
|||||||
@ -961,7 +961,7 @@ export class SelectionController extends EventTarget {
|
|||||||
* @type {boolean}
|
* @type {boolean}
|
||||||
*/
|
*/
|
||||||
get isTextFocus() {
|
get isTextFocus() {
|
||||||
return this.focusNode.nodeType === Node.TEXT_NODE;
|
return this.focusNode != null && this.focusNode.nodeType === Node.TEXT_NODE;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -970,7 +970,9 @@ export class SelectionController extends EventTarget {
|
|||||||
* @type {boolean}
|
* @type {boolean}
|
||||||
*/
|
*/
|
||||||
get isTextAnchor() {
|
get isTextAnchor() {
|
||||||
return this.anchorNode.nodeType === Node.TEXT_NODE;
|
return (
|
||||||
|
this.anchorNode != null && this.anchorNode.nodeType === Node.TEXT_NODE
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1378,67 +1378,45 @@ pub fn calculate_text_layout_data(
|
|||||||
let current_y = para_layout.y;
|
let current_y = para_layout.y;
|
||||||
let text_paragraph = text_paragraphs.get(paragraph_index);
|
let text_paragraph = text_paragraphs.get(paragraph_index);
|
||||||
if let Some(text_para) = text_paragraph {
|
if let Some(text_para) = text_paragraph {
|
||||||
let mut span_ranges: Vec<(usize, usize, usize, String, String)> = vec![];
|
let mut span_ranges: Vec<(usize, usize, usize)> = vec![];
|
||||||
let mut cur = 0;
|
let mut cur = 0;
|
||||||
for (span_index, span) in text_para.children().iter().enumerate() {
|
for (span_index, span) in text_para.children().iter().enumerate() {
|
||||||
let transformed_text: String = span.apply_text_transform();
|
let text: String = span.apply_text_transform();
|
||||||
let original_text = span.text.clone();
|
let text_len = text.encode_utf16().count();
|
||||||
let text = transformed_text.clone();
|
span_ranges.push((cur, cur + text_len + 1, span_index));
|
||||||
let text_len = text.len();
|
|
||||||
span_ranges.push((cur, cur + text_len, span_index, text, original_text));
|
|
||||||
cur += text_len;
|
cur += text_len;
|
||||||
}
|
}
|
||||||
for (start, end, span_index, transformed_text, original_text) in span_ranges {
|
for (start, end, span_index) in span_ranges {
|
||||||
// Skip empty spans to avoid invalid rect calculations
|
|
||||||
if start >= end {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let rects = para_layout.paragraph.get_rects_for_range(
|
let rects = para_layout.paragraph.get_rects_for_range(
|
||||||
start..end,
|
start..end,
|
||||||
RectHeightStyle::Tight,
|
RectHeightStyle::Tight,
|
||||||
RectWidthStyle::Tight,
|
RectWidthStyle::Tight,
|
||||||
);
|
);
|
||||||
|
|
||||||
for textbox in rects {
|
for textbox in rects {
|
||||||
let direction = textbox.direct;
|
let direction = textbox.direct;
|
||||||
let mut rect = textbox.rect;
|
let mut rect = textbox.rect;
|
||||||
let cy = rect.top + rect.height() / 2.0;
|
let cy = rect.top + rect.height() / 2.0;
|
||||||
|
|
||||||
// Get byte positions from Skia's transformed text layout
|
// Get byte positions from Skia's transformed text layout
|
||||||
let glyph_start = para_layout
|
let start_pos = para_layout
|
||||||
.paragraph
|
.paragraph
|
||||||
.get_glyph_position_at_coordinate((rect.left + 0.1, cy))
|
.get_glyph_position_at_coordinate((rect.left + 0.1, cy))
|
||||||
.position as usize;
|
.position as usize
|
||||||
let glyph_end = para_layout
|
- start;
|
||||||
|
|
||||||
|
let end_pos = para_layout
|
||||||
.paragraph
|
.paragraph
|
||||||
.get_glyph_position_at_coordinate((rect.right - 0.1, cy))
|
.get_glyph_position_at_coordinate((rect.right - 0.1, cy))
|
||||||
.position as usize;
|
.position as usize
|
||||||
|
- start;
|
||||||
// Convert to byte positions relative to this span
|
|
||||||
let byte_start = glyph_start.saturating_sub(start);
|
|
||||||
let byte_end = glyph_end.saturating_sub(start);
|
|
||||||
|
|
||||||
// Convert byte positions to character positions in ORIGINAL text
|
|
||||||
// This handles multi-byte UTF-8 and text transform differences
|
|
||||||
let char_start = transformed_text
|
|
||||||
.char_indices()
|
|
||||||
.position(|(i, _)| i >= byte_start)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let char_end = transformed_text
|
|
||||||
.char_indices()
|
|
||||||
.position(|(i, _)| i >= byte_end)
|
|
||||||
.unwrap_or_else(|| transformed_text.chars().count());
|
|
||||||
|
|
||||||
// Clamp to original text length for safety
|
|
||||||
let original_char_count = original_text.chars().count();
|
|
||||||
let final_start = char_start.min(original_char_count);
|
|
||||||
let final_end = char_end.min(original_char_count);
|
|
||||||
|
|
||||||
rect.offset((x, current_y));
|
rect.offset((x, current_y));
|
||||||
position_data.push(PositionData {
|
position_data.push(PositionData {
|
||||||
paragraph: paragraph_index as u32,
|
paragraph: paragraph_index as u32,
|
||||||
span: span_index as u32,
|
span: span_index as u32,
|
||||||
start_pos: final_start as u32,
|
start_pos: start_pos as u32,
|
||||||
end_pos: final_end as u32,
|
end_pos: end_pos as u32,
|
||||||
x: rect.x(),
|
x: rect.x(),
|
||||||
y: rect.y(),
|
y: rect.y(),
|
||||||
width: rect.width(),
|
width: rect.width(),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user