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

This commit is contained in:
Alejandro Alonso 2026-04-09 09:05:02 +02:00
commit 27449139ad
19 changed files with 833 additions and 254 deletions

View File

@ -1,9 +0,0 @@
{
"files.exclude": {
"**/.clj-kondo": true,
"**/.cpcache": true,
"**/.lsp": true,
"**/.shadow-cljs": true,
"**/node_modules": true
}
}

View File

@ -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)

View File

@ -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)

View 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))))

View File

@ -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})

View File

@ -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))))))

View File

@ -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]

View File

@ -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)]

View File

@ -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")]]]]))

View File

@ -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);
}

View File

@ -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))))

View File

@ -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]

View File

@ -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)))

View File

@ -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)

View File

@ -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)

View File

@ -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"

View File

@ -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"

View File

@ -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();
}
}

View File

@ -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)
}
}
}