mirror of
https://github.com/penpot/penpot.git
synced 2026-04-25 11:18:36 +00:00
Merge remote-tracking branch 'origin/staging' into develop
This commit is contained in:
commit
27449139ad
9
.vscode/settings.json
vendored
9
.vscode/settings.json
vendored
@ -1,9 +0,0 @@
|
||||
{
|
||||
"files.exclude": {
|
||||
"**/.clj-kondo": true,
|
||||
"**/.cpcache": true,
|
||||
"**/.lsp": true,
|
||||
"**/.shadow-cljs": true,
|
||||
"**/node_modules": true
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
105
common/src/app/common/schema/messages.cljc
Normal file
105
common/src/app/common/schema/messages.cljc
Normal file
@ -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))))
|
||||
@ -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})
|
||||
|
||||
|
||||
@ -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))))))
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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")]]]]))
|
||||
@ -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);
|
||||
}
|
||||
@ -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))))
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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?"
|
||||
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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<ParagraphBuilderGroup>,
|
||||
pub paragraphs: Vec<Vec<skia::textlayout::Paragraph>>,
|
||||
cached_extrect: Cell<Option<CachedExtrect>>,
|
||||
}
|
||||
|
||||
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<Item = &'a skia::textlayout::Paragraph>,
|
||||
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<Rect> {
|
||||
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::<skia::textlayout::Paragraph>),
|
||||
|(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user