on document.body,
+ keeping the DOM clean and avoiding removeChild race conditions."
+ ([]
+ (use-portal-container :default))
+ ([category]
+ (let [category (name category)]
+ (mf/with-memo [category]
+ (get-or-create-portal-container category)))))
(defn use-dynamic-grid-item-width
([] (use-dynamic-grid-item-width nil))
diff --git a/frontend/src/app/main/ui/modal.cljs b/frontend/src/app/main/ui/modal.cljs
index 5df1cc3daa..6e9b1df7d4 100644
--- a/frontend/src/app/main/ui/modal.cljs
+++ b/frontend/src/app/main/ui/modal.cljs
@@ -84,7 +84,7 @@
(mf/defc modal-container*
{::mf/props :obj}
[]
- (let [container (hooks/use-portal-container)]
+ (let [container (hooks/use-portal-container :modal)]
(when-let [modal (mf/deref ref:modal)]
(mf/portal
(mf/html [:> modal-wrapper* {:data modal :key (dm/str (:id modal))}])
diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs
index 2e7446c425..b9aab0ecf0 100644
--- a/frontend/src/app/main/ui/workspace.cljs
+++ b/frontend/src/app/main/ui/workspace.cljs
@@ -97,7 +97,7 @@
[:section {:class (stl/css :workspace-viewport)}
(when (dbg/enabled? :coordinates)
- [:& coordinates/coordinates {:colorpalette? colorpalette?}])
+ [:> coordinates/coordinates* {:is-colorpalette colorpalette?}])
(when (dbg/enabled? :history-overlay)
[:div {:class (stl/css :history-debug-overlay)}
diff --git a/frontend/src/app/main/ui/workspace/colorpicker.cljs b/frontend/src/app/main/ui/workspace/colorpicker.cljs
index 3e930e9f81..d6d4300848 100644
--- a/frontend/src/app/main/ui/workspace/colorpicker.cljs
+++ b/frontend/src/app/main/ui/workspace/colorpicker.cljs
@@ -33,12 +33,12 @@
[app.main.ui.ds.layout.tab-switcher :refer [tab-switcher*]]
[app.main.ui.hooks :as hooks]
[app.main.ui.icons :as deprecated-icon]
- [app.main.ui.workspace.colorpicker.color-inputs :refer [color-inputs]]
+ [app.main.ui.workspace.colorpicker.color-inputs :refer [color-inputs*]]
[app.main.ui.workspace.colorpicker.color-tokens :refer [token-section*]]
[app.main.ui.workspace.colorpicker.gradients :refer [gradients*]]
- [app.main.ui.workspace.colorpicker.harmony :refer [harmony-selector]]
- [app.main.ui.workspace.colorpicker.hsva :refer [hsva-selector]]
- [app.main.ui.workspace.colorpicker.libraries :refer [libraries]]
+ [app.main.ui.workspace.colorpicker.harmony :refer [harmony-selector*]]
+ [app.main.ui.workspace.colorpicker.hsva :refer [hsva-selector*]]
+ [app.main.ui.workspace.colorpicker.libraries :refer [libraries*]]
[app.main.ui.workspace.colorpicker.ramp :refer [ramp-selector*]]
[app.main.ui.workspace.colorpicker.shortcuts :as sc]
[app.util.dom :as dom]
@@ -93,7 +93,7 @@
(dom/set-css-property! node "--saturation-grad-from" (format-hsl hsl-from))
(dom/set-css-property! node "--saturation-grad-to" (format-hsl hsl-to)))))
-(mf/defc colorpicker
+(mf/defc colorpicker*
[{:keys [data disable-gradient disable-opacity disable-image on-change on-accept origin combined-tokens color-origin on-token-change tab applied-token]}]
(let [state (mf/deref refs/colorpicker)
node-ref (mf/use-ref)
@@ -511,27 +511,28 @@
:on-finish-drag on-finish-drag}]
"harmony"
- [:& harmony-selector
+ [:> harmony-selector*
{:color current-color
:disable-opacity disable-opacity
:on-change handle-change-color
- :on-start-drag on-start-drag}]
+ :on-start-drag on-start-drag
+ :on-finish-drag on-finish-drag}]
"hsva"
- [:& hsva-selector
+ [:> hsva-selector*
{:color current-color
:disable-opacity disable-opacity
:on-change handle-change-color
:on-start-drag on-start-drag
:on-finish-drag on-finish-drag}]))]]
- [:& color-inputs
+ [:> color-inputs*
{:type type
:disable-opacity disable-opacity
:color current-color
:on-change handle-change-color}]
- [:& libraries
+ [:> libraries*
{:state state
:current-color current-color
:disable-gradient disable-gradient
@@ -786,15 +787,15 @@
:data-testid "colorpicker"
:style style}
- [:& colorpicker {:data data
- :combined-tokens grouped-tokens-by-set
- :disable-gradient disable-gradient
- :disable-opacity disable-opacity
- :disable-image disable-image
- :on-token-change on-token-change
- :applied-token applied-token
- :on-change on-change'
- :origin origin
- :tab tab
- :color-origin color-origin
- :on-accept on-accept}]]))
+ [:> colorpicker* {:data data
+ :combined-tokens grouped-tokens-by-set
+ :disable-gradient disable-gradient
+ :disable-opacity disable-opacity
+ :disable-image disable-image
+ :on-token-change on-token-change
+ :applied-token applied-token
+ :on-change on-change'
+ :origin origin
+ :tab tab
+ :color-origin color-origin
+ :on-accept on-accept}]]))
diff --git a/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.cljs b/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.cljs
index fc384cdfdd..09ad2d0e8c 100644
--- a/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.cljs
+++ b/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.cljs
@@ -28,7 +28,7 @@
[val]
(* (/ val 255) 100))
-(mf/defc color-inputs [{:keys [type color disable-opacity on-change]}]
+(mf/defc color-inputs* [{:keys [type color disable-opacity on-change]}]
(let [{red :r green :g blue :b
hue :h saturation :s value :v
hex :hex alpha :alpha} color
diff --git a/frontend/src/app/main/ui/workspace/colorpicker/harmony.cljs b/frontend/src/app/main/ui/workspace/colorpicker/harmony.cljs
index c043899551..393e89df69 100644
--- a/frontend/src/app/main/ui/workspace/colorpicker/harmony.cljs
+++ b/frontend/src/app/main/ui/workspace/colorpicker/harmony.cljs
@@ -11,7 +11,7 @@
[app.common.geom.point :as gpt]
[app.common.math :as mth]
[app.common.types.color :as cc]
- [app.main.ui.workspace.colorpicker.slider-selector :refer [slider-selector]]
+ [app.main.ui.workspace.colorpicker.slider-selector :refer [slider-selector*]]
[app.util.dom :as dom]
[app.util.object :as obj]
[cuerdas.core :as str]
@@ -58,7 +58,7 @@
y (+ (/ canvas-side 2) (* comp-y (/ canvas-side 2)))]
(gpt/point x y)))
-(mf/defc harmony-selector [{:keys [color disable-opacity on-change on-start-drag on-finish-drag]}]
+(mf/defc harmony-selector* [{:keys [color disable-opacity on-change on-start-drag on-finish-drag]}]
(let [canvas-ref (mf/use-ref nil)
canvas-side 192
{hue :h saturation :s value :v alpha :alpha} color
@@ -134,24 +134,21 @@
:style {"--hue-from" (dm/str "hsl(" h1 ", " (* s1 100) "%, " (* l1 100) "%)")
"--hue-to" (dm/str "hsl(" h2 ", " (* s2 100) "%, " (* l2 100) "%)")}}
[:div {:class (stl/css :handlers-wrapper)}
- [:& slider-selector {:type :value
- :vertical? true
- :reverse? false
- :value value
- :max-value 255
- :vertical true
- :on-change on-change-value
- :on-start-drag on-start-drag
- :on-finish-drag on-finish-drag}]
+ [:> slider-selector* {:type :value
+ :is-vertical true
+ :value value
+ :max-value 255
+ :on-change on-change-value
+ :on-start-drag on-start-drag
+ :on-finish-drag on-finish-drag}]
(when (not disable-opacity)
- [[:& slider-selector {:type :opacity
- :vertical? true
+ [:> slider-selector* {:type :opacity
+ :is-vertical true
:value alpha
:max-value 1
- :vertical true
:on-change on-change-opacity
:on-start-drag on-start-drag
- :on-finish-drag on-finish-drag}]])]
+ :on-finish-drag on-finish-drag}])]
[:div {:class (stl/css :hue-wheel-wrapper)}
[:canvas {:class (stl/css :hue-wheel)
diff --git a/frontend/src/app/main/ui/workspace/colorpicker/hsva.cljs b/frontend/src/app/main/ui/workspace/colorpicker/hsva.cljs
index 9a7d240f55..807d976314 100644
--- a/frontend/src/app/main/ui/workspace/colorpicker/hsva.cljs
+++ b/frontend/src/app/main/ui/workspace/colorpicker/hsva.cljs
@@ -8,10 +8,10 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.types.color :as cc]
- [app.main.ui.workspace.colorpicker.slider-selector :refer [slider-selector]]
+ [app.main.ui.workspace.colorpicker.slider-selector :refer [slider-selector*]]
[rumext.v2 :as mf]))
-(mf/defc hsva-selector [{:keys [color disable-opacity on-change on-start-drag on-finish-drag]}]
+(mf/defc hsva-selector* [{:keys [color disable-opacity on-change on-start-drag on-finish-drag]}]
(let [{hue :h saturation :s value :v alpha :alpha} color
handle-change-slider (fn [key]
(fn [new-value]
@@ -26,7 +26,7 @@
[:div {:class (stl/css :hsva-selector)}
[:div {:class (stl/css :hsva-row)}
[:span {:class (stl/css :hsva-selector-label)} "H"]
- [:& slider-selector
+ [:> slider-selector*
{:class (stl/css :hsva-bar)
:type :hue
:max-value 360
@@ -36,7 +36,7 @@
:on-finish-drag on-finish-drag}]]
[:div {:class (stl/css :hsva-row)}
[:span {:class (stl/css :hsva-selector-label)} "S"]
- [:& slider-selector
+ [:> slider-selector*
{:class (stl/css :hsva-bar)
:type :saturation
:max-value 1
@@ -46,10 +46,9 @@
:on-finish-drag on-finish-drag}]]
[:div {:class (stl/css :hsva-row)}
[:span {:class (stl/css :hsva-selector-label)} "V"]
- [:& slider-selector
+ [:> slider-selector*
{:class (stl/css :hsva-bar)
:type :value
- :reverse? false
:max-value 255
:value value
:on-change (handle-change-slider :v)
@@ -58,7 +57,7 @@
(when (not disable-opacity)
[:div {:class (stl/css :hsva-row)}
[:span {:class (stl/css :hsva-selector-label)} "A"]
- [:& slider-selector
+ [:> slider-selector*
{:class (stl/css :hsva-bar)
:type :opacity
:max-value 1
diff --git a/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs b/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs
index e565a05754..baf82c0f79 100644
--- a/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs
+++ b/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs
@@ -27,7 +27,7 @@
[potok.v2.core :as ptk]
[rumext.v2 :as mf]))
-(mf/defc libraries
+(mf/defc libraries*
[{:keys [state on-select-color on-add-library-color disable-gradient disable-opacity disable-image]}]
(let [selected* (h/use-shared-state mdc/colorpicker-selected-broadcast-key :recent)
selected (deref selected*)
diff --git a/frontend/src/app/main/ui/workspace/colorpicker/ramp.cljs b/frontend/src/app/main/ui/workspace/colorpicker/ramp.cljs
index 154587fc7b..68eab222cd 100644
--- a/frontend/src/app/main/ui/workspace/colorpicker/ramp.cljs
+++ b/frontend/src/app/main/ui/workspace/colorpicker/ramp.cljs
@@ -11,11 +11,11 @@
[app.common.math :as mth]
[app.common.types.color :as cc]
[app.main.ui.components.color-bullet :as cb]
- [app.main.ui.workspace.colorpicker.slider-selector :refer [slider-selector]]
+ [app.main.ui.workspace.colorpicker.slider-selector :refer [slider-selector*]]
[app.util.dom :as dom]
[rumext.v2 :as mf]))
-(mf/defc value-saturation-selector [{:keys [saturation value on-change on-start-drag on-finish-drag]}]
+(mf/defc value-saturation-selector* [{:keys [saturation value on-change on-start-drag on-finish-drag]}]
(let [dragging?* (mf/use-state false)
dragging? (deref dragging?*)
calculate-pos
@@ -127,7 +127,7 @@
(reset! internal-color* (enrich-color-map color))))
[:*
- [:& value-saturation-selector
+ [:> value-saturation-selector*
{:hue h
:saturation s
:value v
@@ -140,17 +140,17 @@
[:& cb/color-bullet {:color bullet-color
:area true}]
[:div {:class (stl/css :sliders-wrapper)}
- [:& slider-selector {:type :hue
- :max-value 360
- :value h
- :on-change on-change-hue
- :on-start-drag on-start-drag
- :on-finish-drag on-finish-drag}]
+ [:> slider-selector* {:type :hue
+ :max-value 360
+ :value h
+ :on-change on-change-hue
+ :on-start-drag on-start-drag
+ :on-finish-drag on-finish-drag}]
(when (not disable-opacity)
- [:& slider-selector {:type :opacity
- :max-value 1
- :value alpha
- :on-change on-change-opacity
- :on-start-drag on-start-drag
- :on-finish-drag on-finish-drag}])]]]))
+ [:> slider-selector* {:type :opacity
+ :max-value 1
+ :value alpha
+ :on-change on-change-opacity
+ :on-start-drag on-start-drag
+ :on-finish-drag on-finish-drag}])]]]))
diff --git a/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs b/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs
index c69acfd703..f125b6368b 100644
--- a/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs
+++ b/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs
@@ -13,8 +13,8 @@
[app.util.object :as obj]
[rumext.v2 :as mf]))
-(mf/defc slider-selector
- [{:keys [value class min-value max-value vertical? reverse? on-change on-start-drag on-finish-drag type]}]
+(mf/defc slider-selector*
+ [{:keys [value class min-value max-value is-vertical on-change on-start-drag on-finish-drag type]}]
(let [min-value (or min-value 0)
max-value (or max-value 1)
dragging? (mf/use-state false)
@@ -42,17 +42,14 @@
(when on-change
(let [{:keys [left right top bottom]} (-> ev dom/get-target dom/get-bounding-rect)
{:keys [x y]} (-> ev dom/get-client-position)
- unit-value (if vertical?
+ unit-value (if is-vertical
(mth/clamp (/ (- bottom y) (- bottom top)) 0 1)
(mth/clamp (/ (- x left) (- right left)) 0 1))
- unit-value (if reverse?
- (mth/abs (- unit-value 1.0))
- unit-value)
value (+ min-value (* unit-value (- max-value min-value)))]
(on-change value))))]
- [:div {:class (dm/str class (stl/css-case :vertical vertical?
+ [:div {:class (dm/str class (stl/css-case :vertical is-vertical
:slider-selector true
:hue (= type :hue)
:opacity (= type :opacity)
@@ -65,14 +62,10 @@
:on-pointer-move #(when @dragging? (calculate-pos %))}
(let [value-percent (* (/ (- value min-value)
(- max-value min-value)) 100)
-
- value-percent (if reverse?
- (mth/abs (- value-percent 100))
- value-percent)
value-percent-str (str value-percent "%")
style-common #js {:pointerEvents "none"}
style-horizontal (obj/merge! #js {:left value-percent-str} style-common)
style-vertical (obj/merge! #js {:bottom value-percent-str} style-common)]
[:div {:class (stl/css :handler)
- :style (if vertical? style-vertical style-horizontal)}])]))
+ :style (if is-vertical style-vertical style-horizontal)}])]))
diff --git a/frontend/src/app/main/ui/workspace/coordinates.cljs b/frontend/src/app/main/ui/workspace/coordinates.cljs
index 5ad5dfc572..05de77500a 100644
--- a/frontend/src/app/main/ui/workspace/coordinates.cljs
+++ b/frontend/src/app/main/ui/workspace/coordinates.cljs
@@ -11,10 +11,10 @@
[app.main.ui.hooks :as hooks]
[rumext.v2 :as mf]))
-(mf/defc coordinates
- [{:keys [colorpalette?]}]
+(mf/defc coordinates*
+ [{:keys [is-colorpalette]}]
(let [coords (hooks/use-rxsub ms/mouse-position)]
- [:div {:class (stl/css-case :container-color-palette-open colorpalette?
+ [:div {:class (stl/css-case :container-color-palette-open is-colorpalette
:container true)}
[:span {:alt "x" :class (stl/css :coordinate)}
(str "X: " (:x coords "-"))]
diff --git a/frontend/src/app/main/ui/workspace/palette.cljs b/frontend/src/app/main/ui/workspace/palette.cljs
index 80c396989e..74f5d6901f 100644
--- a/frontend/src/app/main/ui/workspace/palette.cljs
+++ b/frontend/src/app/main/ui/workspace/palette.cljs
@@ -23,7 +23,7 @@
[app.main.ui.icons :as deprecated-icon]
[app.main.ui.workspace.color-palette :refer [color-palette*]]
[app.main.ui.workspace.color-palette-ctx-menu :refer [color-palette-ctx-menu*]]
- [app.main.ui.workspace.text-palette :refer [text-palette]]
+ [app.main.ui.workspace.text-palette :refer [text-palette*]]
[app.main.ui.workspace.text-palette-ctx-menu :refer [text-palette-ctx-menu]]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
@@ -207,9 +207,9 @@
:close-menu on-close-menu
:on-select-palette on-select-text-palette-menu
:selected selected-text}]
- [:& text-palette {:size size
- :selected selected-text
- :width vport-width}]])
+ [:> text-palette* {:size size
+ :selected selected-text
+ :width vport-width}]])
(when color-palette?
[:*
[:> color-palette-ctx-menu* {:show show-menu?
diff --git a/frontend/src/app/main/ui/workspace/presence.cljs b/frontend/src/app/main/ui/workspace/presence.cljs
index 38302ed536..d7bcda046f 100644
--- a/frontend/src/app/main/ui/workspace/presence.cljs
+++ b/frontend/src/app/main/ui/workspace/presence.cljs
@@ -29,7 +29,7 @@
:style {:background-color color}
:src (cfg/resolve-profile-photo-url profile)}]]))
-(mf/defc active-sessions
+(mf/defc active-sessions*
{::mf/memo true}
[]
(let [profiles (mf/deref refs/profiles)
diff --git a/frontend/src/app/main/ui/workspace/right_header.cljs b/frontend/src/app/main/ui/workspace/right_header.cljs
index addbfc251e..c719aae349 100644
--- a/frontend/src/app/main/ui/workspace/right_header.cljs
+++ b/frontend/src/app/main/ui/workspace/right_header.cljs
@@ -25,7 +25,7 @@
[app.main.ui.exports.assets :refer [progress-widget]]
[app.main.ui.formats :as fmt]
[app.main.ui.icons :as deprecated-icon]
- [app.main.ui.workspace.presence :refer [active-sessions]]
+ [app.main.ui.workspace.presence :refer [active-sessions*]]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[okulary.core :as l]
@@ -196,7 +196,7 @@
[:div {:class (stl/css :workspace-header-right)}
[:div {:class (stl/css :users-section)}
- [:& active-sessions]]
+ [:> active-sessions*]]
[:& progress-widget]
diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs
index 118a1f5a25..55f81f1600 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs
@@ -16,6 +16,7 @@
[app.main.data.workspace.shortcuts :as sc]
[app.main.data.workspace.texts :as dwt]
[app.main.data.workspace.tokens.application :as dwta]
+ [app.main.data.workspace.texts-v3 :as dwt-v3]
[app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.features :as features]
@@ -333,9 +334,12 @@
(mf/use-fn
(mf/deps values)
(fn [ids attrs]
- (st/emit! (dwt/save-font (-> (merge (txt/get-default-text-attrs) values attrs)
- (select-keys txt/text-node-attrs)))
- (dwt/update-all-attrs ids attrs))))
+ (let [updated-attrs (-> (merge (txt/get-default-text-attrs) values attrs)
+ (select-keys txt/text-node-attrs))]
+ (when (features/active-feature? @st/state "text-editor-wasm/v1")
+ (st/emit! (dwt-v3/v3-update-text-editor-styles (first ids) attrs)))
+ (st/emit! (dwt/save-font updated-attrs)
+ (dwt/update-all-attrs ids attrs)))))
on-change
(mf/use-fn
diff --git a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs
index 3c683a80ef..a8509dbe81 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs
@@ -30,6 +30,7 @@
[app.util.timers :as timers]
[cuerdas.core :as str]
[okulary.core :as l]
+ [promesa.core :as p]
[rumext.v2 :as mf]))
;; FIXME: can we unify this two refs in one?
@@ -77,18 +78,21 @@
(mf/deps id current-page-id is-separator?)
(fn []
(when-not is-separator?
- ;; For the wasm renderer, apply a blur effect to the viewport canvas
- ;; when we navigate to a different page.
+ ;; WASM page transitions:
+ ;; - Capture the current page (A) once
+ ;; - Show a blurred snapshot while the target page (B/C/...) renders
+ ;; - If the user clicks again during the transition, keep showing the original (A) snapshot
(if (and (features/active-feature? @st/state "render-wasm/v1")
(not= id current-page-id))
(do
- (wasm.api/capture-canvas-pixels)
- (wasm.api/apply-canvas-blur)
- ;; NOTE: it seems we need two RAF so the blur is actually applied and visible
- ;; in the canvas :(
- (timers/raf
- (fn []
- (timers/raf navigate-fn))))
+ (-> (wasm.api/apply-canvas-blur)
+ (p/finally
+ (fn []
+ ;; NOTE: it seems we need two RAF so the blur is actually applied and visible
+ ;; in the canvas :(
+ (timers/raf
+ (fn []
+ (timers/raf navigate-fn)))))))
(navigate-fn)))))
on-delete
diff --git a/frontend/src/app/main/ui/workspace/text_palette.cljs b/frontend/src/app/main/ui/workspace/text_palette.cljs
index 5325102a05..7599a1baed 100644
--- a/frontend/src/app/main/ui/workspace/text_palette.cljs
+++ b/frontend/src/app/main/ui/workspace/text_palette.cljs
@@ -22,8 +22,9 @@
[potok.v2.core :as ptk]
[rumext.v2 :as mf]))
-(mf/defc typography-item
- [{:keys [file-id selected-ids typography name-only? size current-file-id]}]
+(mf/defc typography-item*
+ {::mf/private true}
+ [{:keys [file-id selected-ids typography size current-file-id]}]
(let [font-data (f/get-font-data (:font-id typography))
font-variant-id (:font-variant-id typography)
variant-data (->> font-data :variants (d/seek #(= (:id %) font-variant-id)))
@@ -60,14 +61,12 @@
:font-weight (:font-weight typography)
:font-style (:font-style typography)}}
(:name typography)]
- (when-not name-only?
- [:*
- [:div {:class (stl/css :typography-font)}
- (:name font-data)]
- [:div {:class (stl/css :typography-data)}
- (str (:font-size typography) "px | " (:name variant-data))]])]))
+ [:div {:class (stl/css :typography-font)}
+ (:name font-data)]
+ [:div {:class (stl/css :typography-data)}
+ (str (:font-size typography) "px | " (or (:name variant-data) "--"))]]))
-(mf/defc palette
+(mf/defc palette*
[{:keys [selected selected-ids current-file-id file-typographies libraries size width]}]
(let [file-id
(case selected
@@ -165,7 +164,7 @@
:max-width (str width "px")
:right (str (* offset-step offset) "px")}}
(for [[idx item] (map-indexed vector current-typographies)]
- [:& typography-item
+ [:> typography-item*
{:key idx
:file-id file-id
:current-file-id current-file-id
@@ -178,7 +177,7 @@
:disabled (= offset max-offset)
:on-click on-right-arrow-click} deprecated-icon/arrow])]))
-(mf/defc text-palette
+(mf/defc text-palette*
{::mf/wrap [mf/memo]}
[{:keys [size width selected] :as props}]
(let [selected-ids (mf/deref refs/selected-shapes)
@@ -189,10 +188,10 @@
file-typographies (mf/deref refs/workspace-file-typography)
libraries (mf/deref refs/files)
current-file-id (mf/use-ctx ctx/current-file-id)]
- [:& palette {:current-file-id current-file-id
- :selected-ids selected-ids
- :file-typographies file-typographies
- :libraries libraries
- :width width
- :selected selected
- :size size}]))
+ [:> palette* {:current-file-id current-file-id
+ :selected-ids selected-ids
+ :file-typographies file-typographies
+ :libraries libraries
+ :width width
+ :selected selected
+ :size size}]))
diff --git a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs
index d87398ec55..5ce0b1c16c 100644
--- a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs
+++ b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs
@@ -522,7 +522,7 @@
dropdown-direction-change* (mf/use-ref 0)
top (+ (get-in mdata [:position :y]) 5)
left (+ (get-in mdata [:position :x]) 5)
- container (hooks/use-portal-container)]
+ container (hooks/use-portal-container :popup)]
(mf/use-effect
(mf/deps is-open?)
diff --git a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs
index 6b49e7df0a..574376ed2b 100644
--- a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs
+++ b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs
@@ -37,6 +37,8 @@
dropdown-direction-change* (mf/use-ref 0)
top (+ (get-in mdata [:position :y]) 5)
left (+ (get-in mdata [:position :x]) 5)
+ container (hooks/use-portal-container :popup)
+
rename-node (mf/use-fn
(mf/deps mdata on-rename-node)
(fn []
@@ -44,6 +46,7 @@
type (get mdata :type)]
(when node
(on-rename-node node type)))))
+
duplicate-node (mf/use-fn
(mf/deps mdata on-duplicate-node)
(fn []
@@ -52,7 +55,6 @@
(when node
(on-duplicate-node node type)))))
- container (hooks/use-portal-container)
delete-node (mf/use-fn
(mf/deps mdata)
(fn []
@@ -74,7 +76,7 @@
(mf/set-ref-val! dropdown-direction-change* (inc (mf/ref-val dropdown-direction-change*)))))))
;; FIXME: perf optimization
-
+
(when is-open?
(mf/portal
(mf/html
diff --git a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs
index a8687c9719..d688588e2f 100644
--- a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs
+++ b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs
@@ -114,7 +114,7 @@
:is-open? true
:rect rect))))))
- container (hooks/use-portal-container)]
+ container (hooks/use-portal-container :popup)]
[:div {:on-click on-open-dropdown
:disabled (not can-edit?)
diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs
index 5bf6037c1a..3b75d406bb 100644
--- a/frontend/src/app/main/ui/workspace/viewport.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport.cljs
@@ -578,7 +578,7 @@
:tool drawing-tool}])
(when show-grids?
- [:& frame-grid/frame-grid
+ [:> frame-grid/frame-grid*
{:zoom zoom
:selected selected
:transform transform
@@ -589,7 +589,7 @@
:zoom zoom}])
(when show-snap-points?
- [:& snap-points/snap-points
+ [:> snap-points/snap-points*
{:layout layout
:transform transform
:drawing drawing-obj
@@ -690,13 +690,13 @@
:disabled (or drawing-tool @space?)}])))
(when show-prototypes?
- [:& interactions/interactions
+ [:> interactions/interactions*
{:selected selected
:page-id page-id
:zoom zoom
:objects objects-modified
:current-transform transform
- :hover-disabled? hover-disabled?}])])
+ :is-hover-disabled hover-disabled?}])])
(when show-gradient-handlers?
[:> gradients/gradient-handlers*
@@ -727,7 +727,7 @@
:view-only true}]))]
[:g.scrollbar-wrapper {:clipPath "url(#clip-handlers)"}
- [:& scroll-bars/viewport-scrollbars
+ [:> scroll-bars/viewport-scrollbars*
{:objects base-objects
:zoom zoom
:vbox vbox
diff --git a/frontend/src/app/main/ui/workspace/viewport/actions.cljs b/frontend/src/app/main/ui/workspace/viewport/actions.cljs
index f0711768b2..c3c26ab6a5 100644
--- a/frontend/src/app/main/ui/workspace/viewport/actions.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport/actions.cljs
@@ -23,7 +23,6 @@
[app.main.store :as st]
[app.main.ui.workspace.sidebar.assets.components :as wsac]
[app.main.ui.workspace.viewport.viewport-ref :as uwvv]
- [app.render-wasm.api :as wasm.api]
[app.util.dom :as dom]
[app.util.dom.dnd :as dnd]
[app.util.dom.normalize-wheel :as nw]
@@ -280,7 +279,6 @@
(.releasePointerCapture target (.-pointerId event)))
(let [native-event (dom/event->native-event event)
- off-pt (dom/get-offset-position native-event)
ctrl? (kbd/ctrl? native-event)
shift? (kbd/shift? native-event)
alt? (kbd/alt? native-event)
@@ -290,10 +288,7 @@
middle-click? (= 2 (.-which native-event))]
(when left-click?
- (st/emit! (mse/->MouseEvent :up ctrl? shift? alt? meta?))
-
- (when (wasm.api/text-editor-has-focus?)
- (wasm.api/text-editor-pointer-up (.-x off-pt) (.-y off-pt))))
+ (st/emit! (mse/->MouseEvent :up ctrl? shift? alt? meta?)))
(when middle-click?
(dom/prevent-default native-event)
@@ -354,9 +349,7 @@
(let [last-position (mf/use-var nil)]
(mf/use-fn
(fn [event]
- (let [native-event (unchecked-get event "nativeEvent")
- off-pt (dom/get-offset-position native-event)
- raw-pt (dom/get-client-position event)
+ (let [raw-pt (dom/get-client-position event)
pt (uwvv/point->viewport raw-pt)
;; We calculate the delta because Safari's MouseEvent.movementX/Y drop
@@ -365,13 +358,6 @@
(gpt/subtract raw-pt @last-position)
(gpt/point 0 0))]
- ;; IMPORTANT! This function, right now it's called on EVERY pointermove. I think
- ;; in the future (when we handle the UI in the render) should be better to
- ;; have a "wasm.api/pointer-move" function that works as an entry point for
- ;; all the pointer-move events.
- (when (wasm.api/text-editor-has-focus?)
- (wasm.api/text-editor-pointer-move (.-x off-pt) (.-y off-pt)))
-
(rx/push! move-stream pt)
(reset! last-position raw-pt)
(st/emit! (mse/->PointerEvent :delta delta
diff --git a/frontend/src/app/main/ui/workspace/viewport/frame_grid.cljs b/frontend/src/app/main/ui/workspace/viewport/frame_grid.cljs
index 61246ea705..5fafd22a6e 100644
--- a/frontend/src/app/main/ui/workspace/viewport/frame_grid.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport/frame_grid.cljs
@@ -17,7 +17,7 @@
[app.main.refs :as refs]
[rumext.v2 :as mf]))
-(mf/defc square-grid [{:keys [frame zoom grid] :as props}]
+(mf/defc square-grid* [{:keys [frame zoom grid]}]
(let [grid-id (mf/use-memo #(uuid/next))
{:keys [size] :as params} (-> grid :params)
{color-value :color color-opacity :opacity} (-> grid :params :color)
@@ -45,7 +45,7 @@
:height (:height frame)
:fill (str "url(#" grid-id ")")}]]))
-(mf/defc layout-grid
+(mf/defc layout-grid*
[{:keys [key frame grid zoom]}]
(let [{color-value :color color-opacity :opacity} (-> grid :params :color)
;; Support for old color format
@@ -124,7 +124,7 @@
selrect
parents))
-(mf/defc grid-display-frame
+(mf/defc grid-display-frame*
{::mf/wrap [mf/memo]}
[{:keys [frame zoom transforming]}]
(let [frame-id (:id frame)
@@ -154,16 +154,16 @@
:zoom zoom
:grid grid}]
(case (:type grid)
- :square [:> square-grid props]
- :column [:> layout-grid props]
- :row [:> layout-grid props])))])))
+ :square [:> square-grid* props]
+ :column [:> layout-grid* props]
+ :row [:> layout-grid* props])))])))
(defn has-grid?
[{:keys [grids]}]
(and (some? grids)
(d/not-empty? (->> grids (filter :display)))))
-(mf/defc frame-grid
+(mf/defc frame-grid*
{::mf/wrap [mf/memo]}
[{:keys [zoom transform selected focus]}]
(let [frames (->> (mf/deref refs/workspace-frames)
@@ -175,7 +175,7 @@
(when (and #_(not (is-transform? frame))
(not (ctst/rotated-frame? frame))
(or (empty? focus) (contains? focus (:id frame))))
- [:& grid-display-frame {:key (str "grid-" (:id frame))
- :zoom zoom
- :frame frame
- :transforming transforming}]))]))
+ [:> grid-display-frame* {:key (str "grid-" (:id frame))
+ :zoom zoom
+ :frame frame
+ :transforming transforming}]))]))
diff --git a/frontend/src/app/main/ui/workspace/viewport/gradients.cljs b/frontend/src/app/main/ui/workspace/viewport/gradients.cljs
index 9573efab01..cd2623fd47 100644
--- a/frontend/src/app/main/ui/workspace/viewport/gradients.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport/gradients.cljs
@@ -43,7 +43,7 @@
(def gradient-endpoint-radius-selected 6)
(def gradient-endpoint-radius-handler 20)
-(mf/defc shadow [{:keys [id offset]}]
+(mf/defc shadow* [{:keys [id offset]}]
[:filter {:id id
:x "-10%"
:y "-10%"
@@ -61,7 +61,7 @@
(def checkerboard "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAIAAAC0tAIdAAACvUlEQVQoFQGyAk39AeLi4gAAAAAAAB0dHQAAAAAAAOPj4wAAAAAAAB0dHQAAAAAAAOPj4wAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB////AAAAAAAA4+PjAAAAAAAAHR0dAAAAAAAA4+PjAAAAAAAAHR0dAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATj4+MAAAAAAAAdHR0AAAAAAADj4+MAAAAAAAAdHR0AAAAAAADj4+MAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAjScaa0cU7nIAAAAASUVORK5CYII=")
-(mf/defc gradient-color-handler
+(mf/defc gradient-color-handler*
[{:keys [zoom point color angle selected index
on-click on-pointer-down on-pointer-up on-pointer-move on-lost-pointer-capture]}]
[:g {:filter "url(#gradient-drop-shadow)"
@@ -118,7 +118,7 @@
:r (/ 2 zoom)
:fill "var(--app-white)"}]])
-(mf/defc gradient-handler-transformed
+(mf/defc gradient-handler-transformed*
[{:keys [from-p
to-p
width-p
@@ -270,7 +270,7 @@
[:g.gradient-handlers {:pointer-events "none"}
[:defs
- [:& shadow {:id "gradient-drop-shadow" :offset (/ 2 zoom)}]]
+ [:> shadow* {:id "gradient-drop-shadow" :offset (/ 2 zoom)}]]
(let [lv (-> (gpt/to-vec from-p to-p)
(gpt/unit))
@@ -425,7 +425,7 @@
(-> (gpt/to-vec from-p to-p)
(gpt/scale (:offset stop))))]
- [:& gradient-color-handler
+ [:> gradient-color-handler*
{:key index
:selected (= editing index)
:zoom zoom
@@ -505,7 +505,7 @@
(when (and norm-dist (d/num? norm-dist))
(change! {:width norm-dist})))))]
- [:& gradient-handler-transformed
+ [:> gradient-handler-transformed*
{:editing editing
:from-p from-p
:to-p to-p
diff --git a/frontend/src/app/main/ui/workspace/viewport/interactions.cljs b/frontend/src/app/main/ui/workspace/viewport/interactions.cljs
index af6b1e58aa..8b1ae15552 100644
--- a/frontend/src/app/main/ui/workspace/viewport/interactions.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport/interactions.cljs
@@ -100,8 +100,8 @@
[orig-pos orig-x orig-y dest-pos dest-x dest-y]))
-(mf/defc interaction-marker
- [{:keys [x y stroke action-type arrow-dir zoom] :as props}]
+(mf/defc interaction-marker*
+ [{:keys [x y stroke action-type arrow-dir zoom]}]
(let [icon-pdata (case action-type
:navigate (case arrow-dir
:right "M -6.5 0 L 5.5 0 M 6.715 0.715 L -0.5 -6.5 M 6.715 -0.715 L -0.365 6.635"
@@ -142,8 +142,8 @@
"translate(" (* zoom x) ", " (* zoom y) ")")}])]))
-(mf/defc interaction-path
- [{:keys [index level orig-shape dest-shape dest-point selected selected? action-type zoom] :as props}]
+(mf/defc interaction-path*
+ [{:keys [index level orig-shape dest-shape dest-point selected is-selected action-type zoom]}]
(let [[orig-pos orig-x orig-y dest-pos dest-x dest-y]
(cond
dest-shape
@@ -168,11 +168,11 @@
incoming? (and (some? dest-shape)
(contains? selected (:id dest-shape)))
stroke-color (cond
- selected? outgoing-link-color
+ is-selected outgoing-link-color
incoming? incoming-link-color
:else neutral-link-color)]
- (if-not selected?
+ (if-not is-selected
[:g {:on-pointer-down #(on-pointer-down % index orig-shape)}
[:path {:stroke stroke-color
:fill "none"
@@ -180,13 +180,13 @@
:stroke-width (/ 2 zoom)
:d pdata}]
(when (not dest-shape)
- [:& interaction-marker {:index index
- :x dest-x
- :y dest-y
- :stroke stroke-color
- :action-type action-type
- :arrow-dir arrow-dir
- :zoom zoom}])]
+ [:> interaction-marker* {:index index
+ :x dest-x
+ :y dest-y
+ :stroke stroke-color
+ :action-type action-type
+ :arrow-dir arrow-dir
+ :zoom zoom}])]
[:g {:on-pointer-down #(on-pointer-down % index orig-shape)}
[:path {:stroke stroke-color
@@ -200,36 +200,36 @@
:shape dest-shape
:color stroke-color}])
- [:& interaction-marker {:index index
- :x orig-x
- :y orig-y
- :stroke stroke-color
- :zoom zoom}]
- [:& interaction-marker {:index index
- :x dest-x
- :y dest-y
- :stroke stroke-color
- :action-type action-type
- :arrow-dir arrow-dir
- :zoom zoom}]])))
+ [:> interaction-marker* {:index index
+ :x orig-x
+ :y orig-y
+ :stroke stroke-color
+ :zoom zoom}]
+ [:> interaction-marker* {:index index
+ :x dest-x
+ :y dest-y
+ :stroke stroke-color
+ :action-type action-type
+ :arrow-dir arrow-dir
+ :zoom zoom}]])))
-(mf/defc interaction-handle
- [{:keys [index shape zoom] :as props}]
+(mf/defc interaction-handle*
+ [{:keys [index shape zoom]}]
(let [shape-rect (:selrect shape)
handle-x (+ (:x shape-rect) (:width shape-rect))
handle-y (+ (:y shape-rect) (/ (:height shape-rect) 2))]
[:g {:on-pointer-down #(on-pointer-down % index shape)}
- [:& interaction-marker {:x handle-x
- :y handle-y
- :stroke "var(--color-accent-tertiary)"
- :action-type :navigate
- :arrow-dir :right
- :zoom zoom}]]))
+ [:> interaction-marker* {:x handle-x
+ :y handle-y
+ :stroke "var(--color-accent-tertiary)"
+ :action-type :navigate
+ :arrow-dir :right
+ :zoom zoom}]]))
-(mf/defc overlay-marker
- [{:keys [page-id index orig-shape dest-shape position objects hover-disabled?] :as props}]
+(mf/defc overlay-marker*
+ [{:keys [page-id index orig-shape dest-shape position objects is-hover-disabled]}]
(let [start-move-position
(fn [_]
(st/emit! (dw/start-move-overlay-pos index)))]
@@ -260,8 +260,8 @@
(some? thumbnail-data)
(assoc :thumbnail thumbnail-data))]
[:g {:on-pointer-down start-move-position
- :on-pointer-enter #(reset! hover-disabled? true)
- :on-pointer-leave #(reset! hover-disabled? false)}
+ :on-pointer-enter #(reset! is-hover-disabled true)
+ :on-pointer-leave #(reset! is-hover-disabled false)}
[:g {:transform (gmt/translate-matrix (gpt/point (- marker-x dest-x) (- marker-y dest-y)))}
[:& (mf/provider muc/render-thumbnails) {:value true}
[:& (mf/provider embed/context) {:value false}
@@ -283,8 +283,8 @@
:r 8
:fill "var(--color-accent-tertiary)"}]]))))
-(mf/defc interactions
- [{:keys [current-transform objects zoom selected hover-disabled? page-id] :as props}]
+(mf/defc interactions*
+ [{:keys [current-transform objects zoom selected is-hover-disabled page-id]}]
(let [active-shapes (into []
(comp (filter #(seq (:interactions %))))
(vals objects))
@@ -315,26 +315,26 @@
selected? (contains? selected (:id shape))
level (calc-level index (:interactions shape))]
(when-not selected?
- [:& interaction-path {:key (dm/str "non-selected-" (:id shape) "-" index)
- :index index
- :level level
- :orig-shape shape
- :dest-shape dest-shape
- :selected selected
- :selected? false
- :action-type (:action-type interaction)
- :zoom zoom}]))))]
+ [:> interaction-path* {:key (dm/str "non-selected-" (:id shape) "-" index)
+ :index index
+ :level level
+ :orig-shape shape
+ :dest-shape dest-shape
+ :selected selected
+ :is-selected false
+ :action-type (:action-type interaction)
+ :zoom zoom}]))))]
[:g.selected
(when (and draw-interaction-to first-selected)
- [:& interaction-path {:key "interactive"
- :index nil
- :orig-shape first-selected
- :dest-point draw-interaction-to
- :dest-shape draw-interaction-to-frame
- :selected? true
- :action-type :navigate
- :zoom zoom}])
+ [:> interaction-path* {:key "interactive"
+ :index nil
+ :orig-shape first-selected
+ :dest-point draw-interaction-to
+ :dest-shape draw-interaction-to-frame
+ :is-selected true
+ :action-type :navigate
+ :zoom zoom}])
(for [shape selected-shapes]
(if (seq (:interactions shape))
(for [[index interaction] (d/enumerate (:interactions shape))]
@@ -343,38 +343,38 @@
(get objects (:destination interaction)))
level (calc-level index (:interactions shape))]
[:g {:key (dm/str "interaction-path-" (:id shape) "-" index)}
- [:& interaction-path {:index index
- :level level
- :orig-shape shape
- :dest-shape dest-shape
- :selected selected
- :selected? true
- :action-type (:action-type interaction)
- :zoom zoom}]
+ [:> interaction-path* {:index index
+ :level level
+ :orig-shape shape
+ :dest-shape dest-shape
+ :selected selected
+ :is-selected true
+ :action-type (:action-type interaction)
+ :zoom zoom}]
(when (and (or (= (:action-type interaction) :open-overlay)
(= (:action-type interaction) :toggle-overlay))
(= (:overlay-pos-type interaction) :manual))
(if (and (some? move-overlay-to)
(= move-overlay-index index))
- [:& overlay-marker {:page-id page-id
- :index index
- :orig-shape shape
- :dest-shape dest-shape
- :position move-overlay-to
- :objects objects
- :hover-disabled? hover-disabled?}]
- [:& overlay-marker {:page-id page-id
- :index index
- :orig-shape shape
- :dest-shape dest-shape
- :position (:overlay-position interaction)
- :objects objects
- :hover-disabled? hover-disabled?}]))])))
+ [:> overlay-marker* {:page-id page-id
+ :index index
+ :orig-shape shape
+ :dest-shape dest-shape
+ :position move-overlay-to
+ :objects objects
+ :is-hover-disabled is-hover-disabled}]
+ [:> overlay-marker* {:page-id page-id
+ :index index
+ :orig-shape shape
+ :dest-shape dest-shape
+ :position (:overlay-position interaction)
+ :objects objects
+ :is-hover-disabled is-hover-disabled}]))])))
(when (and shape
(not (cfh/unframed-shape? shape))
(not (#{:move :rotate} current-transform)))
- [:& interaction-handle {:key (:id shape)
- :index nil
- :shape shape
- :selected selected
- :zoom zoom}])))]]))
+ [:> interaction-handle* {:key (:id shape)
+ :index nil
+ :shape shape
+ :selected selected
+ :zoom zoom}])))]]))
diff --git a/frontend/src/app/main/ui/workspace/viewport/rulers.cljs b/frontend/src/app/main/ui/workspace/viewport/rulers.cljs
index bec8eaf330..b23e71ade2 100644
--- a/frontend/src/app/main/ui/workspace/viewport/rulers.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport/rulers.cljs
@@ -142,7 +142,7 @@
"Z"))
-(mf/defc rulers-text
+(mf/defc rulers-text*
"Draws the text for the rulers in a specific axis"
[{:keys [vbox step offset axis zoom-inverse]}]
(let [clip-id (str "clip-ruler-" (d/name axis))
@@ -186,13 +186,13 @@
:style {:stroke font-color
:stroke-width rulers-width}}]]))]))
-(mf/defc viewport-frame
- [{:keys [show-rulers? zoom zoom-inverse vbox offset-x offset-y]}]
-
+(mf/defc viewport-frame*
+ {::mf/private true}
+ [{:keys [show-rulers zoom zoom-inverse vbox offset-x offset-y]}]
(let [{:keys [width height] x1 :x y1 :y} vbox
x2 (+ x1 width)
y2 (+ y1 height)
- bw (if show-rulers? (* ruler-area-size zoom-inverse) 0)
+ bw (if show-rulers (* ruler-area-size zoom-inverse) 0)
br (/ canvas-border-radius zoom)
bs (* 4 zoom-inverse)]
[:*
@@ -214,13 +214,13 @@
:fill-rule "evenodd"
:fill rulers-background}]]
- (when show-rulers?
+ (when show-rulers
(let [step (calculate-step-size zoom)]
[:g.viewport-frame-rulers
- [:& rulers-text {:vbox vbox :offset offset-x :step step :zoom-inverse zoom-inverse :axis :x}]
- [:& rulers-text {:vbox vbox :offset offset-y :step step :zoom-inverse zoom-inverse :axis :y}]]))]))
+ [:> rulers-text* {:vbox vbox :offset offset-x :step step :zoom-inverse zoom-inverse :axis :x}]
+ [:> rulers-text* {:vbox vbox :offset offset-y :step step :zoom-inverse zoom-inverse :axis :y}]]))]))
-(mf/defc selection-area
+(mf/defc selection-area*
[{:keys [vbox zoom-inverse selection-rect offset-x offset-y]}]
;; When using the format-number callls we consider if the guide is associated to a frame and we show the position relative to it with the offset
[:g.selection-area
@@ -332,8 +332,8 @@
(when (some? vbox)
[:g.viewport-frame {:pointer-events "none"}
- [:& viewport-frame
- {:show-rulers? show-rulers?
+ [:> viewport-frame*
+ {:show-rulers show-rulers?
:zoom zoom
:zoom-inverse zoom-inverse
:vbox vbox
@@ -341,7 +341,7 @@
:offset-y offset-y}]
(when (and show-rulers? (some? selection-rect))
- [:& selection-area
+ [:> selection-area*
{:zoom zoom
:zoom-inverse zoom-inverse
:vbox vbox
diff --git a/frontend/src/app/main/ui/workspace/viewport/scroll_bars.cljs b/frontend/src/app/main/ui/workspace/viewport/scroll_bars.cljs
index 87ee8d3656..3bf3e0a0e0 100644
--- a/frontend/src/app/main/ui/workspace/viewport/scroll_bars.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport/scroll_bars.cljs
@@ -26,7 +26,7 @@
(def other-height 100)
-(mf/defc viewport-scrollbars
+(mf/defc viewport-scrollbars*
{::mf/wrap [mf/memo]}
[{:keys [objects zoom vbox bottom-padding]}]
diff --git a/frontend/src/app/main/ui/workspace/viewport/snap_distances.cljs b/frontend/src/app/main/ui/workspace/viewport/snap_distances.cljs
index c903a19389..97b8c905f8 100644
--- a/frontend/src/app/main/ui/workspace/viewport/snap_distances.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport/snap_distances.cljs
@@ -50,7 +50,7 @@
(def pill-text-border-radius 4)
(def pill-text-padding 4)
-(mf/defc shape-distance-segment
+(mf/defc shape-distance-segment*
"Displays a segment between two selrects with the distance between them"
[{:keys [sr1 sr2 coord zoom]}]
(let [from-c (mth/min (get sr1 (if (= :x coord) :x2 :y2))
@@ -268,7 +268,7 @@
#(rx/push! subject [selrect selected frame]))
(for [[sr1 sr2] segments-to-display]
- [:& shape-distance-segment
+ [:> shape-distance-segment*
{:key (str/ffmt "%-%-%-%"
(dm/get-prop sr1 :x)
(dm/get-prop sr1 :y)
diff --git a/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs b/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs
index d65ae80f06..ccc0660e2b 100644
--- a/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs
@@ -25,7 +25,7 @@
;; (def ^:private line-opacity 1 )
;; (def ^:private line-width 2)
-(mf/defc snap-point
+(mf/defc snap-point*
[{:keys [point zoom]}]
(let [{:keys [x y]} point
cross-width (/ 3 zoom)]
@@ -41,7 +41,7 @@
:y2 (- y cross-width)
:style {:stroke line-color :stroke-width (str (/ line-width zoom))}}]]))
-(mf/defc snap-line
+(mf/defc snap-line*
[{:keys [snap point zoom]}]
[:line {:x1 (:x snap)
:y1 (:y snap)
@@ -50,8 +50,8 @@
:style {:stroke line-color :stroke-width (str (/ line-width zoom))}
:opacity line-opacity}])
-(defn get-snap
- [coord {:keys [shapes page-id remove-snap? zoom]}]
+(defn- get-snap
+ [coord shapes page-id remove-snap zoom]
(let [bounds (gsh/shapes->rect shapes)
frame-id (snap/snap-frame-id shapes)]
@@ -63,7 +63,7 @@
(rx/merge-map
(fn [[frame-id point]]
- (->> (snap/get-snap-points page-id frame-id remove-snap? zoom point coord)
+ (->> (snap/get-snap-points page-id frame-id remove-snap zoom point coord)
(rx/map #(mapcat second %))
(rx/map #(map :pt %))
(rx/map #(vector point % coord)))))
@@ -74,7 +74,7 @@
[coord]
(if (= coord :x) :y :x))
-(defn add-point-to-snaps
+(defn- add-point-to-snaps
[[point snaps coord]]
(let [normalize-coord #(assoc % coord (get point coord))]
(cons point (map normalize-coord snaps))))
@@ -100,8 +100,9 @@
(map (fn [[fixedv [minv maxv]]] [(hash-map coord fixedv (flip coord) minv)
(hash-map coord fixedv (flip coord) maxv)]))))
-(mf/defc snap-feedback
- [{:keys [shapes remove-snap? zoom modifiers] :as props}]
+(mf/defc snap-feedback*
+ {::mf/private true}
+ [{:keys [shapes remove-snap zoom modifiers page-id]}]
(let [state (mf/use-state [])
subject (mf/use-memo #(rx/subject))
@@ -116,9 +117,9 @@
(fn []
(let [sub (->> subject
(rx/switch-map
- (fn [props]
- (->> (get-snap :y props)
- (rx/combine-latest (get-snap :x props)))))
+ (fn [{:keys [shapes page-id remove-snap zoom]}]
+ (->> (get-snap :y shapes page-id remove-snap zoom)
+ (rx/combine-latest (get-snap :x shapes page-id remove-snap zoom)))))
(rx/map
(fn [result]
@@ -133,28 +134,31 @@
#(rx/dispose! sub))))
(mf/use-effect
- (mf/deps shapes remove-snap? modifiers)
+ (mf/deps shapes remove-snap modifiers page-id zoom)
(fn []
- (rx/push! subject props)))
+ (rx/push! subject {:shapes shapes
+ :page-id page-id
+ :remove-snap remove-snap
+ :zoom zoom})))
[:g.snap-feedback
(for [[from-point to-point] snap-lines]
- [:& snap-line {:key (str "line-" (:x from-point)
- "-" (:y from-point)
- "-" (:x to-point)
- "-" (:y to-point) "-")
- :snap from-point
- :point to-point
- :zoom zoom}])
+ [:> snap-line* {:key (str "line-" (:x from-point)
+ "-" (:y from-point)
+ "-" (:x to-point)
+ "-" (:y to-point) "-")
+ :snap from-point
+ :point to-point
+ :zoom zoom}])
(for [point snap-points]
- [:& snap-point {:key (str "point-" (:x point)
- "-" (:y point))
- :point point
- :zoom zoom}])]))
+ [:> snap-point* {:key (str "point-" (:x point)
+ "-" (:y point))
+ :point point
+ :zoom zoom}])]))
-(mf/defc snap-points
+(mf/defc snap-points*
{::mf/wrap [mf/memo]}
- [{:keys [layout zoom objects selected page-id drawing focus] :as props}]
+ [{:keys [layout zoom objects selected page-id drawing focus]}]
(dm/assert! (set? selected))
(let [shapes (into [] (keep (d/getf objects)) selected)
@@ -165,7 +169,7 @@
(mf/with-memo [layout filter-shapes objects focus]
(snap/make-remove-snap layout filter-shapes objects focus))
- remove-snap?
+ remove-snap
(mf/use-callback
(mf/deps remove-snap-base?)
(fn [{:keys [type grid] :as snap}]
@@ -176,8 +180,8 @@
shapes (if drawing [drawing] shapes)
frame-id (snap/snap-frame-id shapes)]
(when-not (ctl/any-layout? objects frame-id)
- [:& snap-feedback {:shapes shapes
- :page-id page-id
- :remove-snap? remove-snap?
- :zoom zoom}])))
+ [:> snap-feedback* {:shapes shapes
+ :page-id page-id
+ :remove-snap remove-snap
+ :zoom zoom}])))
diff --git a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs
index 7a25682209..a47897d2d6 100644
--- a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs
@@ -78,7 +78,7 @@
:stroke-width (/ 1 zoom)}}]))
-(mf/defc frame-title
+(mf/defc frame-title*
{::mf/wrap [mf/memo
#(mf/deferred % ts/raf)]
::mf/forward-ref true}
@@ -261,16 +261,16 @@
(not= id uuid/zero)
(or (dbg/enabled? :shape-titles) (= parent-id uuid/zero))
(or (empty? focus) (contains? focus id)))
- [:& frame-title {:key (dm/str "frame-title-" id)
- :frame shape
- :zoom zoom
- :is-selected (contains? selected id)
- :is-show-artboard-names is-show-artboard-names
- :is-show-id (dbg/enabled? :shape-titles)
- :is-grid-edition (and (= id edition) grid-edition?)
- :on-frame-enter on-frame-enter
- :on-frame-leave on-frame-leave
- :on-frame-select on-frame-select}]))]))
+ [:> frame-title* {:key (dm/str "frame-title-" id)
+ :frame shape
+ :zoom zoom
+ :is-selected (contains? selected id)
+ :is-show-artboard-names is-show-artboard-names
+ :is-show-id (dbg/enabled? :shape-titles)
+ :is-grid-edition (and (= id edition) grid-edition?)
+ :on-frame-enter on-frame-enter
+ :on-frame-leave on-frame-leave
+ :on-frame-select on-frame-select}]))]))
(mf/defc frame-flow*
[{:keys [flow frame is-selected zoom on-frame-enter on-frame-leave on-frame-select]}]
diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs
index edfd3ce582..e71747b2d3 100644
--- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs
@@ -132,21 +132,21 @@
(apply-modifiers-to-objects base-objects wasm-modifiers))
;; STATE
- alt? (mf/use-state false)
- shift? (mf/use-state false)
- mod? (mf/use-state false)
- space? (mf/use-state false)
- z? (mf/use-state false)
- cursor (mf/use-state (utils/get-cursor :pointer-inner))
- hover-ids (mf/use-state nil)
- hover (mf/use-state nil)
- measure-hover (mf/use-state nil)
- hover-disabled? (mf/use-state false)
- hover-top-frame-id (mf/use-state nil)
- frame-hover (mf/use-state nil)
- active-frames (mf/use-state #{})
- canvas-init? (mf/use-state false)
- initialized? (mf/use-state false)
+ alt? (mf/use-state false)
+ shift? (mf/use-state false)
+ mod? (mf/use-state false)
+ space? (mf/use-state false)
+ z? (mf/use-state false)
+ cursor (mf/use-state (utils/get-cursor :pointer-inner))
+ hover-ids (mf/use-state nil)
+ hover (mf/use-state nil)
+ measure-hover (mf/use-state nil)
+ hover-disabled? (mf/use-state false)
+ hover-top-frame-id (mf/use-state nil)
+ frame-hover (mf/use-state nil)
+ active-frames (mf/use-state #{})
+ canvas-init? (mf/use-state false)
+ initialized? (mf/use-state false)
;; REFS
[viewport-ref
@@ -205,6 +205,9 @@
mode-inspect? (= options-mode :inspect)
+ ;; True when we are opening a new file or switching to a new page
+ page-transition? (mf/deref wasm.api/page-transition?)
+
on-click (actions/on-click hover selected edition path-drawing? drawing-tool space? selrect z?)
on-context-menu (actions/on-context-menu hover hover-ids read-only?)
on-double-click (actions/on-double-click hover hover-ids hover-top-frame-id path-drawing? base-objects edition drawing-tool z? read-only?)
@@ -234,37 +237,43 @@
show-cursor-tooltip? tooltip
show-draw-area? drawing-obj
show-gradient-handlers? (= (count selected) 1)
- show-grids? (contains? layout :display-guides)
+ show-grids? (and (contains? layout :display-guides) (not page-transition?))
- show-frame-outline? (and (= transform :move) (not panning))
+ show-frame-outline? (and (= transform :move) (not panning) (not page-transition?))
show-outlines? (and (nil? transform)
(not panning)
(not edition)
(not drawing-obj)
- (not (#{:comments :path :curve} drawing-tool)))
+ (not (#{:comments :path :curve} drawing-tool))
+ (not page-transition?))
show-pixel-grid? (and (contains? layout :show-pixel-grid)
- (>= zoom 8))
- show-text-editor? (and editing-shape (= :text (:type editing-shape)))
+ (>= zoom 8)
+ (not page-transition?))
+ show-text-editor? (and editing-shape (= :text (:type editing-shape)) (not page-transition?))
hover-grid? (and (some? @hover-top-frame-id)
- (ctl/grid-layout? objects @hover-top-frame-id))
+ (ctl/grid-layout? objects @hover-top-frame-id)
+ (not page-transition?))
- show-grid-editor? (and editing-shape (ctl/grid-layout? editing-shape))
- show-presence? page-id
- show-prototypes? (= options-mode :prototype)
- show-selection-handlers? (and (seq selected) (not show-text-editor?))
+ show-grid-editor? (and editing-shape (ctl/grid-layout? editing-shape) (not page-transition?))
+ show-presence? (and page-id (not page-transition?))
+ show-prototypes? (and (= options-mode :prototype) (not page-transition?))
+ show-selection-handlers? (and (seq selected) (not show-text-editor?) (not page-transition?))
show-snap-distance? (and (contains? layout :dynamic-alignment)
(= transform :move)
- (seq selected))
+ (seq selected)
+ (not page-transition?))
show-snap-points? (and (or (contains? layout :dynamic-alignment)
(contains? layout :snap-guides))
- (or drawing-obj transform))
- show-selrect? (and selrect (empty? drawing) (not text-editing?))
+ (or drawing-obj transform)
+ (not page-transition?))
+ show-selrect? (and selrect (empty? drawing) (not text-editing?) (not page-transition?))
show-measures? (and (not transform)
(not path-editing?)
- (or show-distances? mode-inspect?))
- show-artboard-names? (contains? layout :display-artboard-names)
+ (or show-distances? mode-inspect?)
+ (not page-transition?))
+ show-artboard-names? (and (contains? layout :display-artboard-names) (not page-transition?))
hide-ui? (contains? layout :hide-ui)
show-rulers? (and (contains? layout :rulers) (not hide-ui?))
@@ -280,6 +289,8 @@
(or (ctk/is-variant-container? first-shape)
(ctk/is-variant? first-shape)))
+ show-scrollbar? (not page-transition?)
+
add-variant
(mf/use-fn
(mf/deps first-shape)
@@ -312,7 +323,8 @@
rule-area-size (/ rulers/ruler-area-size zoom)
preview-blend (-> refs/workspace-preview-blend
(mf/deref))
- shapes-loading? (mf/deref wasm.api/shapes-loading?)]
+ shapes-loading? (mf/deref wasm.api/shapes-loading?)
+ transition-image-url (mf/deref wasm.api/transition-image-url*)]
;; NOTE: We need this page-id dependency to react to it and reset the
;; canvas, even though we are not using `page-id` inside the hook.
@@ -342,15 +354,7 @@
(cond
init?
(do
- (reset! canvas-init? true)
- (wasm.api/apply-canvas-blur)
- (if (wasm.api/has-captured-pixels?)
- ;; Page switch: restore previously captured pixels (blurred)
- (wasm.api/restore-previous-canvas-pixels)
- ;; First load: try to draw a blurred page thumbnail
- (when-let [frame-id (get page :thumbnail-frame-id)]
- (when-let [uri (dm/get-in @st/state [:thumbnails frame-id])]
- (wasm.api/draw-thumbnail-to-canvas uri)))))
+ (reset! canvas-init? true))
(pos? retries)
(vreset! timeout-id-ref
@@ -393,19 +397,20 @@
(when @canvas-init?
(if (not @initialized?)
(do
+ ;; Initial file open uses the same transition workflow as page switches,
+ ;; but with a solid background-color blurred placeholder.
+ (wasm.api/start-initial-load-transition! background)
;; Keep the blurred previous-page preview (page switch) or
;; blank canvas (first load) visible while shapes load.
;; The loading overlay is suppressed because on-shapes-ready
;; is set.
(wasm.api/initialize-viewport
- base-objects zoom vbox background 1 nil
- (fn []
- (wasm.api/clear-canvas-pixels)))
+ base-objects zoom vbox :background background)
(reset! initialized? true)
(mf/set-ref-val! last-file-version-id-ref file-version-id))
(when (and (some? file-version-id)
(not= file-version-id (mf/ref-val last-file-version-id-ref)))
- (wasm.api/initialize-viewport base-objects zoom vbox background)
+ (wasm.api/initialize-viewport base-objects zoom vbox :background background)
(mf/set-ref-val! last-file-version-id-ref file-version-id)))))
(mf/with-effect [focus]
@@ -477,6 +482,21 @@
:style {:background-color background
:pointer-events "none"}}]
+ ;; Show the transition image when we are opening a new file or switching to a new page
+ (when (and page-transition? (some? transition-image-url))
+ (let [src transition-image-url]
+ [:img {:data-testid "canvas-wasm-transition"
+ :src src
+ :draggable false
+ :style {:position "absolute"
+ :inset 0
+ :width "100%"
+ :height "100%"
+ :object-fit "cover"
+ :pointer-events "none"
+ :filter "blur(4px)"}}]))
+
+
[:svg.viewport-controls
{:xmlns "http://www.w3.org/2000/svg"
:xmlnsXlink "http://www.w3.org/1999/xlink"
@@ -637,7 +657,7 @@
:tool drawing-tool}])
(when show-grids?
- [:& frame-grid/frame-grid
+ [:> frame-grid/frame-grid*
{:zoom zoom
:selected selected
:transform transform
@@ -648,7 +668,7 @@
:zoom zoom}])
(when show-snap-points?
- [:& snap-points/snap-points
+ [:> snap-points/snap-points*
{:layout layout
:transform transform
:drawing drawing-obj
@@ -750,13 +770,13 @@
:disabled (or drawing-tool @space?)}])))
(when show-prototypes?
- [:& interactions/interactions
+ [:> interactions/interactions*
{:selected selected
:page-id page-id
:zoom zoom
:objects objects-modified
:current-transform transform
- :hover-disabled? hover-disabled?}])])
+ :is-hover-disabled hover-disabled?}])])
(when show-gradient-handlers?
[:> gradients/gradient-handlers*
@@ -777,9 +797,10 @@
(get objects-modified @hover-top-frame-id))
:view-only (not show-grid-editor?)}])]
- [:g.scrollbar-wrapper {:clipPath "url(#clip-handlers)"}
- [:& scroll-bars/viewport-scrollbars
- {:objects base-objects
- :zoom zoom
- :vbox vbox
- :bottom-padding (when palete-size (+ palete-size 8))}]]]]]))
+ (when show-scrollbar?
+ [:g.scrollbar-wrapper {:clipPath "url(#clip-handlers)"}
+ [:> scroll-bars/viewport-scrollbars*
+ {:objects base-objects
+ :zoom zoom
+ :vbox vbox
+ :bottom-padding (when palete-size (+ palete-size 8))}]])]]]))
diff --git a/frontend/src/app/plugins/text.cljs b/frontend/src/app/plugins/text.cljs
index 1154af2366..aa4539c980 100644
--- a/frontend/src/app/plugins/text.cljs
+++ b/frontend/src/app/plugins/text.cljs
@@ -15,6 +15,8 @@
[app.common.types.text :as txt]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.texts :as dwt]
+ [app.main.data.workspace.wasm-text :as dwwt]
+ [app.main.features :as features]
[app.main.fonts :as fonts]
[app.main.store :as st]
[app.plugins.format :as format]
@@ -417,8 +419,10 @@
(st/emit! (dwt/update-editor-state shape editor)))
:else
- (st/emit! (dwsh/update-shapes [id]
- #(update % :content txt/change-text value))))))}
+ (do
+ (st/emit! (dwsh/update-shapes [id] #(update % :content txt/change-text value)))
+ (when (features/active-feature? @st/state "render-wasm/v1")
+ (st/emit! (dwwt/resize-wasm-text-debounce id)))))))}
{:name "growType"
:get #(-> % u/proxy->shape :grow-type d/name)
@@ -434,7 +438,10 @@
(u/not-valid plugin-id :growType "Plugin doesn't have 'content:write' permission")
:else
- (st/emit! (dwsh/update-shapes [id] #(assoc % :grow-type value))))))}
+ (st/emit!
+ (dwsh/update-shapes [id] #(assoc % :grow-type value))
+ (when (features/active-feature? @st/state "render-wasm/v1")
+ (st/emit! (dwwt/resize-wasm-text-debounce id)))))))}
{:name "fontId"
:get #(-> % u/proxy->shape text-props :font-id format/format-mixed)
diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs
index f12cbbc332..a44bafb15b 100644
--- a/frontend/src/app/render_wasm/api.cljs
+++ b/frontend/src/app/render_wasm/api.cljs
@@ -27,7 +27,6 @@
[app.main.router :as rt]
[app.main.store :as st]
[app.main.ui.shapes.text]
- [app.main.worker :as mw]
[app.render-wasm.api.fonts :as f]
[app.render-wasm.api.shapes :as shapes]
[app.render-wasm.api.texts :as t]
@@ -55,6 +54,109 @@
(def use-dpr? (contains? cf/flags :render-wasm-dpr))
+;; --- Page transition state (WASM viewport)
+;;
+;; Goal: avoid showing tile-by-tile rendering during page switches (and initial load),
+;; by keeping a blurred snapshot overlay visible until WASM dispatches
+;; `penpot:wasm:tiles-complete`.
+;;
+;; - `page-transition?`: true while the overlay should be considered active.
+;; - `transition-image-url*`: URL used by the UI overlay (usually `blob:` from the
+;; current WebGL canvas snapshot; on initial load it may be a tiny SVG data-url
+;; derived from the page background color).
+;; - `transition-epoch*`: monotonic counter used to ignore stale async work/events
+;; when the user clicks pages rapidly (A -> B -> C).
+;; - `transition-tiles-handler*`: the currently installed DOM event handler for
+;; `penpot:wasm:tiles-complete`, so we can remove/replace it safely.
+(defonce page-transition? (atom false))
+(defonce transition-image-url* (atom nil))
+(defonce transition-epoch* (atom 0))
+(defonce transition-tiles-handler* (atom nil))
+
+(def ^:private transition-blur-css "blur(4px)")
+
+(defn- set-transition-blur!
+ []
+ (when-let [canvas ^js wasm/canvas]
+ (dom/set-style! canvas "filter" transition-blur-css))
+ (when-let [nodes (.querySelectorAll ^js ug/document ".blurrable")]
+ (doseq [^js node (array-seq nodes)]
+ (dom/set-style! node "filter" transition-blur-css))))
+
+(defn- clear-transition-blur!
+ []
+ (when-let [canvas ^js wasm/canvas]
+ (dom/set-style! canvas "filter" ""))
+ (when-let [nodes (.querySelectorAll ^js ug/document ".blurrable")]
+ (doseq [^js node (array-seq nodes)]
+ (dom/set-style! node "filter" ""))))
+
+(defn set-transition-image-from-background!
+ "Sets `transition-image-url*` to a data URL representing a solid background color."
+ [background]
+ (when (string? background)
+ (let [svg (str "
")]
+ (reset! transition-image-url*
+ (str "data:image/svg+xml;charset=utf-8," (js/encodeURIComponent svg))))))
+
+(defn begin-page-transition!
+ []
+ (reset! page-transition? true)
+ (swap! transition-epoch* inc))
+
+(defn end-page-transition!
+ []
+ (reset! page-transition? false)
+ (when-let [prev @transition-tiles-handler*]
+ (.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" prev))
+ (reset! transition-tiles-handler* nil)
+ (reset! transition-image-url* nil)
+ (clear-transition-blur!)
+ ;; Clear captured pixels so future transitions must explicitly capture again.
+ (set! wasm/canvas-snapshot-url nil))
+
+(defn- set-transition-tiles-complete-handler!
+ "Installs a tiles-complete handler bound to the current transition epoch.
+ Replaces any previous handler so rapid page switching doesn't end the wrong transition."
+ [epoch f]
+ (when-let [prev @transition-tiles-handler*]
+ (.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" prev))
+ (letfn [(handler [_]
+ (when (= epoch @transition-epoch*)
+ (.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" handler)
+ (reset! transition-tiles-handler* nil)
+ (f)))]
+ (reset! transition-tiles-handler* handler)
+ (.addEventListener ^js ug/document "penpot:wasm:tiles-complete" handler)))
+
+(defn start-initial-load-transition!
+ "Starts a page-transition workflow for initial file open.
+
+ - Sets `page-transition?` to true
+ - Installs a tiles-complete handler to end the transition
+ - Uses a solid background-color placeholder as the transition image"
+ [background]
+ ;; If something already toggled `page-transition?` (e.g. legacy init code paths),
+ ;; ensure we still have a deterministic placeholder on initial load.
+ (when (or (not @page-transition?) (nil? @transition-image-url*))
+ (set-transition-image-from-background! background))
+ (when-not @page-transition?
+ ;; Start transition + bind the tiles-complete handler to this epoch.
+ (let [epoch (begin-page-transition!)]
+ (set-transition-tiles-complete-handler! epoch end-page-transition!))))
+
+(defn listen-tiles-render-complete-once!
+ "Registers a one-shot listener for `penpot:wasm:tiles-complete`, dispatched from WASM
+ when a full tile pass finishes."
+ [f]
+ (.addEventListener ^js ug/document
+ "penpot:wasm:tiles-complete"
+ (fn [_]
+ (f))
+ #js {:once true}))
+
(defn text-editor-wasm?
[]
(or (contains? cf/flags :feature-text-editor-wasm)
@@ -94,16 +196,9 @@
(def ^:const TEXT_EDITOR_EVENT_NEEDS_LAYOUT 4)
;; Re-export public WebGL functions
-(def capture-canvas-pixels webgl/capture-canvas-pixels)
-(def restore-previous-canvas-pixels webgl/restore-previous-canvas-pixels)
-(def clear-canvas-pixels webgl/clear-canvas-pixels)
+(def capture-canvas-snapshot-url webgl/capture-canvas-snapshot-url)
(def draw-thumbnail-to-canvas webgl/draw-thumbnail-to-canvas)
-(defn has-captured-pixels?
- "Returns true if there are saved canvas pixels from a previous page."
- []
- (some? wasm/canvas-pixels))
-
;; Re-export public text editor functions
(def text-editor-focus text-editor/text-editor-focus)
(def text-editor-blur text-editor/text-editor-blur)
@@ -307,8 +402,9 @@
"Apply style attrs to the currently selected text spans.
Updates the cached content, pushes to WASM, and returns {:shape-id :content} for saving."
[attrs]
- (text-editor/apply-styles-to-selection attrs use-shape set-shape-text-content)
- (request-render "apply-styles-to-selection"))
+ (let [result (text-editor/apply-styles-to-selection attrs use-shape set-shape-text-content)]
+ (request-render "apply-styles-to-selection")
+ result))
(defn set-parent-id
[id]
@@ -1018,15 +1114,6 @@
(render-finish)
(perf/end-measure "render-from-cache"))
-(defn update-text-rect!
- [id]
- (when wasm/context-initialized?
- (mw/emit!
- {:cmd :index/update-text-rect
- :page-id (:current-page-id @st/state)
- :shape-id id
- :dimensions (get-text-dimensions id)})))
-
(defn- ensure-text-content
"Guarantee that the shape always sends a valid text tree to WASM. When the
content is nil (freshly created text) we fall back to
@@ -1099,10 +1186,7 @@
"Synchronously update text layouts for all shapes and send rect updates
to the worker index."
[text-ids]
- (run! (fn [id]
- (f/update-text-layout id)
- (update-text-rect! id))
- text-ids))
+ (run! f/update-text-layout text-ids))
(defn process-pending
[shapes thumbnails full on-complete]
@@ -1120,11 +1204,13 @@
(if (or (seq pending-thumbnails) (seq pending-full))
(->> (rx/concat
(->> (rx/from (vals pending-thumbnails))
- (rx/merge-map (fn [callback] (callback)))
- (rx/reduce conj []))
+ (rx/merge-map (fn [callback] (if (fn? callback) (callback) (rx/empty))))
+ (rx/reduce conj [])
+ (rx/catch #(rx/empty)))
(->> (rx/from (vals pending-full))
- (rx/mapcat (fn [callback] (callback)))
- (rx/reduce conj [])))
+ (rx/mapcat (fn [callback] (if (fn? callback) (callback) (rx/empty))))
+ (rx/reduce conj [])
+ (rx/catch #(rx/empty))))
(rx/subs!
(fn [_]
;; Fonts are now loaded — recompute text layouts so Skia
@@ -1134,7 +1220,7 @@
(update-text-layouts text-ids)))
(request-render "images-loaded"))
noop-fn
- (fn [] (when on-complete (on-complete)))))
+ (fn [] (when (fn? on-complete) (on-complete)))))
;; No pending images — complete immediately.
(when on-complete (on-complete)))))
@@ -1300,14 +1386,14 @@
loading begins, allowing callers to reveal the page content during
transitions."
([objects]
- (set-objects objects nil nil))
+ (set-objects objects nil nil false))
([objects render-callback]
- (set-objects objects render-callback nil))
- ([objects render-callback on-shapes-ready]
+ (set-objects objects render-callback nil false))
+ ([objects render-callback on-shapes-ready force-sync]
(perf/begin-measure "set-objects")
(let [shapes (shapes-in-tree-order objects)
total-shapes (count shapes)]
- (if (< total-shapes ASYNC_THRESHOLD)
+ (if (or force-sync (< total-shapes ASYNC_THRESHOLD))
(set-objects-sync shapes render-callback on-shapes-ready)
(do
(begin-shapes-loading!)
@@ -1456,19 +1542,16 @@
(request-render "set-modifiers")))))
(defn initialize-viewport
- ([base-objects zoom vbox background]
- (initialize-viewport base-objects zoom vbox background 1 nil nil))
- ([base-objects zoom vbox background callback]
- (initialize-viewport base-objects zoom vbox background 1 callback nil))
- ([base-objects zoom vbox background background-opacity callback]
- (initialize-viewport base-objects zoom vbox background background-opacity callback nil))
- ([base-objects zoom vbox background background-opacity callback on-shapes-ready]
- (let [rgba (sr-clr/hex->u32argb background background-opacity)
- total-shapes (count (vals base-objects))]
- (h/call wasm/internal-module "_set_canvas_background" rgba)
- (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox)))
- (h/call wasm/internal-module "_init_shapes_pool" total-shapes)
- (set-objects base-objects callback on-shapes-ready))))
+ [base-objects zoom vbox &
+ {:keys [background background-opacity on-render on-shapes-ready force-sync]
+ :or {background-opacity 1}}]
+ (let [rgba (when background (sr-clr/hex->u32argb background background-opacity))
+ total-shapes (count (vals base-objects))]
+
+ (when rgba (h/call wasm/internal-module "_set_canvas_background" rgba))
+ (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox)))
+ (h/call wasm/internal-module "_init_shapes_pool" total-shapes)
+ (set-objects base-objects on-render on-shapes-ready force-sync)))
(def ^:private default-context-options
#js {:antialias false
@@ -1537,6 +1620,8 @@
(h/call wasm/internal-module "_set_render_options" flags dpr)
(when-let [t (wasm-aa-threshold-from-route-params)]
(h/call wasm/internal-module "_set_antialias_threshold" t))
+ (when-let [max-tex (webgl/max-texture-size context)]
+ (h/call wasm/internal-module "_set_max_atlas_texture_size" max-tex))
;; Set browser and canvas size only after initialization
(h/call wasm/internal-module "_set_browser" browser)
@@ -1762,11 +1847,11 @@
:direction (dr/translate-direction direction)
:font-id (get element :font-id)
:font-family (get element :font-family)
- :font-size (get element :font-size)
+ :font-size (dm/str (get element :font-size) "px")
:font-weight (get element :font-weight)
:text-transform (get element :text-transform)
:text-decoration (get element :text-decoration)
- :letter-spacing (get element :letter-spacing)
+ :letter-spacing (dm/str (get element :letter-spacing) "px")
:font-style (get element :font-style)
:fills (get element :fills)
:text text})))))))
@@ -1774,9 +1859,36 @@
(defn apply-canvas-blur
[]
- (when wasm/canvas (dom/set-style! wasm/canvas "filter" "blur(4px)"))
- (let [controls-to-blur (dom/query-all (dom/get-element "viewport-controls") ".blurrable")]
- (run! #(dom/set-style! % "filter" "blur(4px)") controls-to-blur)))
+ (let [already? @page-transition?
+ epoch (begin-page-transition!)]
+ (set-transition-tiles-complete-handler! epoch end-page-transition!)
+ ;; Two-phase transition:
+ ;; - Apply CSS blur to the live canvas immediately (no async wait), so the user
+ ;; sees the transition right away.
+ ;; - In parallel, capture a `blob:` snapshot URL; once ready, switch the overlay
+ ;; to that fixed image (and guard with `epoch` to avoid stale async updates).
+ (set-transition-blur!)
+ ;; Lock the snapshot for the whole transition: if the user clicks to another page
+ ;; while the transition is active, keep showing the original page snapshot until
+ ;; the final target page finishes rendering.
+ (if already?
+ (p/resolved nil)
+ (do
+ ;; If we already have a snapshot URL, use it immediately.
+ (when-let [url wasm/canvas-snapshot-url]
+ (when (string? url)
+ (reset! transition-image-url* url)))
+
+ ;; Capture a fresh snapshot asynchronously and update the overlay as soon
+ ;; as it is ready (guarded by `epoch` to avoid stale async updates).
+ (-> (capture-canvas-snapshot-url)
+ (p/then (fn [url]
+ (when (and (string? url)
+ @page-transition?
+ (= epoch @transition-epoch*))
+ (reset! transition-image-url* url))
+ url))
+ (p/catch (fn [_] nil)))))))
(defn render-shape-pixels
[shape-id scale]
diff --git a/frontend/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs
index 474a705979..3c011cd3db 100644
--- a/frontend/src/app/render_wasm/api/fonts.cljs
+++ b/frontend/src/app/render_wasm/api/fonts.cljs
@@ -21,7 +21,8 @@
[cuerdas.core :as str]
[goog.object :as gobj]
[lambdaisland.uri :as u]
- [okulary.core :as l]))
+ [okulary.core :as l]
+ [potok.v2.core :as ptk]))
(def ^:private fonts
(l/derived :fonts st/state))
@@ -127,6 +128,7 @@
mem (js/Uint8Array. (.-buffer heap) ptr size)]
(.set mem (js/Uint8Array. font-array-buffer))
+ (st/emit! (ptk/data-event :font-loaded {:font-id (:font-id font-data)}))
(h/call wasm/internal-module "_store_font"
(aget font-id-buffer 0)
(aget font-id-buffer 1)
@@ -208,7 +210,8 @@
id-buffer (uuid/get-u32 (:wasm-id font-data))
font-data (assoc font-data :family-id-buffer id-buffer)
font-stored? (font-stored? font-data emoji?)]
- (when-not font-stored?
+ (if font-stored?
+ (st/async-emit! (ptk/data-event :font-loaded {:font-id (:font-id font-data)}))
(fetch-font font-data uri emoji? fallback?)))))
(defn serialize-font-style
diff --git a/frontend/src/app/render_wasm/api/webgl.cljs b/frontend/src/app/render_wasm/api/webgl.cljs
index c6741944a2..7442947953 100644
--- a/frontend/src/app/render_wasm/api/webgl.cljs
+++ b/frontend/src/app/render_wasm/api/webgl.cljs
@@ -9,9 +9,17 @@
(:require
[app.common.logging :as log]
[app.render-wasm.wasm :as wasm]
- [app.util.dom :as dom]
[promesa.core :as p]))
+(defn max-texture-size
+ "Returns `gl.MAX_TEXTURE_SIZE` (max dimension of a 2D texture), or nil if
+ unavailable."
+ [gl]
+ (when gl
+ (let [n (.getParameter ^js gl (.-MAX_TEXTURE_SIZE ^js gl))]
+ (when (and (number? n) (pos? n) (js/isFinite n))
+ (js/Math.floor n)))))
+
(defn get-webgl-context
"Gets the WebGL context from the WASM module"
[]
@@ -135,38 +143,29 @@ void main() {
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) nil)
(.deleteTexture ^js gl texture))))
-(defn restore-previous-canvas-pixels
- "Restores previous canvas pixels into the new canvas"
- []
- (when-let [previous-canvas-pixels wasm/canvas-pixels]
- (when-let [gl wasm/gl-context]
- (draw-imagedata-to-webgl gl previous-canvas-pixels)
- (set! wasm/canvas-pixels nil))))
+(defn capture-canvas-snapshot-url
+ "Captures the current viewport canvas as a PNG `blob:` URL and stores it in
+ `wasm/canvas-snapshot-url`.
-(defn clear-canvas-pixels
+ Returns a promise resolving to the URL string (or nil)."
[]
- (when wasm/canvas
- (let [context wasm/gl-context]
- (.clearColor ^js context 0 0 0 0.0)
- (.clear ^js context (.-COLOR_BUFFER_BIT ^js context))
- (.clear ^js context (.-DEPTH_BUFFER_BIT ^js context))
- (.clear ^js context (.-STENCIL_BUFFER_BIT ^js context)))
- (dom/set-style! wasm/canvas "filter" "none")
- (let [controls-to-unblur (dom/query-all (dom/get-element "viewport-controls") ".blurrable")]
- (run! #(dom/set-style! % "filter" "none") controls-to-unblur))
- (set! wasm/canvas-pixels nil)))
-
-(defn capture-canvas-pixels
- "Captures the pixels of the viewport canvas"
- []
- (when wasm/canvas
- (let [context wasm/gl-context
- width (.-width wasm/canvas)
- height (.-height wasm/canvas)
- buffer (js/Uint8ClampedArray. (* width height 4))
- _ (.readPixels ^js context 0 0 width height (.-RGBA ^js context) (.-UNSIGNED_BYTE ^js context) buffer)
- image-data (js/ImageData. buffer width height)]
- (set! wasm/canvas-pixels image-data))))
+ (if-let [^js canvas wasm/canvas]
+ (p/create
+ (fn [resolve _reject]
+ ;; Revoke previous snapshot to avoid leaking blob URLs.
+ (when-let [prev wasm/canvas-snapshot-url]
+ (when (and (string? prev) (.startsWith ^js prev "blob:"))
+ (js/URL.revokeObjectURL prev)))
+ (set! wasm/canvas-snapshot-url nil)
+ (.toBlob canvas
+ (fn [^js blob]
+ (if blob
+ (let [url (js/URL.createObjectURL blob)]
+ (set! wasm/canvas-snapshot-url url)
+ (resolve url))
+ (resolve nil)))
+ "image/png")))
+ (p/resolved nil)))
(defn draw-thumbnail-to-canvas
"Loads an image from `uri` and draws it stretched to fill the WebGL canvas.
diff --git a/frontend/src/app/render_wasm/shape.cljs b/frontend/src/app/render_wasm/shape.cljs
index 032f3d7926..ac61bbac2e 100644
--- a/frontend/src/app/render_wasm/shape.cljs
+++ b/frontend/src/app/render_wasm/shape.cljs
@@ -323,11 +323,7 @@
(vals)
(rx/from)
(rx/mapcat (fn [callback] (callback)))
- (rx/reduce conj [])
- (rx/tap
- (fn []
- (when (cfh/text-shape? shape)
- (api/update-text-rect! (:id shape)))))))
+ (rx/reduce conj [])))
(rx/empty))))
(defn process-shape-changes!
diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs
index cadbd72d31..1cfb5b834c 100644
--- a/frontend/src/app/render_wasm/text_editor.cljs
+++ b/frontend/src/app/render_wasm/text_editor.cljs
@@ -15,7 +15,7 @@
[app.render-wasm.mem :as mem]
[app.render-wasm.wasm :as wasm]))
-(def ^:const TEXT_EDITOR_STYLES_METADATA_SIZE (* 30 4))
+(def ^:const TEXT_EDITOR_STYLES_METADATA_SIZE (* 31 4))
(def ^:const TEXT_EDITOR_STYLES_FILL_SOLID 0)
(def ^:const TEXT_EDITOR_STYLES_FILL_LINEAR_GRADIENT 1)
(def ^:const TEXT_EDITOR_STYLES_FILL_RADIAL_GRADIENT 2)
@@ -261,22 +261,23 @@
line-height-state (aget heap-u32 (+ u32-offset 9))
letter-spacing-state (aget heap-u32 (+ u32-offset 10))
num-fills (aget heap-u32 (+ u32-offset 11))
+ multiple-fills (aget heap-u32 (+ u32-offset 12))
- text-align-value (aget heap-u32 (+ u32-offset 12))
- text-direction-value (aget heap-u32 (+ u32-offset 13))
- text-decoration-value (aget heap-u32 (+ u32-offset 14))
- text-transform-value (aget heap-u32 (+ u32-offset 15))
- font-family-id-a (aget heap-u32 (+ u32-offset 16))
- font-family-id-b (aget heap-u32 (+ u32-offset 17))
- font-family-id-c (aget heap-u32 (+ u32-offset 18))
- font-family-id-d (aget heap-u32 (+ u32-offset 19))
+ text-align-value (aget heap-u32 (+ u32-offset 13))
+ text-direction-value (aget heap-u32 (+ u32-offset 14))
+ text-decoration-value (aget heap-u32 (+ u32-offset 15))
+ text-transform-value (aget heap-u32 (+ u32-offset 16))
+ font-family-id-a (aget heap-u32 (+ u32-offset 17))
+ font-family-id-b (aget heap-u32 (+ u32-offset 18))
+ font-family-id-c (aget heap-u32 (+ u32-offset 19))
+ font-family-id-d (aget heap-u32 (+ u32-offset 20))
font-family-id-value (uuid/from-unsigned-parts font-family-id-a font-family-id-b font-family-id-c font-family-id-d)
- font-family-style-value (aget heap-u32 (+ u32-offset 20))
- _font-family-weight-value (aget heap-u32 (+ u32-offset 21))
- font-size-value (aget heap-f32 (+ u32-offset 22))
- font-weight-value (aget heap-i32 (+ u32-offset 23))
- line-height-value (aget heap-f32 (+ u32-offset 28))
- letter-spacing-value (aget heap-f32 (+ u32-offset 29))
+ font-family-style-value (aget heap-u32 (+ u32-offset 21))
+ _font-family-weight-value (aget heap-u32 (+ u32-offset 22))
+ font-size-value (aget heap-f32 (+ u32-offset 23))
+ font-weight-value (aget heap-i32 (+ u32-offset 24))
+ line-height-value (aget heap-f32 (+ u32-offset 29))
+ letter-spacing-value (aget heap-f32 (+ u32-offset 30))
font-id (fonts/uuid->font-id font-family-id-value)
font-style-value (text-editor-translate-font-style (text-editor-get-style-property font-family-state font-family-style-value))
font-variant-id-computed (text-editor-compute-font-variant-id font-id font-weight-value font-style-value)
@@ -291,6 +292,11 @@
(filter some?)
(into []))
+ ;; The order of these two variables is important, do not
+ ;; reorder them.
+ selected-colors (if (= multiple-fills 1) fills nil)
+ fills (if (= multiple-fills 1) :multiple fills)
+
result {:vertical-align (text-editor-translate-vertical-align vertical-align)
:text-align (text-editor-translate-text-align (text-editor-get-style-property text-align-state text-align-value))
:text-direction (text-editor-translate-text-direction (text-editor-get-style-property text-direction-state text-direction-value))
@@ -306,6 +312,7 @@
:font-variant-id (text-editor-get-style-property font-variant-id-state font-variant-id-computed)
:typography-ref-file nil
:typography-ref-id nil
+ :selected-colors selected-colors
:fills fills}]
(mem/free)
@@ -471,6 +478,19 @@
;; This is used as a intermediate cache between Clojure global state and WASM state.
(def ^:private shape-text-contents (atom {}))
+(defn cache-shape-text-content!
+ [shape-id content]
+ (when (some? content)
+ (swap! shape-text-contents assoc shape-id content)))
+
+(defn get-cached-content
+ [shape-id]
+ (get @shape-text-contents shape-id))
+
+(defn update-cached-content!
+ [shape-id content]
+ (swap! shape-text-contents assoc shape-id content))
+
(defn- merge-exported-texts-into-content
"Merge exported span texts back into the existing content tree.
@@ -522,26 +542,13 @@
new-texts (text-editor-export-content)]
(when (and shape-id new-texts)
(let [texts-clj (js->clj new-texts)
- content (get @shape-text-contents shape-id)]
+ content (get-cached-content shape-id)]
(when content
(let [merged (merge-exported-texts-into-content content texts-clj)]
(swap! shape-text-contents assoc shape-id merged)
{:shape-id shape-id
:content merged})))))))
-(defn cache-shape-text-content!
- [shape-id content]
- (when (some? content)
- (swap! shape-text-contents assoc shape-id content)))
-
-(defn get-cached-content
- [shape-id]
- (get @shape-text-contents shape-id))
-
-(defn update-cached-content!
- [shape-id content]
- (swap! shape-text-contents assoc shape-id content))
-
(defn- normalize-selection
"Given anchor/focus para+offset, return {:start-para :start-offset :end-para :end-offset}
ordered so start <= end."
@@ -558,6 +565,7 @@
Splits spans at boundaries as needed."
[para sel-start sel-end attrs]
(let [spans (:children para)
+
result (loop [spans spans
pos 0
acc []]
@@ -594,7 +602,7 @@
selection (text-editor-get-selection)]
(when (and shape-id selection)
- (let [content (get @shape-text-contents shape-id)]
+ (let [content (get-cached-content shape-id)]
(when content
(let [normalized-selection (normalize-selection selection)
{:keys [start-para start-offset end-para end-offset]} normalized-selection
@@ -630,11 +638,13 @@
(range (count paragraphs))
paragraphs))
+
new-content (when new-paragraphs
(assoc content :children
[(assoc paragraph-set :children new-paragraphs)]))]
+
(when new-content
- (swap! shape-text-contents assoc shape-id new-content)
+ (update-cached-content! shape-id new-content)
(use-shape-fn shape-id)
(set-shape-text-content-fn shape-id new-content)
{:shape-id shape-id
diff --git a/frontend/src/app/render_wasm/wasm.cljs b/frontend/src/app/render_wasm/wasm.cljs
index c54091d5e2..5c43ba4899 100644
--- a/frontend/src/app/render_wasm/wasm.cljs
+++ b/frontend/src/app/render_wasm/wasm.cljs
@@ -12,8 +12,9 @@
;; Reference to the HTML canvas element.
(defonce canvas nil)
-;; Reference to the captured pixels of the canvas (for page switching effect)
-(defonce canvas-pixels nil)
+;; Snapshot of the current canvas suitable for `

` overlays.
+;; This is typically a `blob:` URL created via `canvas.toBlob`.
+(defonce canvas-snapshot-url nil)
;; Reference to the Emscripten GL context wrapper.
(defonce gl-context-handle nil)
diff --git a/frontend/src/app/util/browser_history.js b/frontend/src/app/util/browser_history.js
index d206b83b7f..074da03f70 100644
--- a/frontend/src/app/util/browser_history.js
+++ b/frontend/src/app/util/browser_history.js
@@ -44,6 +44,6 @@ goog.scope(function() {
}
self.replace_token_BANG_ = function(instance, token) {
- instance.replaceToken(token);
+ instance?.replaceToken(token);
}
});
diff --git a/frontend/src/app/worker/import.cljs b/frontend/src/app/worker/import.cljs
index a191b9466f..20c314f012 100644
--- a/frontend/src/app/worker/import.cljs
+++ b/frontend/src/app/worker/import.cljs
@@ -11,6 +11,7 @@
[app.common.logging :as log]
[app.common.schema :as sm]
[app.common.uuid :as uuid]
+ [app.main.data.uploads :as uploads]
[app.main.repo :as rp]
[app.util.http :as http]
[app.util.i18n :as i18n :refer [tr]]
@@ -129,6 +130,23 @@
(->> (rx/from files)
(rx/merge-map analyze-file)))
+(defn- import-blob-via-upload
+ "Fetches `uri` as a Blob, uploads it using the generic chunked-upload
+ session API and calls `import-binfile` with the resulting upload-id.
+ Returns an observable of SSE events from the import stream."
+ [uri {:keys [name version project-id]}]
+ (->> (slurp-uri uri :blob)
+ (rx/mapcat
+ (fn [blob]
+ (->> (uploads/upload-blob-chunked blob)
+ (rx/mapcat
+ (fn [{:keys [session-id]}]
+ (rp/cmd! ::sse/import-binfile
+ {:name name
+ :upload-id session-id
+ :version version
+ :project-id project-id}))))))))
+
(defmethod impl/handler :import-files
[{:keys [project-id files]}]
(let [binfile-v1 (filter #(= :binfile-v1 (:type %)) files)
@@ -138,31 +156,22 @@
(->> (rx/from binfile-v1)
(rx/merge-map
(fn [data]
- (->> (http/send!
- {:uri (:uri data)
- :response-type :blob
- :method :get})
- (rx/map :body)
- (rx/mapcat
- (fn [file]
- (->> (rp/cmd! ::sse/import-binfile
- {:name (str/replace (:name data) #".penpot$" "")
- :file file
- :version 1
- :project-id project-id})
- (rx/tap (fn [event]
- (let [payload (sse/get-payload event)
- type (sse/get-type event)]
- (if (= type "progress")
- (log/dbg :hint "import-binfile: progress"
- :section (:section payload)
- :name (:name payload))
- (log/dbg :hint "import-binfile: end")))))
- (rx/filter sse/end-of-stream?)
- (rx/map (fn [_]
- {:status :finish
- :file-id (:file-id data)})))))
-
+ (->> (import-blob-via-upload (:uri data)
+ {:name (str/replace (:name data) #".penpot$" "")
+ :version 1
+ :project-id project-id})
+ (rx/tap (fn [event]
+ (let [payload (sse/get-payload event)
+ type (sse/get-type event)]
+ (if (= type "progress")
+ (log/dbg :hint "import-binfile: progress"
+ :section (:section payload)
+ :name (:name payload))
+ (log/dbg :hint "import-binfile: end")))))
+ (rx/filter sse/end-of-stream?)
+ (rx/map (fn [_]
+ {:status :finish
+ :file-id (:file-id data)}))
(rx/catch
(fn [cause]
(log/error :hint "unexpected error on import process"
@@ -179,29 +188,24 @@
(rx/mapcat identity)
(rx/merge-map
(fn [[uri entries]]
- (->> (slurp-uri uri :blob)
- (rx/mapcat (fn [content]
- ;; FIXME: implement the naming and filtering
- (->> (rp/cmd! ::sse/import-binfile
- {:name (-> entries first :name)
- :file content
- :version 3
- :project-id project-id})
- (rx/tap (fn [event]
- (let [payload (sse/get-payload event)
- type (sse/get-type event)]
- (if (= type "progress")
- (log/dbg :hint "import-binfile: progress"
- :section (:section payload)
- :name (:name payload))
- (log/dbg :hint "import-binfile: end")))))
- (rx/filter sse/end-of-stream?)
- (rx/mapcat (fn [_]
- (->> (rx/from entries)
- (rx/map (fn [entry]
- {:status :finish
- :file-id (:file-id entry)}))))))))
-
+ (->> (import-blob-via-upload uri
+ {:name (-> entries first :name)
+ :version 3
+ :project-id project-id})
+ (rx/tap (fn [event]
+ (let [payload (sse/get-payload event)
+ type (sse/get-type event)]
+ (if (= type "progress")
+ (log/dbg :hint "import-binfile: progress"
+ :section (:section payload)
+ :name (:name payload))
+ (log/dbg :hint "import-binfile: end")))))
+ (rx/filter sse/end-of-stream?)
+ (rx/mapcat (fn [_]
+ (->> (rx/from entries)
+ (rx/map (fn [entry]
+ {:status :finish
+ :file-id (:file-id entry)})))))
(rx/catch
(fn [cause]
(log/error :hint "unexpected error on import process"
@@ -213,5 +217,3 @@
{:status :error
:error (ex-message cause)
:file-id (:file-id entry)}))))))))))))
-
-
diff --git a/frontend/src/app/worker/index.cljs b/frontend/src/app/worker/index.cljs
index c40f0b6fd8..3ff1f37d19 100644
--- a/frontend/src/app/worker/index.cljs
+++ b/frontend/src/app/worker/index.cljs
@@ -10,9 +10,7 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.changes :as ch]
- [app.common.geom.matrix :as gmt]
[app.common.geom.rect :as grc]
- [app.common.geom.shapes :as gsh]
[app.common.logging :as log]
[app.common.time :as ct]
[app.worker.impl :as impl]
@@ -65,33 +63,7 @@
(log/dbg :hint "page index updated" :id page-id :elapsed elapsed ::log/sync? true))))
nil))
-(defmethod impl/handler :index/update-text-rect
- [{:keys [page-id shape-id dimensions]}]
- (let [page (dm/get-in @state [:pages-index page-id])
- objects (get page :objects)
- shape (get objects shape-id)
- center (gsh/shape->center shape)
- transform (:transform shape (gmt/matrix))
- rect (-> (grc/make-rect dimensions)
- (grc/rect->points))
- points (gsh/transform-points rect center transform)
- selrect (gsh/calculate-selrect points (gsh/points->center points))
-
- data {:position-data nil
- :points points
- :selrect selrect}
-
- shape (d/patch-object shape data)
-
- objects
- (assoc objects shape-id shape)]
-
- (swap! state update-in [::text-rect page-id] assoc shape-id data)
- (swap! state update-in [::selection page-id] selection/update-index-single objects shape)
- nil))
-
;; FIXME: schema
-
(defmethod impl/handler :index/query-snap
[{:keys [page-id frame-id axis ranges bounds] :as message}]
(if-let [index (get @state ::snap)]
diff --git a/frontend/src/app/worker/thumbnails.cljs b/frontend/src/app/worker/thumbnails.cljs
index 70d8216d14..d90536619d 100644
--- a/frontend/src/app/worker/thumbnails.cljs
+++ b/frontend/src/app/worker/thumbnails.cljs
@@ -171,7 +171,10 @@
zoom (/ width (:width vbox))]
(wasm.api/initialize-viewport
- objects zoom vbox bgcolor
+ objects zoom vbox
+ :background bgcolor
+ :force-sync true
+ :on-render
(fn []
(if frame
(wasm.api/render-sync-shape (:id frame))
diff --git a/frontend/src/debug.cljs b/frontend/src/debug.cljs
index ee3f58b74c..65ac296895 100644
--- a/frontend/src/debug.cljs
+++ b/frontend/src/debug.cljs
@@ -135,6 +135,28 @@
(wasm.mem/free)
text)))
+(defn ^:export wasmAtlasConsole
+ "Logs the current render-wasm atlas as an image in the JS console (if present)."
+ []
+ (let [module wasm/internal-module
+ f (when module (unchecked-get module "_debug_atlas_console"))]
+ (if (fn? f)
+ (wasm.h/call module "_debug_atlas_console")
+ (js/console.warn "[debug] render-wasm module not ready or missing _debug_atlas_console"))))
+
+(defn ^:export wasmAtlasBase64
+ "Returns the atlas PNG base64 (empty string if missing/empty)."
+ []
+ (let [module wasm/internal-module
+ f (when module (unchecked-get module "_debug_atlas_base64"))]
+ (if (fn? f)
+ (let [ptr (wasm.h/call module "_debug_atlas_base64")
+ s (or (wasm-read-len-prefixed-utf8 ptr) "")]
+ s)
+ (do
+ (js/console.warn "[debug] render-wasm module not ready or missing _debug_atlas_base64")
+ ""))))
+
(defn ^:export wasmCacheConsole
"Logs the current render-wasm cache surface as an image in the JS console."
[]
diff --git a/frontend/test/frontend_tests/data/uploads_test.cljs b/frontend/test/frontend_tests/data/uploads_test.cljs
new file mode 100644
index 0000000000..1512fcb90b
--- /dev/null
+++ b/frontend/test/frontend_tests/data/uploads_test.cljs
@@ -0,0 +1,117 @@
+;; 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.uploads-test
+ "Integration tests for the generic chunked-upload logic in
+ app.main.data.uploads."
+ (:require
+ [app.common.uuid :as uuid]
+ [app.config :as cf]
+ [app.main.data.uploads :as uploads]
+ [beicon.v2.core :as rx]
+ [cljs.test :as t :include-macros true]
+ [frontend-tests.helpers.http :as http]))
+
+;; ---------------------------------------------------------------------------
+;; Local helpers
+;; ---------------------------------------------------------------------------
+
+(defn- make-blob
+ "Creates a JS Blob of exactly `size` bytes."
+ [size]
+ (let [buf (js/Uint8Array. size)]
+ (js/Blob. #js [buf] #js {:type "application/octet-stream"})))
+
+;; ---------------------------------------------------------------------------
+;; upload-blob-chunked tests
+;; ---------------------------------------------------------------------------
+
+(t/deftest upload-blob-chunked-creates-session-and-uploads-chunks
+ (t/testing "upload-blob-chunked calls create-upload-session then upload-chunk for each slice"
+ (t/async done
+ (let [session-id (uuid/next)
+ chunk-size cf/upload-chunk-size
+ ;; Exactly two full chunks
+ blob-size (* 2 chunk-size)
+ blob (make-blob blob-size)
+ calls (atom [])
+
+ fetch-mock
+ (fn [url _opts]
+ (let [cmd (http/url->cmd url)]
+ (swap! calls conj cmd)
+ (js/Promise.resolve
+ (case cmd
+ :create-upload-session
+ (http/make-transit-response
+ {:session-id session-id})
+
+ :upload-chunk
+ (http/make-transit-response
+ {:session-id session-id :index 0})
+
+ (http/make-json-response
+ {:error (str "unexpected cmd: " cmd)})))))
+
+ orig (http/install-fetch-mock! fetch-mock)]
+
+ (->> (uploads/upload-blob-chunked blob)
+ (rx/subs!
+ (fn [{:keys [session-id]}]
+ (t/is (uuid? session-id)))
+ (fn [err]
+ (t/is false (str "unexpected error: " (ex-message err)))
+ (done))
+ (fn []
+ (http/restore-fetch! orig)
+ (let [cmd-seq @calls]
+ ;; First call must create the session
+ (t/is (= :create-upload-session (first cmd-seq)))
+ ;; Two chunk uploads
+ (t/is (= 2 (count (filter #(= :upload-chunk %) cmd-seq))))
+ ;; No assemble call here — that's the caller's responsibility
+ (t/is (not (some #(= :assemble-file-media-object %) cmd-seq))))
+ (done))))))))
+
+(t/deftest upload-blob-chunked-chunk-count-matches-blob
+ (t/testing "number of upload-chunk calls equals ceil(blob-size / chunk-size)"
+ (t/async done
+ (let [session-id (uuid/next)
+ chunk-size cf/upload-chunk-size
+ ;; Three chunks: 2 full + 1 partial
+ blob-size (+ (* 2 chunk-size) 1)
+ blob (make-blob blob-size)
+ chunk-calls (atom 0)
+
+ fetch-mock
+ (fn [url _opts]
+ (let [cmd (http/url->cmd url)]
+ (js/Promise.resolve
+ (case cmd
+ :create-upload-session
+ (http/make-transit-response
+ {:session-id session-id})
+
+ :upload-chunk
+ (do (swap! chunk-calls inc)
+ (http/make-transit-response
+ {:session-id session-id :index 0}))
+
+ (http/make-json-response
+ {:error (str "unexpected cmd: " cmd)})))))
+
+ orig (http/install-fetch-mock! fetch-mock)]
+
+ (->> (uploads/upload-blob-chunked blob)
+ (rx/subs!
+ (fn [_] nil)
+ (fn [err]
+ (t/is false (str "unexpected error: " (ex-message err)))
+ (done))
+ (fn []
+ (http/restore-fetch! orig)
+ (t/is (= 3 @chunk-calls))
+ (done))))))))
diff --git a/frontend/test/frontend_tests/data/workspace_media_test.cljs b/frontend/test/frontend_tests/data/workspace_media_test.cljs
new file mode 100644
index 0000000000..915adb203b
--- /dev/null
+++ b/frontend/test/frontend_tests/data/workspace_media_test.cljs
@@ -0,0 +1,189 @@
+;; 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.workspace-media-test
+ "Integration tests for the chunked-upload logic in
+ app.main.data.workspace.media."
+ (:require
+ [app.common.uuid :as uuid]
+ [app.config :as cf]
+ [app.main.data.workspace.media :as media]
+ [beicon.v2.core :as rx]
+ [cljs.test :as t :include-macros true]
+ [frontend-tests.helpers.http :as http]))
+
+;; ---------------------------------------------------------------------------
+;; Local helpers
+;; ---------------------------------------------------------------------------
+
+(defn- make-blob
+ "Creates a JS Blob of exactly `size` bytes with the given `mtype`."
+ [size mtype]
+ (let [buf (js/Uint8Array. size)]
+ (js/Blob. #js [buf] #js {:type mtype})))
+
+;; ---------------------------------------------------------------------------
+;; Small-file path: direct upload (no chunking)
+;; ---------------------------------------------------------------------------
+
+(t/deftest small-file-uses-direct-upload
+ (t/testing "blobs below chunk-size use :upload-file-media-object directly"
+ (t/async done
+ (let [file-id (uuid/next)
+ ;; One byte below the threshold so the blob takes the direct path
+ blob-size (dec cf/upload-chunk-size)
+ blob (make-blob blob-size "image/jpeg")
+ calls (atom [])
+
+ fetch-mock
+ (fn [url _opts]
+ (let [cmd (http/url->cmd url)]
+ (swap! calls conj cmd)
+ (js/Promise.resolve
+ (http/make-json-response
+ {:id (str (uuid/next))
+ :name "img"
+ :width 100
+ :height 100
+ :mtype "image/jpeg"
+ :file-id (str file-id)}))))
+
+ orig (http/install-fetch-mock! fetch-mock)]
+
+ (->> (media/process-blobs
+ {:file-id file-id
+ :local? true
+ :blobs [blob]
+ :on-image (fn [_] nil)
+ :on-svg (fn [_] nil)})
+ (rx/subs!
+ (fn [_] nil)
+ (fn [err]
+ (t/is false (str "unexpected error: " (ex-message err)))
+ (done))
+ (fn []
+ (http/restore-fetch! orig)
+ ;; Should call :upload-file-media-object, NOT the chunked API
+ (t/is (= 1 (count @calls)))
+ (t/is (= :upload-file-media-object (first @calls)))
+ (done))))))))
+
+;; ---------------------------------------------------------------------------
+;; Large-file path: chunked upload via uploads namespace
+;; ---------------------------------------------------------------------------
+
+(t/deftest large-file-uses-chunked-upload
+ (t/testing "blobs at or above chunk-size use the three-step session API"
+ (t/async done
+ (let [file-id (uuid/next)
+ session-id (uuid/next)
+ chunk-size cf/upload-chunk-size
+ ;; Exactly two full chunks
+ blob-size (* 2 chunk-size)
+ blob (make-blob blob-size "image/jpeg")
+ calls (atom [])
+
+ fetch-mock
+ (fn [url _opts]
+ (let [cmd (http/url->cmd url)]
+ (swap! calls conj cmd)
+ (js/Promise.resolve
+ (http/make-json-response
+ (case cmd
+ :create-upload-session
+ {:session-id (str session-id)}
+
+ :upload-chunk
+ {:session-id (str session-id) :index 0}
+
+ :assemble-file-media-object
+ {:id (str (uuid/next))
+ :name "img"
+ :width 100
+ :height 100
+ :mtype "image/jpeg"
+ :file-id (str file-id)}
+
+ ;; Default: return an error response
+ {:error (str "unexpected cmd: " cmd)})))))
+
+ orig (http/install-fetch-mock! fetch-mock)]
+
+ (->> (media/process-blobs
+ {:file-id file-id
+ :local? true
+ :blobs [blob]
+ :on-image (fn [_] nil)
+ :on-svg (fn [_] nil)})
+ (rx/subs!
+ (fn [_] nil)
+ (fn [err]
+ (t/is false (str "unexpected error: " (ex-message err)))
+ (done))
+ (fn []
+ (http/restore-fetch! orig)
+ (let [cmd-seq @calls]
+ ;; First call must create the session
+ (t/is (= :create-upload-session (first cmd-seq)))
+ ;; Two chunk uploads
+ (t/is (= 2 (count (filter #(= :upload-chunk %) cmd-seq))))
+ ;; Last call must assemble
+ (t/is (= :assemble-file-media-object (last cmd-seq)))
+ ;; Direct upload must NOT be called
+ (t/is (not (some #(= :upload-file-media-object %) cmd-seq))))
+ (done))))))))
+
+(t/deftest chunked-upload-chunk-count-matches-blob
+ (t/testing "number of chunk upload calls equals ceil(blob-size / chunk-size)"
+ (t/async done
+ (let [file-id (uuid/next)
+ session-id (uuid/next)
+ chunk-size cf/upload-chunk-size
+ ;; Three chunks: 2 full + 1 partial
+ blob-size (+ (* 2 chunk-size) 1)
+ blob (make-blob blob-size "image/jpeg")
+ chunk-calls (atom 0)
+
+ fetch-mock
+ (fn [url _opts]
+ (let [cmd (http/url->cmd url)]
+ (js/Promise.resolve
+ (http/make-json-response
+ (case cmd
+ :create-upload-session
+ {:session-id (str session-id)}
+
+ :upload-chunk
+ (do (swap! chunk-calls inc)
+ {:session-id (str session-id) :index 0})
+
+ :assemble-file-media-object
+ {:id (str (uuid/next))
+ :name "img"
+ :width 100
+ :height 100
+ :mtype "image/jpeg"
+ :file-id (str file-id)}
+
+ {:error (str "unexpected cmd: " cmd)})))))
+
+ orig (http/install-fetch-mock! fetch-mock)]
+
+ (->> (media/process-blobs
+ {:file-id file-id
+ :local? true
+ :blobs [blob]
+ :on-image (fn [_] nil)
+ :on-svg (fn [_] nil)})
+ (rx/subs!
+ (fn [_] nil)
+ (fn [err]
+ (t/is false (str "unexpected error: " (ex-message err)))
+ (done))
+ (fn []
+ (http/restore-fetch! orig)
+ (t/is (= 3 @chunk-calls))
+ (done))))))))
diff --git a/frontend/test/frontend_tests/helpers/http.cljs b/frontend/test/frontend_tests/helpers/http.cljs
new file mode 100644
index 0000000000..28895f4049
--- /dev/null
+++ b/frontend/test/frontend_tests/helpers/http.cljs
@@ -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
+
+(ns frontend-tests.helpers.http
+ "Helpers for intercepting and mocking the global `fetch` function in
+ ClojureScript tests. The underlying HTTP layer (`app.util.http`) calls
+ `(js/fetch url params)` directly, so replacing `globalThis.fetch` is the
+ correct interception point."
+ (:require
+ [app.common.transit :as t]
+ [clojure.string :as str]))
+
+(defn install-fetch-mock!
+ "Replaces the global `js/fetch` with `handler-fn`.
+
+ `handler-fn` is called with `[url opts]` where `url` is a plain string
+ such as `\"http://localhost/api/main/methods/some-cmd\"`. It must return
+ a JS Promise that resolves to a fetch Response object.
+
+ Returns the previous `globalThis.fetch` value so callers can restore it
+ with [[restore-fetch!]]."
+ [handler-fn]
+ (let [prev (.-fetch js/globalThis)]
+ (set! (.-fetch js/globalThis) handler-fn)
+ prev))
+
+(defn restore-fetch!
+ "Restores `globalThis.fetch` to `orig` (the value returned by
+ [[install-fetch-mock!]])."
+ [orig]
+ (set! (.-fetch js/globalThis) orig))
+
+(defn make-json-response
+ "Creates a minimal fetch `Response` that returns `body-clj` serialised as
+ plain JSON with HTTP status 200."
+ [body-clj]
+ (let [json-str (.stringify js/JSON (clj->js body-clj))
+ headers (js/Headers. #js {"content-type" "application/json"})]
+ (js/Response. json-str #js {:status 200 :headers headers})))
+
+(defn make-transit-response
+ "Creates a minimal fetch `Response` that returns `body-clj` serialised as
+ Transit+JSON with HTTP status 200. Use this helper when the code under
+ test inspects typed values (UUIDs, keywords, etc.) from the response body,
+ since the HTTP layer only decodes transit+json content automatically."
+ [body-clj]
+ (let [transit-str (t/encode-str body-clj {:type :json-verbose})
+ headers (js/Headers. #js {"content-type" "application/transit+json"})]
+ (js/Response. transit-str #js {:status 200 :headers headers})))
+
+(defn url->cmd
+ "Extracts the RPC command keyword from a URL string.
+
+ Example: `\"http://…/api/main/methods/create-upload-session\"`
+ → `:create-upload-session`."
+ [url]
+ (when (string? url)
+ (keyword (last (str/split url #"/")))))
diff --git a/frontend/test/frontend_tests/main_errors_test.cljs b/frontend/test/frontend_tests/main_errors_test.cljs
new file mode 100644
index 0000000000..5dc1747658
--- /dev/null
+++ b/frontend/test/frontend_tests/main_errors_test.cljs
@@ -0,0 +1,136 @@
+;; 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.main-errors-test
+ "Unit tests for app.main.errors.
+
+ Tests cover:
+ - stale-asset-error? – pure predicate
+ - exception->error-data – pure transformer
+ - on-error re-entrancy guard – prevents recursive invocations
+ - flash schedules async emit – ntf/show is not emitted synchronously"
+ (:require
+ [app.main.errors :as errors]
+ [cljs.test :as t :include-macros true]
+ [potok.v2.core :as ptk]))
+
+;; ---------------------------------------------------------------------------
+;; stale-asset-error?
+;; ---------------------------------------------------------------------------
+
+(t/deftest stale-asset-error-nil
+ (t/testing "nil cause returns nil/falsy"
+ (t/is (not (errors/stale-asset-error? nil)))))
+
+(t/deftest stale-asset-error-keyword-cst-undefined
+ (t/testing "error with $cljs$cst$ and 'is undefined' is recognised"
+ (let [err (js/Error. "foo$cljs$cst$bar is undefined")]
+ (t/is (true? (boolean (errors/stale-asset-error? err)))))))
+
+(t/deftest stale-asset-error-keyword-cst-null
+ (t/testing "error with $cljs$cst$ and 'is null' is recognised"
+ (let [err (js/Error. "foo$cljs$cst$bar is null")]
+ (t/is (true? (boolean (errors/stale-asset-error? err)))))))
+
+(t/deftest stale-asset-error-protocol-dispatch-undefined
+ (t/testing "error with $cljs$core$I and 'Cannot read properties of undefined' is recognised"
+ (let [err (js/Error. "Cannot read properties of undefined (reading '$cljs$core$IFn$_invoke$arity$1$')")]
+ (t/is (true? (boolean (errors/stale-asset-error? err)))))))
+
+(t/deftest stale-asset-error-not-a-function
+ (t/testing "error with $cljs$cst$ and 'is not a function' is recognised"
+ (let [err (js/Error. "foo$cljs$cst$bar is not a function")]
+ (t/is (true? (boolean (errors/stale-asset-error? err)))))))
+
+(t/deftest stale-asset-error-unrelated-message
+ (t/testing "ordinary error without stale-asset signature is NOT recognised"
+ (let [err (js/Error. "Cannot read properties of undefined (reading 'foo')")]
+ (t/is (not (errors/stale-asset-error? err))))))
+
+(t/deftest stale-asset-error-only-cst-no-undefined
+ (t/testing "error with $cljs$cst$ but no undefined/null/not-a-function keyword is not recognised"
+ (let [err (js/Error. "foo$cljs$cst$bar exploded")]
+ (t/is (not (errors/stale-asset-error? err))))))
+
+;; ---------------------------------------------------------------------------
+;; exception->error-data
+;; ---------------------------------------------------------------------------
+
+(t/deftest exception->error-data-plain-error
+ (t/testing "plain JS Error is converted to a data map with :hint and ::instance"
+ (let [err (js/Error. "something went wrong")
+ data (errors/exception->error-data err)]
+ (t/is (= "something went wrong" (:hint data)))
+ (t/is (identical? err (::errors/instance data))))))
+
+(t/deftest exception->error-data-ex-info
+ (t/testing "ex-info error preserves existing :hint and attaches ::instance"
+ (let [err (ex-info "original" {:hint "my-hint" :type :network})
+ data (errors/exception->error-data err)]
+ (t/is (= "my-hint" (:hint data)))
+ (t/is (= :network (:type data)))
+ (t/is (identical? err (::errors/instance data))))))
+
+(t/deftest exception->error-data-ex-info-no-hint
+ (t/testing "ex-info without :hint falls back to ex-message"
+ (let [err (ex-info "fallback message" {:type :validation})
+ data (errors/exception->error-data err)]
+ (t/is (= "fallback message" (:hint data))))))
+
+;; ---------------------------------------------------------------------------
+;; on-error dispatches to ptk/handle-error
+;;
+;; We use a dedicated test-only error type so we can add/remove a
+;; defmethod without touching the real handlers.
+;; ---------------------------------------------------------------------------
+
+(def ^:private test-handled (atom nil))
+
+(defmethod ptk/handle-error ::test-dispatch
+ [err]
+ (reset! test-handled err))
+
+(t/deftest on-error-dispatches-map-error
+ (t/testing "on-error dispatches a map error to ptk/handle-error using its :type"
+ (reset! test-handled nil)
+ (errors/on-error {:type ::test-dispatch :hint "hello"})
+ (t/is (= ::test-dispatch (:type @test-handled)))
+ (t/is (= "hello" (:hint @test-handled)))))
+
+(t/deftest on-error-wraps-exception-then-dispatches
+ (t/testing "on-error wraps a JS Error into error-data before dispatching"
+ (reset! test-handled nil)
+ (let [err (ex-info "wrapped" {:type ::test-dispatch})]
+ (errors/on-error err)
+ (t/is (= ::test-dispatch (:type @test-handled)))
+ (t/is (identical? err (::errors/instance @test-handled))))))
+
+;; ---------------------------------------------------------------------------
+;; on-error re-entrancy guard
+;;
+;; The guard is implemented via the `handling-error?` volatile inside
+;; app.main.errors. We can verify its effect by registering a
+;; handle-error method that itself calls on-error and checking that
+;; only one invocation gets through.
+;; ---------------------------------------------------------------------------
+
+(def ^:private reentrant-call-count (atom 0))
+
+(defmethod ptk/handle-error ::test-reentrant
+ [_err]
+ (swap! reentrant-call-count inc)
+ ;; Simulate a secondary error inside the error handler
+ ;; (e.g. the notification emit itself throws).
+ ;; Without the re-entrancy guard this would recurse indefinitely.
+ (when (= 1 @reentrant-call-count)
+ (errors/on-error {:type ::test-reentrant :hint "secondary"})))
+
+(t/deftest on-error-reentrancy-guard-prevents-recursion
+ (t/testing "a second on-error call while handling an error is suppressed by the guard"
+ (reset! reentrant-call-count 0)
+ (errors/on-error {:type ::test-reentrant :hint "first"})
+ ;; The guard must have allowed only the first invocation through.
+ (t/is (= 1 @reentrant-call-count))))
diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs
index 3cd38c12f0..ff7a1f0699 100644
--- a/frontend/test/frontend_tests/runner.cljs
+++ b/frontend/test/frontend_tests/runner.cljs
@@ -3,8 +3,10 @@
[cljs.test :as t]
[frontend-tests.basic-shapes-test]
[frontend-tests.data.repo-test]
+ [frontend-tests.data.uploads-test]
[frontend-tests.data.viewer-test]
[frontend-tests.data.workspace-colors-test]
+ [frontend-tests.data.workspace-media-test]
[frontend-tests.data.workspace-texts-test]
[frontend-tests.data.workspace-thumbnails-test]
[frontend-tests.helpers-shapes-test]
@@ -14,6 +16,7 @@
[frontend-tests.logic.frame-guides-test]
[frontend-tests.logic.groups-test]
[frontend-tests.logic.pasting-in-containers-test]
+ [frontend-tests.main-errors-test]
[frontend-tests.plugins.context-shapes-test]
[frontend-tests.svg-fills-test]
[frontend-tests.tokens.import-export-test]
@@ -41,8 +44,11 @@
(t/run-tests
'frontend-tests.basic-shapes-test
'frontend-tests.data.repo-test
+ 'frontend-tests.main-errors-test
+ 'frontend-tests.data.uploads-test
'frontend-tests.data.viewer-test
'frontend-tests.data.workspace-colors-test
+ 'frontend-tests.data.workspace-media-test
'frontend-tests.data.workspace-texts-test
'frontend-tests.data.workspace-thumbnails-test
'frontend-tests.helpers-shapes-test
diff --git a/frontend/test/frontend_tests/tokens/helpers/state.cljs b/frontend/test/frontend_tests/tokens/helpers/state.cljs
index 9de2e773e5..79f0081e9f 100644
--- a/frontend/test/frontend_tests/tokens/helpers/state.cljs
+++ b/frontend/test/frontend_tests/tokens/helpers/state.cljs
@@ -43,7 +43,9 @@
(fn [stream]
(->> stream
#_(rx/tap #(prn (ptk/type %)))
- (rx/filter #(ptk/type? event-type %)))))
+ (rx/filter #(ptk/type? event-type %))
+ ;; Safeguard timeout
+ (rx/timeout 200 (rx/of :the/end)))))
(def stop-on-send-update-indices
"Stops on `send-update-indices` function being called, which should be the last function of an event chain."
diff --git a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs
index 0af65155bf..956a2977a0 100644
--- a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs
+++ b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs
@@ -13,6 +13,7 @@
[app.common.types.text :as txt]
[app.common.types.tokens-lib :as ctob]
[app.main.data.workspace.tokens.application :as dwta]
+ [app.main.data.workspace.wasm-text :as dwwt]
[cljs.test :as t :include-macros true]
[cuerdas.core :as str]
[frontend-tests.helpers.pages :as thp]
@@ -58,8 +59,11 @@
(ctob/add-token (cthi/id :set-a)
(ctob/make-token reference-border-radius-token))))))
+(def debounce-text-stop
+ (tohs/stop-on ::dwwt/resize-wasm-text-debounce-commit))
+
(t/deftest test-apply-token
- (t/testing "applies token to shape and updates shape attributes to resolved value"
+ (t/testing "applies token to shape and updates shape attributes to resolved value"
(t/async
done
(let [file (setup-file-with-tokens)
@@ -553,7 +557,8 @@
(t/is (= (:font-size style-text-blocks) "24"))
(t/testing "WASM text mocks were exercised"
(t/is (pos? (thw/call-count :set-shape-text-content)))
- (t/is (pos? (thw/call-count :get-text-dimensions)))))))))))
+ (t/is (pos? (thw/call-count :get-text-dimensions))))))
+ debounce-text-stop)))))
(t/deftest test-apply-line-height
(t/testing "applies line-height token and updates the text line-height"
@@ -591,7 +596,8 @@
(t/is (= (:line-height style-text-blocks) 1.5))
(t/testing "WASM text mocks were exercised"
(t/is (pos? (thw/call-count :set-shape-text-content)))
- (t/is (pos? (thw/call-count :get-text-dimensions)))))))))))
+ (t/is (pos? (thw/call-count :get-text-dimensions))))))
+ debounce-text-stop)))))
(t/deftest test-apply-letter-spacing
(t/testing "applies letter-spacing token and updates the text letter-spacing"
@@ -629,7 +635,8 @@
(t/is (= (:letter-spacing style-text-blocks) "2"))
(t/testing "WASM text mocks were exercised"
(t/is (pos? (thw/call-count :set-shape-text-content)))
- (t/is (pos? (thw/call-count :get-text-dimensions)))))))))))
+ (t/is (pos? (thw/call-count :get-text-dimensions))))))
+ debounce-text-stop)))))
(t/deftest test-apply-font-family
(t/testing "applies font-family token and updates the text font-family"
@@ -667,7 +674,8 @@
(t/is (= (:font-family style-text-blocks) (:font-id txt/default-text-attrs)))
(t/testing "WASM text mocks were exercised"
(t/is (pos? (thw/call-count :set-shape-text-content)))
- (t/is (pos? (thw/call-count :get-text-dimensions)))))))))))
+ (t/is (pos? (thw/call-count :get-text-dimensions))))))
+ debounce-text-stop)))))
(t/deftest test-apply-text-case
(t/testing "applies text-case token and updates the text transform"
@@ -775,7 +783,8 @@
(t/is (= (:font-weight style-text-blocks) "400"))
(t/testing "WASM text mocks were exercised"
(t/is (pos? (thw/call-count :set-shape-text-content)))
- (t/is (pos? (thw/call-count :get-text-dimensions)))))))))))
+ (t/is (pos? (thw/call-count :get-text-dimensions))))))
+ debounce-text-stop)))))
(t/deftest test-toggle-token-none
(t/testing "should apply token to all selected items, where no item has the token applied"
@@ -1001,7 +1010,8 @@
(t/is (= (:text-decoration style-text-blocks) "underline"))
(t/testing "WASM text mocks were exercised"
(t/is (pos? (thw/call-count :set-shape-text-content)))
- (t/is (pos? (thw/call-count :get-text-dimensions)))))))))))
+ (t/is (pos? (thw/call-count :get-text-dimensions))))))
+ debounce-text-stop)))))
(t/deftest test-apply-reference-typography-token
(t/testing "applies typography (composite) tokens with references"
@@ -1049,7 +1059,8 @@
(t/is (= (:font-family style-text-blocks) "Arial"))
(t/testing "WASM text mocks were exercised"
(t/is (pos? (thw/call-count :set-shape-text-content)))
- (t/is (pos? (thw/call-count :get-text-dimensions)))))))))))
+ (t/is (pos? (thw/call-count :get-text-dimensions))))))
+ debounce-text-stop)))))
(t/deftest test-unapply-atomic-tokens-on-composite-apply
(t/testing "unapplies atomic typography tokens when applying composite token"
@@ -1206,4 +1217,5 @@
(t/is (nil? (:typography-ref-file text-node-3)))
(t/testing "WASM text mocks were exercised"
(t/is (pos? (thw/call-count :set-shape-text-content)))
- (t/is (pos? (thw/call-count :get-text-dimensions)))))))))))
+ (t/is (pos? (thw/call-count :get-text-dimensions))))))
+ debounce-text-stop)))))
diff --git a/render-wasm/src/js/wapi.js b/render-wasm/src/js/wapi.js
index 4af5c0bf89..13f3fcb698 100644
--- a/render-wasm/src/js/wapi.js
+++ b/render-wasm/src/js/wapi.js
@@ -12,5 +12,13 @@ addToLibrary({
} else {
return window.cancelAnimationFrame(frameId);
}
+ },
+ wapi_notifyTilesRenderComplete: function wapi_notifyTilesRenderComplete() {
+ // The corresponding listener lives on `document` (main thread), so in a
+ // worker context we simply skip the dispatch instead of crashing.
+ if (typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope) {
+ return;
+ }
+ document.dispatchEvent(new CustomEvent('penpot:wasm:tiles-complete'));
}
});
diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs
index 747b6018f8..ee3a7815f3 100644
--- a/render-wasm/src/main.rs
+++ b/render-wasm/src/main.rs
@@ -154,6 +154,18 @@ pub extern "C" fn set_antialias_threshold(threshold: f32) -> Result<()> {
Ok(())
}
+#[no_mangle]
+#[wasm_error]
+pub extern "C" fn set_max_atlas_texture_size(max_px: i32) -> Result<()> {
+ with_state_mut!(state, {
+ state
+ .render_state_mut()
+ .surfaces
+ .set_max_atlas_texture_size(max_px);
+ });
+ Ok(())
+}
+
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> {
@@ -854,7 +866,12 @@ pub extern "C" fn set_structure_modifiers() -> Result<()> {
#[wasm_error]
pub extern "C" fn clean_modifiers() -> Result<()> {
with_state_mut!(state, {
- state.shapes.clean_all();
+ let prev_modifier_ids = state.shapes.clean_all();
+ if !prev_modifier_ids.is_empty() {
+ state
+ .render_state
+ .update_tiles_shapes(&prev_modifier_ids, &mut state.shapes)?;
+ }
});
Ok(())
}
diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs
index aee90b6320..5df0326ace 100644
--- a/render-wasm/src/render.rs
+++ b/render-wasm/src/render.rs
@@ -43,6 +43,7 @@ const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3;
const MAX_BLOCKING_TIME_MS: i32 = 32;
const NODE_BATCH_THRESHOLD: i32 = 3;
+
const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0;
type ClipStack = Vec<(Rect, Option
, Matrix)>;
@@ -223,6 +224,7 @@ impl NodeRenderState {
/// - `enter(...)` / `exit(...)` should be called when entering and leaving shape
/// render contexts.
/// - `is_active()` returns whether the current shape is being rendered in focus.
+#[derive(Clone)]
pub struct FocusMode {
shapes: Vec,
active: bool,
@@ -715,12 +717,14 @@ impl RenderState {
// In fast mode the viewport is moving (pan/zoom) so Cache surface
// positions would be wrong — only save to the tile HashMap.
self.surfaces.cache_current_tile_texture(
+ &mut self.gpu_state,
&self.tile_viewbox,
&self
.current_tile
.ok_or(Error::CriticalError("Current tile not found".to_string()))?,
&tile_rect,
fast_mode,
+ self.render_area,
);
self.surfaces.draw_cached_tile_surface(
@@ -968,6 +972,11 @@ impl RenderState {
.draw_rect(bounds, &paint);
}
+ // Uncomment to debug the render_position_data
+ // if let Type::Text(text_content) = &shape.shape_type {
+ // text::render_position_data(self, fills_surface_id, &shape, text_content);
+ // }
+
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas()
.concat(&transform.invert().unwrap_or(Matrix::default()));
@@ -1459,6 +1468,28 @@ impl RenderState {
performance::begin_measure!("render_from_cache");
let cached_scale = self.get_cached_scale();
+ let bg_color = self.background_color;
+
+ // During fast mode (pan/zoom), if a previous full-quality render still has pending tiles,
+ // always prefer the persistent atlas. The atlas is incrementally updated as tiles finish,
+ // and drawing from it avoids mixing a partially-updated Cache surface with missing tiles.
+ if self.options.is_fast_mode() && self.render_in_progress && self.surfaces.has_atlas() {
+ self.surfaces
+ .draw_atlas_to_target(self.viewbox, self.options.dpr(), bg_color);
+
+ if self.options.is_debug_visible() {
+ debug::render(self);
+ }
+
+ ui::render(self, shapes);
+ debug::render_wasm_label(self);
+
+ self.flush_and_submit();
+ performance::end_measure!("render_from_cache");
+ performance::end_timed_log!("render_from_cache", _start);
+ return;
+ }
+
// Check if we have a valid cached viewbox (non-zero dimensions indicate valid cache)
if self.cached_viewbox.area.width() > 0.0 {
// Scale and translate the target according to the cached data
@@ -1475,7 +1506,62 @@ impl RenderState {
let offset_y = self.viewbox.area.top * self.cached_viewbox.zoom * self.options.dpr();
let translate_x = (start_tile_x as f32 * tiles::TILE_SIZE) - offset_x;
let translate_y = (start_tile_y as f32 * tiles::TILE_SIZE) - offset_y;
- let bg_color = self.background_color;
+
+ // For zoom-out, prefer cache only if it fully covers the viewport.
+ // Otherwise, atlas will provide a more correct full-viewport preview.
+ let zooming_out = self.viewbox.zoom < self.cached_viewbox.zoom;
+ if zooming_out {
+ let cache_dim = self.surfaces.cache_dimensions();
+ let cache_w = cache_dim.width as f32;
+ let cache_h = cache_dim.height as f32;
+
+ // Viewport in target pixels.
+ let vw = (self.viewbox.width * self.options.dpr()).max(1.0);
+ let vh = (self.viewbox.height * self.options.dpr()).max(1.0);
+
+ // Inverse-map viewport corners into cache coordinates.
+ // target = (cache * navigate_zoom) translated by (translate_x, translate_y) (in cache coords).
+ // => cache = (target / navigate_zoom) - translate
+ let inv = if navigate_zoom.abs() > f32::EPSILON {
+ 1.0 / navigate_zoom
+ } else {
+ 0.0
+ };
+
+ let cx0 = (0.0 * inv) - translate_x;
+ let cy0 = (0.0 * inv) - translate_y;
+ let cx1 = (vw * inv) - translate_x;
+ let cy1 = (vh * inv) - translate_y;
+
+ let min_x = cx0.min(cx1);
+ let min_y = cy0.min(cy1);
+ let max_x = cx0.max(cx1);
+ let max_y = cy0.max(cy1);
+
+ let cache_covers =
+ min_x >= 0.0 && min_y >= 0.0 && max_x <= cache_w && max_y <= cache_h;
+ if !cache_covers {
+ // Early return only if atlas exists; otherwise keep cache path.
+ if self.surfaces.has_atlas() {
+ self.surfaces.draw_atlas_to_target(
+ self.viewbox,
+ self.options.dpr(),
+ bg_color,
+ );
+
+ if self.options.is_debug_visible() {
+ debug::render(self);
+ }
+
+ ui::render(self, shapes);
+ debug::render_wasm_label(self);
+ self.flush_and_submit();
+ performance::end_measure!("render_from_cache");
+ performance::end_timed_log!("render_from_cache", _start);
+ return;
+ }
+ }
+ }
// Setup canvas transform
{
@@ -1531,6 +1617,7 @@ impl RenderState {
self.flush_and_submit();
}
+
performance::end_measure!("render_from_cache");
performance::end_timed_log!("render_from_cache", _start);
}
@@ -1670,6 +1757,7 @@ impl RenderState {
self.cancel_animation_frame();
self.render_request_id = Some(wapi::request_animation_frame!());
} else {
+ wapi::notify_tiles_render_complete!();
performance::end_measure!("render");
}
}
@@ -1687,7 +1775,6 @@ impl RenderState {
self.render_shape_tree_partial(base_object, tree, timestamp, false)?;
}
self.flush_and_submit();
-
Ok(())
}
@@ -1700,6 +1787,26 @@ impl RenderState {
) -> Result<(Vec, i32, i32)> {
let target_surface = SurfaceId::Export;
+ // `render_shape_pixels` is used by the workspace to render thumbnails using the
+ // same WASM renderer instance. It must not leak any state into the main
+ // viewport renderer (tile cache, atlas, focus mode, render context, etc.).
+ //
+ // In particular, `update_render_context` clears and reconfigures multiple
+ // render surfaces, and `render_area` drives atlas blits. If we don't restore
+ // them, the workspace can temporarily show missing tiles until the next
+ // interaction (e.g. zoom) forces a full context rebuild.
+ let saved_focus_mode = self.focus_mode.clone();
+ let saved_export_context = self.export_context;
+ let saved_render_area = self.render_area;
+ let saved_render_area_with_margins = self.render_area_with_margins;
+ let saved_current_tile = self.current_tile;
+ let saved_pending_nodes = std::mem::take(&mut self.pending_nodes);
+ let saved_nested_fills = std::mem::take(&mut self.nested_fills);
+ let saved_nested_blurs = std::mem::take(&mut self.nested_blurs);
+ let saved_nested_shadows = std::mem::take(&mut self.nested_shadows);
+ let saved_ignore_nested_blurs = self.ignore_nested_blurs;
+ let saved_preview_mode = self.preview_mode;
+
// Reset focus mode so all shapes in the export tree are rendered.
// Without this, leftover focus_mode state from the workspace could
// cause shapes (and their background blur) to be skipped.
@@ -1751,6 +1858,30 @@ impl RenderState {
.expect("PNG encode failed");
let skia::ISize { width, height } = image.dimensions();
+ // Restore the workspace render state.
+ self.focus_mode = saved_focus_mode;
+ self.export_context = saved_export_context;
+ self.render_area = saved_render_area;
+ self.render_area_with_margins = saved_render_area_with_margins;
+ self.current_tile = saved_current_tile;
+ self.pending_nodes = saved_pending_nodes;
+ self.nested_fills = saved_nested_fills;
+ self.nested_blurs = saved_nested_blurs;
+ self.nested_shadows = saved_nested_shadows;
+ self.ignore_nested_blurs = saved_ignore_nested_blurs;
+ self.preview_mode = saved_preview_mode;
+
+ // Restore render-surface transforms for the workspace context.
+ // If we have a current tile, restore its tile render context; otherwise
+ // fall back to restoring the previous render_area (may be empty).
+ let workspace_scale = self.get_scale();
+ if let Some(tile) = self.current_tile {
+ self.update_render_context(tile);
+ } else if !self.render_area.is_empty() {
+ self.surfaces
+ .update_render_context(self.render_area, workspace_scale);
+ }
+
Ok((data.as_bytes().to_vec(), width, height))
}
@@ -2454,6 +2585,7 @@ impl RenderState {
let has_effects = transformed_element.has_effects_that_extend_bounds();
let is_visible = export
+ || mask
|| if is_container || has_effects {
let element_extrect =
extrect.get_or_insert_with(|| transformed_element.extrect(tree, scale));
@@ -2699,13 +2831,8 @@ impl RenderState {
}
} else {
performance::begin_measure!("render_shape_tree::uncached");
- // Only allow stopping (yielding) if the current tile is NOT visible.
- // This ensures all visible tiles render synchronously before showing,
- // eliminating empty squares during zoom. Interest-area tiles can still yield.
- let tile_is_visible = self.tile_viewbox.is_visible(¤t_tile);
- let can_stop = allow_stop && !tile_is_visible;
- let (is_empty, early_return) =
- self.render_shape_tree_partial_uncached(tree, timestamp, can_stop, false)?;
+ let (is_empty, early_return) = self
+ .render_shape_tree_partial_uncached(tree, timestamp, allow_stop, false)?;
if early_return {
return Ok(());
@@ -2729,6 +2856,24 @@ impl RenderState {
paint.set_color(self.background_color);
s.canvas().draw_rect(tile_rect, &paint);
});
+ // Keep Cache surface coherent for render_from_cache.
+ if !self.options.is_fast_mode() {
+ if !self.cache_cleared_this_render {
+ self.surfaces.clear_cache(self.background_color);
+ self.cache_cleared_this_render = true;
+ }
+ let aligned_rect = self.get_aligned_tile_bounds(current_tile);
+ self.surfaces.apply_mut(SurfaceId::Cache as u32, |s| {
+ let mut paint = skia::Paint::default();
+ paint.set_color(self.background_color);
+ s.canvas().draw_rect(aligned_rect, &paint);
+ });
+ }
+
+ // Clear atlas region to transparent so background shows through.
+ let _ = self
+ .surfaces
+ .clear_doc_rect_in_atlas(&mut self.gpu_state, self.render_area);
}
}
}
diff --git a/render-wasm/src/render/debug.rs b/render-wasm/src/render/debug.rs
index 47b739b484..f374e32af3 100644
--- a/render-wasm/src/render/debug.rs
+++ b/render-wasm/src/render/debug.rs
@@ -194,6 +194,15 @@ pub fn console_debug_surface(render_state: &mut RenderState, id: SurfaceId) {
run_script!(format!("console.log('%c ', 'font-size: 1px; background: url(data:image/png;base64,{base64_image}) no-repeat; padding: 100px; background-size: contain;')"));
}
+pub fn console_debug_surface_base64(render_state: &mut RenderState, id: SurfaceId) {
+ let base64_image = render_state
+ .surfaces
+ .base64_snapshot(id)
+ .expect("Failed to get base64 image");
+
+ println!("{}", base64_image);
+}
+
#[allow(dead_code)]
#[cfg(target_arch = "wasm32")]
pub fn console_debug_surface_rect(render_state: &mut RenderState, id: SurfaceId, rect: skia::Rect) {
@@ -223,3 +232,33 @@ pub extern "C" fn debug_cache_console() -> Result<()> {
});
Ok(())
}
+
+#[no_mangle]
+#[wasm_error]
+#[cfg(target_arch = "wasm32")]
+pub extern "C" fn debug_cache_base64() -> Result<()> {
+ with_state_mut!(state, {
+ console_debug_surface_base64(state.render_state_mut(), SurfaceId::Cache);
+ });
+ Ok(())
+}
+
+#[no_mangle]
+#[wasm_error]
+#[cfg(target_arch = "wasm32")]
+pub extern "C" fn debug_atlas_console() -> Result<()> {
+ with_state_mut!(state, {
+ console_debug_surface(state.render_state_mut(), SurfaceId::Atlas);
+ });
+ Ok(())
+}
+
+#[no_mangle]
+#[wasm_error]
+#[cfg(target_arch = "wasm32")]
+pub extern "C" fn debug_atlas_base64() -> Result<()> {
+ with_state_mut!(state, {
+ console_debug_surface_base64(state.render_state_mut(), SurfaceId::Atlas);
+ });
+ Ok(())
+}
diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs
index 7b1a49f540..27454ec90f 100644
--- a/render-wasm/src/render/options.rs
+++ b/render-wasm/src/render/options.rs
@@ -19,7 +19,7 @@ impl Default for RenderOptions {
flags: 0,
dpr: None,
fast_mode: false,
- antialias_threshold: 15.0,
+ antialias_threshold: 7.0,
}
}
}
diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs
index 1c5a77c72c..e3a9512e08 100644
--- a/render-wasm/src/render/surfaces.rs
+++ b/render-wasm/src/render/surfaces.rs
@@ -1,6 +1,7 @@
use crate::error::{Error, Result};
use crate::performance;
use crate::shapes::Shape;
+use crate::view::Viewbox;
use skia_safe::{self as skia, IRect, Paint, RRect};
@@ -15,6 +16,16 @@ const TEXTURES_BATCH_DELETE: usize = 256;
// If it's too big it could affect performance.
const TILE_SIZE_MULTIPLIER: i32 = 2;
+/// Atlas texture size limits (px per side).
+///
+/// - `DEFAULT_MAX_ATLAS_TEXTURE_SIZE` is the startup fallback used until the
+/// frontend reads the real `gl.MAX_TEXTURE_SIZE` and sends it via
+/// [`Surfaces::set_max_atlas_texture_size`].
+/// - `MAX_ATLAS_TEXTURE_SIZE` is a hard upper bound to clamp the runtime value
+/// (defensive cap to avoid accidentally creating oversized GPU textures).
+const MAX_ATLAS_TEXTURE_SIZE: i32 = 4096;
+const DEFAULT_MAX_ATLAS_TEXTURE_SIZE: i32 = 1024;
+
#[repr(u32)]
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum SurfaceId {
@@ -30,6 +41,7 @@ pub enum SurfaceId {
Export = 0b010_0000_0000,
UI = 0b100_0000_0000,
Debug = 0b100_0000_0001,
+ Atlas = 0b100_0000_0010,
}
pub struct Surfaces {
@@ -57,6 +69,18 @@ pub struct Surfaces {
export: skia::Surface,
tiles: TileTextureCache,
+ // Persistent 1:1 document-space atlas that gets incrementally updated as tiles render.
+ // It grows dynamically to include any rendered document rect.
+ atlas: skia::Surface,
+ atlas_origin: skia::Point,
+ atlas_size: skia::ISize,
+ /// Atlas pixel density relative to document pixels (1.0 == 1:1 doc px).
+ /// When the atlas would exceed `max_atlas_texture_size`, this value is
+ /// reduced so the atlas stays within the fixed texture cap.
+ atlas_scale: f32,
+ /// Max width/height in pixels for the atlas surface (typically browser
+ /// `MAX_TEXTURE_SIZE`). Set from ClojureScript after WebGL context creation.
+ max_atlas_texture_size: i32,
sampling_options: skia::SamplingOptions,
pub margins: skia::ISize,
// Tracks which surfaces have content (dirty flag bitmask)
@@ -99,6 +123,10 @@ impl Surfaces {
let ui = gpu_state.create_surface_with_dimensions("ui".to_string(), width, height)?;
let debug = gpu_state.create_surface_with_dimensions("debug".to_string(), width, height)?;
+ // Keep atlas as a regular surface like the rest. Start with a tiny
+ // transparent surface and grow it on demand.
+ let mut atlas = gpu_state.create_surface_with_dimensions("atlas".to_string(), 1, 1)?;
+ atlas.canvas().clear(skia::Color::TRANSPARENT);
let tiles = TileTextureCache::new();
Ok(Surfaces {
@@ -115,6 +143,11 @@ impl Surfaces {
debug,
export,
tiles,
+ atlas,
+ atlas_origin: skia::Point::new(0.0, 0.0),
+ atlas_size: skia::ISize::new(0, 0),
+ atlas_scale: 1.0,
+ max_atlas_texture_size: DEFAULT_MAX_ATLAS_TEXTURE_SIZE,
sampling_options,
margins,
dirty_surfaces: 0,
@@ -122,10 +155,212 @@ impl Surfaces {
})
}
+ /// Sets the maximum atlas texture dimension (one side). Should match the
+ /// WebGL `MAX_TEXTURE_SIZE` reported by the browser. Values are clamped to
+ /// a small minimum so the atlas logic stays well-defined.
+ pub fn set_max_atlas_texture_size(&mut self, max_px: i32) {
+ self.max_atlas_texture_size = max_px.clamp(TILE_SIZE as i32, MAX_ATLAS_TEXTURE_SIZE);
+ }
+
+ fn ensure_atlas_contains(
+ &mut self,
+ gpu_state: &mut GpuState,
+ doc_rect: skia::Rect,
+ ) -> Result<()> {
+ if doc_rect.is_empty() {
+ return Ok(());
+ }
+
+ // Current atlas bounds in document space (1 unit == 1 px).
+ let current_left = self.atlas_origin.x;
+ let current_top = self.atlas_origin.y;
+ let atlas_scale = self.atlas_scale.max(0.01);
+ let current_right = current_left + (self.atlas_size.width as f32) / atlas_scale;
+ let current_bottom = current_top + (self.atlas_size.height as f32) / atlas_scale;
+
+ let mut new_left = current_left;
+ let mut new_top = current_top;
+ let mut new_right = current_right;
+ let mut new_bottom = current_bottom;
+
+ // If atlas is empty/uninitialized, seed to rect (expanded to tile boundaries for fewer reallocs).
+ let needs_init = self.atlas_size.width <= 0 || self.atlas_size.height <= 0;
+ if needs_init {
+ new_left = doc_rect.left.floor();
+ new_top = doc_rect.top.floor();
+ new_right = doc_rect.right.ceil();
+ new_bottom = doc_rect.bottom.ceil();
+ } else {
+ new_left = new_left.min(doc_rect.left.floor());
+ new_top = new_top.min(doc_rect.top.floor());
+ new_right = new_right.max(doc_rect.right.ceil());
+ new_bottom = new_bottom.max(doc_rect.bottom.ceil());
+ }
+
+ // Add padding to reduce realloc frequency.
+ let pad = TILE_SIZE;
+ new_left -= pad;
+ new_top -= pad;
+ new_right += pad;
+ new_bottom += pad;
+
+ let doc_w = (new_right - new_left).max(1.0);
+ let doc_h = (new_bottom - new_top).max(1.0);
+
+ // Compute atlas scale needed to fit within the fixed texture cap.
+ // Keep the highest possible scale (closest to 1.0) that still fits.
+ let cap = self.max_atlas_texture_size.max(TILE_SIZE as i32) as f32;
+ let required_scale = (cap / doc_w).min(cap / doc_h).clamp(0.01, 1.0);
+
+ // Never upscale the atlas (it would add blur and churn).
+ let new_scale = self.atlas_scale.min(required_scale).max(0.01);
+
+ let new_w = (doc_w * new_scale).ceil().clamp(1.0, cap) as i32;
+ let new_h = (doc_h * new_scale).ceil().clamp(1.0, cap) as i32;
+
+ // Fast path: existing atlas already contains the rect.
+ if !needs_init
+ && doc_rect.left >= current_left
+ && doc_rect.top >= current_top
+ && doc_rect.right <= current_right
+ && doc_rect.bottom <= current_bottom
+ {
+ return Ok(());
+ }
+
+ let mut new_atlas =
+ gpu_state.create_surface_with_dimensions("atlas".to_string(), new_w, new_h)?;
+ new_atlas.canvas().clear(skia::Color::TRANSPARENT);
+
+ // Copy old atlas into the new one with offset.
+ if !needs_init {
+ let old_scale = self.atlas_scale.max(0.01);
+ let scale_ratio = new_scale / old_scale;
+ let dx = (current_left - new_left) * new_scale;
+ let dy = (current_top - new_top) * new_scale;
+
+ let image = self.atlas.image_snapshot();
+ let src = skia::Rect::from_xywh(
+ 0.0,
+ 0.0,
+ self.atlas_size.width as f32,
+ self.atlas_size.height as f32,
+ );
+ let dst = skia::Rect::from_xywh(
+ dx,
+ dy,
+ (self.atlas_size.width as f32) * scale_ratio,
+ (self.atlas_size.height as f32) * scale_ratio,
+ );
+ new_atlas.canvas().draw_image_rect(
+ &image,
+ Some((&src, skia::canvas::SrcRectConstraint::Fast)),
+ dst,
+ &skia::Paint::default(),
+ );
+ }
+
+ self.atlas_origin = skia::Point::new(new_left, new_top);
+ self.atlas_size = skia::ISize::new(new_w, new_h);
+ self.atlas_scale = new_scale;
+ self.atlas = new_atlas;
+ Ok(())
+ }
+
+ fn blit_tile_image_into_atlas(
+ &mut self,
+ gpu_state: &mut GpuState,
+ tile_image: &skia::Image,
+ doc_rect: skia::Rect,
+ ) -> Result<()> {
+ self.ensure_atlas_contains(gpu_state, doc_rect)?;
+
+ // Destination is document-space rect mapped into atlas pixel coords.
+ let dst = skia::Rect::from_xywh(
+ (doc_rect.left - self.atlas_origin.x) * self.atlas_scale,
+ (doc_rect.top - self.atlas_origin.y) * self.atlas_scale,
+ doc_rect.width() * self.atlas_scale,
+ doc_rect.height() * self.atlas_scale,
+ );
+
+ self.atlas
+ .canvas()
+ .draw_image_rect(tile_image, None, dst, &skia::Paint::default());
+ Ok(())
+ }
+
+ pub fn clear_doc_rect_in_atlas(
+ &mut self,
+ gpu_state: &mut GpuState,
+ doc_rect: skia::Rect,
+ ) -> Result<()> {
+ if doc_rect.is_empty() {
+ return Ok(());
+ }
+
+ self.ensure_atlas_contains(gpu_state, doc_rect)?;
+
+ // Destination is document-space rect mapped into atlas pixel coords.
+ let dst = skia::Rect::from_xywh(
+ (doc_rect.left - self.atlas_origin.x) * self.atlas_scale,
+ (doc_rect.top - self.atlas_origin.y) * self.atlas_scale,
+ doc_rect.width() * self.atlas_scale,
+ doc_rect.height() * self.atlas_scale,
+ );
+
+ let canvas = self.atlas.canvas();
+ canvas.save();
+ canvas.clip_rect(dst, None, true);
+ canvas.clear(skia::Color::TRANSPARENT);
+ canvas.restore();
+ Ok(())
+ }
+
pub fn clear_tiles(&mut self) {
self.tiles.clear();
}
+ pub fn has_atlas(&self) -> bool {
+ self.atlas_size.width > 0 && self.atlas_size.height > 0
+ }
+
+ /// Draw the persistent atlas onto the target using the current viewbox transform.
+ /// Intended for fast pan/zoom-out previews (avoids per-tile composition).
+ pub fn draw_atlas_to_target(&mut self, viewbox: Viewbox, dpr: f32, background: skia::Color) {
+ if !self.has_atlas() {
+ return;
+ };
+
+ let canvas = self.target.canvas();
+ canvas.save();
+ canvas.reset_matrix();
+ let size = canvas.base_layer_size();
+ canvas.clip_rect(
+ skia::Rect::from_xywh(0.0, 0.0, size.width as f32, size.height as f32),
+ None,
+ true,
+ );
+
+ let s = viewbox.zoom * dpr;
+ let atlas_scale = self.atlas_scale.max(0.01);
+
+ canvas.clear(background);
+ canvas.translate((
+ (self.atlas_origin.x + viewbox.pan_x) * s,
+ (self.atlas_origin.y + viewbox.pan_y) * s,
+ ));
+ canvas.scale((s / atlas_scale, s / atlas_scale));
+
+ self.atlas.draw(
+ canvas,
+ (0.0, 0.0),
+ self.sampling_options,
+ Some(&skia::Paint::default()),
+ );
+
+ canvas.restore();
+ }
+
pub fn margins(&self) -> skia::ISize {
self.margins
}
@@ -255,6 +490,10 @@ impl Surfaces {
);
}
+ pub fn cache_dimensions(&self) -> skia::ISize {
+ skia::ISize::new(self.cache.width(), self.cache.height())
+ }
+
pub fn apply_mut(&mut self, ids: u32, mut f: impl FnMut(&mut skia::Surface)) {
performance::begin_measure!("apply_mut::flags");
if ids & SurfaceId::Target as u32 != 0 {
@@ -352,6 +591,7 @@ impl Surfaces {
SurfaceId::Debug => &mut self.debug,
SurfaceId::UI => &mut self.ui,
SurfaceId::Export => &mut self.export,
+ SurfaceId::Atlas => &mut self.atlas,
}
}
@@ -369,6 +609,7 @@ impl Surfaces {
SurfaceId::Debug => &self.debug,
SurfaceId::UI => &self.ui,
SurfaceId::Export => &self.export,
+ SurfaceId::Atlas => &self.atlas,
}
}
@@ -546,10 +787,12 @@ impl Surfaces {
pub fn cache_current_tile_texture(
&mut self,
+ gpu_state: &mut GpuState,
tile_viewbox: &TileViewbox,
tile: &Tile,
tile_rect: &skia::Rect,
skip_cache_surface: bool,
+ tile_doc_rect: skia::Rect,
) {
let rect = IRect::from_xywh(
self.margins.width,
@@ -571,6 +814,9 @@ impl Surfaces {
);
}
+ // Incrementally update persistent 1:1 atlas in document space.
+ // `tile_doc_rect` is in world/document coordinates (1 unit == 1 px at 100%).
+ let _ = self.blit_tile_image_into_atlas(gpu_state, &tile_image, tile_doc_rect);
self.tiles.add(tile_viewbox, tile, tile_image);
}
}
diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs
index 40bdb6d2ec..541747229a 100644
--- a/render-wasm/src/shapes/text.rs
+++ b/render-wasm/src/shapes/text.rs
@@ -1551,7 +1551,7 @@ pub fn calculate_text_layout_data(
for (span_index, span) in text_para.children().iter().enumerate() {
let text: String = span.apply_text_transform();
let text_len = text.encode_utf16().count();
- span_ranges.push((cur, cur + text_len + 1, span_index));
+ span_ranges.push((cur, cur + text_len, span_index));
cur += text_len;
}
for (start, end, span_index) in span_ranges {
diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs
index 74b81d6336..c624dad43d 100644
--- a/render-wasm/src/state.rs
+++ b/render-wasm/src/state.rs
@@ -160,14 +160,24 @@ impl State {
// Only remove the children when is being deleted from the owner
if shape.parent_id.is_none() || shape.parent_id == Some(parent_id) {
- let tiles::TileRect(rsx, rsy, rex, rey) =
- self.render_state.get_tiles_for_shape(shape, &self.shapes);
- for x in rsx..=rex {
- for y in rsy..=rey {
- let tile = tiles::Tile(x, y);
- self.render_state.remove_cached_tile(tile);
- self.render_state.tiles.remove_shape_at(tile, shape.id);
- }
+ // IMPORTANT:
+ // Do NOT use `get_tiles_for_shape` here. That method intersects the shape
+ // tiles with the current interest area, which means we'd only invalidate
+ // the subset currently near the viewport. When the user later pans/zooms
+ // to reveal previously cached tiles, stale pixels could reappear.
+ //
+ // Instead, remove the shape from *all* tiles where it was indexed, and
+ // drop cached tiles for those entries.
+ let indexed_tiles: Vec = self
+ .render_state
+ .tiles
+ .get_tiles_of(shape.id)
+ .map(|t| t.iter().copied().collect())
+ .unwrap_or_default();
+
+ for tile in indexed_tiles {
+ self.render_state.remove_cached_tile(tile);
+ self.render_state.tiles.remove_shape_at(tile, shape.id);
}
if let Some(shape_to_delete) = self.shapes.get(&id) {
diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs
index 436d57f2ea..7e03befa01 100644
--- a/render-wasm/src/state/shapes_pool.rs
+++ b/render-wasm/src/state/shapes_pool.rs
@@ -278,11 +278,35 @@ impl ShapesPoolImpl {
}
}
- pub fn clean_all(&mut self) {
+ /// Clears transient per-frame state (modifiers, structure, scale_content)
+ /// and returns the list of UUIDs that had a `modifier` applied at the
+ /// moment of cleaning. The caller can use that list to re-sync the tile
+ /// index / tile cache for those shapes: after cleaning their modifier is
+ /// gone, but if we don't touch their tiles they keep pointing at the
+ /// previous modified position and the tile texture cache may serve stale
+ /// pixels.
+ pub fn clean_all(&mut self) -> Vec {
self.clean_shape_cache();
+
+ let modified_uuids: Vec = if self.modifiers.is_empty() {
+ Vec::new()
+ } else {
+ let mut idx_to_uuid: HashMap =
+ HashMap::with_capacity(self.uuid_to_idx.len());
+ for (uuid, idx) in self.uuid_to_idx.iter() {
+ idx_to_uuid.insert(*idx, *uuid);
+ }
+ self.modifiers
+ .keys()
+ .filter_map(|idx| idx_to_uuid.get(idx).copied())
+ .collect()
+ };
+
self.modifiers = HashMap::default();
self.structure = HashMap::default();
self.scale_content = HashMap::default();
+
+ modified_uuids
}
pub fn subtree(&self, id: &Uuid) -> ShapesPoolImpl {
diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs
index 82e7daf1ad..0f89a25d41 100644
--- a/render-wasm/src/state/text_editor.rs
+++ b/render-wasm/src/state/text_editor.rs
@@ -117,6 +117,7 @@ pub struct TextEditorStyles {
pub font_variant_id: Multiple,
pub line_height: Multiple,
pub letter_spacing: Multiple,
+ pub fills_are_multiple: bool,
pub fills: Vec,
}
@@ -233,6 +234,7 @@ impl TextEditorStyles {
font_variant_id: Multiple::empty(),
line_height: Multiple::empty(),
letter_spacing: Multiple::empty(),
+ fills_are_multiple: false,
fills: Vec::new(),
}
}
@@ -248,6 +250,7 @@ impl TextEditorStyles {
self.font_variant_id.reset();
self.line_height.reset();
self.letter_spacing.reset();
+ self.fills_are_multiple = false;
self.fills.clear();
}
}
@@ -324,6 +327,7 @@ pub struct TextEditorState {
// This property indicates that we've started
// selecting something with the pointer.
pub is_pointer_selection_active: bool,
+ pub is_click_event_skipped: bool,
pub active_shape_id: Option,
pub cursor_visible: bool,
pub last_blink_time: f64,
@@ -343,6 +347,7 @@ impl TextEditorState {
composition: TextComposition::new(),
has_focus: false,
is_pointer_selection_active: false,
+ is_click_event_skipped: false,
active_shape_id: None,
cursor_visible: true,
last_blink_time: 0.0,
@@ -529,11 +534,7 @@ impl TextEditorState {
let end_paragraph = end.paragraph.min(paragraphs.len() - 1);
self.current_styles.reset();
-
let mut has_selected_content = false;
- let mut has_fills = false;
- let mut fills_are_multiple = false;
-
for (para_idx, paragraph) in paragraphs
.iter()
.enumerate()
@@ -606,14 +607,11 @@ impl TextEditorState {
.letter_spacing
.merge(Some(span.letter_spacing));
- if !fills_are_multiple {
- if !has_fills {
- self.current_styles.fills = span.fills.clone();
- has_fills = true;
- } else if self.current_styles.fills != span.fills {
- fills_are_multiple = true;
- self.current_styles.fills.clear();
- }
+ if self.current_styles.fills.is_empty() {
+ self.current_styles.fills.append(&mut span.fills.clone());
+ } else if self.current_styles.fills != span.fills {
+ self.current_styles.fills_are_multiple = true;
+ self.current_styles.fills.append(&mut span.fills.clone());
}
}
}
@@ -630,6 +628,7 @@ impl TextEditorState {
let current_offset = focus.offset;
let current_text_span = find_text_span_at_offset(current_paragraph, current_offset);
+ self.current_styles.reset();
self.current_styles
.text_align
.set_single(Some(current_paragraph.text_align()));
diff --git a/render-wasm/src/wapi.rs b/render-wasm/src/wapi.rs
index 1947f7e3c6..f9e7e65769 100644
--- a/render-wasm/src/wapi.rs
+++ b/render-wasm/src/wapi.rs
@@ -35,5 +35,21 @@ macro_rules! cancel_animation_frame {
};
}
+#[macro_export]
+macro_rules! notify_tiles_render_complete {
+ () => {{
+ #[cfg(target_arch = "wasm32")]
+ unsafe extern "C" {
+ pub fn wapi_notifyTilesRenderComplete();
+ }
+
+ #[cfg(target_arch = "wasm32")]
+ unsafe {
+ wapi_notifyTilesRenderComplete()
+ };
+ }};
+}
+
pub use cancel_animation_frame;
+pub use notify_tiles_render_complete;
pub use request_animation_frame;
diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs
index 9e364e8fb7..21c8566ec1 100644
--- a/render-wasm/src/wasm/text_editor.rs
+++ b/render-wasm/src/wasm/text_editor.rs
@@ -208,16 +208,20 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) {
if !state.text_editor_state.has_focus {
return;
}
+
let point = Point::new(x, y);
let Some(shape_id) = state.text_editor_state.active_shape_id else {
return;
};
+
let Some(shape) = state.shapes.get(&shape_id) else {
return;
};
+
if !state.text_editor_state.is_pointer_selection_active {
return;
}
+
let Type::Text(text_content) = &shape.shape_type else {
return;
};
@@ -226,6 +230,9 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) {
state
.text_editor_state
.extend_selection_from_position(&position);
+ // We need this flag to prevent handling the click behavior
+ // just after a pointerup event.
+ state.text_editor_state.is_click_event_skipped = true;
state.text_editor_state.update_styles(text_content);
}
});
@@ -263,6 +270,13 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) {
#[no_mangle]
pub extern "C" fn text_editor_set_cursor_from_offset(x: f32, y: f32) {
with_state_mut!(state, {
+ // We need this flag to prevent handling the click behavior
+ // just after a pointerup event.
+ if state.text_editor_state.is_click_event_skipped {
+ state.text_editor_state.is_click_event_skipped = false;
+ return;
+ }
+
if !state.text_editor_state.has_focus {
return;
}
@@ -271,12 +285,15 @@ pub extern "C" fn text_editor_set_cursor_from_offset(x: f32, y: f32) {
let Some(shape_id) = state.text_editor_state.active_shape_id else {
return;
};
+
let Some(shape) = state.shapes.get(&shape_id) else {
return;
};
+
let Type::Text(text_content) = &shape.shape_type else {
return;
};
+
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
state.text_editor_state.set_caret_from_position(&position);
}
@@ -769,6 +786,7 @@ pub extern "C" fn text_editor_get_current_styles() -> *mut u8 {
}
let mut fill_bytes = Vec::new();
+ let fill_multiple = styles.fills_are_multiple;
let mut fill_count: u32 = 0;
for fill in &styles.fills {
if let Ok(raw_fill) = RawFillData::try_from(fill) {
@@ -781,39 +799,41 @@ pub extern "C" fn text_editor_get_current_styles() -> *mut u8 {
// Layout: 48-byte fixed header + fixed values + serialized fills.
let mut bytes = Vec::with_capacity(132 + fill_bytes.len());
- bytes.extend_from_slice(&vertical_align.to_le_bytes());
- bytes.extend_from_slice(&(*styles.text_align.state() as u32).to_le_bytes());
- bytes.extend_from_slice(&(*styles.text_direction.state() as u32).to_le_bytes());
- bytes.extend_from_slice(&(*styles.text_decoration.state() as u32).to_le_bytes());
- bytes.extend_from_slice(&(*styles.text_transform.state() as u32).to_le_bytes());
- bytes.extend_from_slice(&(*styles.font_family.state() as u32).to_le_bytes());
- bytes.extend_from_slice(&(*styles.font_size.state() as u32).to_le_bytes());
- bytes.extend_from_slice(&(*styles.font_weight.state() as u32).to_le_bytes());
- bytes.extend_from_slice(&(*styles.font_variant_id.state() as u32).to_le_bytes());
- bytes.extend_from_slice(&(*styles.line_height.state() as u32).to_le_bytes());
- bytes.extend_from_slice(&(*styles.letter_spacing.state() as u32).to_le_bytes());
- bytes.extend_from_slice(&fill_count.to_le_bytes());
+ // Header data // offset // index
+ bytes.extend_from_slice(&vertical_align.to_le_bytes()); // 0 // 0
+ bytes.extend_from_slice(&(*styles.text_align.state() as u32).to_le_bytes()); // 4 // 1
+ bytes.extend_from_slice(&(*styles.text_direction.state() as u32).to_le_bytes()); // 8 // 2
+ bytes.extend_from_slice(&(*styles.text_decoration.state() as u32).to_le_bytes()); // 12 // 3
+ bytes.extend_from_slice(&(*styles.text_transform.state() as u32).to_le_bytes()); // 16 // 4
+ bytes.extend_from_slice(&(*styles.font_family.state() as u32).to_le_bytes()); // 20 // 5
+ bytes.extend_from_slice(&(*styles.font_size.state() as u32).to_le_bytes()); // 24 // 6
+ bytes.extend_from_slice(&(*styles.font_weight.state() as u32).to_le_bytes()); // 28 // 7
+ bytes.extend_from_slice(&(*styles.font_variant_id.state() as u32).to_le_bytes()); // 32 // 8
+ bytes.extend_from_slice(&(*styles.line_height.state() as u32).to_le_bytes()); // 36 // 9
+ bytes.extend_from_slice(&(*styles.letter_spacing.state() as u32).to_le_bytes()); // 40 // 10
+ bytes.extend_from_slice(&fill_count.to_le_bytes()); // 44 // 11
+ bytes.extend_from_slice(&(fill_multiple as u32).to_le_bytes()); // 48 // 12
// Value section.
- bytes.extend_from_slice(&text_align.to_le_bytes());
- bytes.extend_from_slice(&text_direction.to_le_bytes());
- bytes.extend_from_slice(&text_decoration.to_le_bytes());
- bytes.extend_from_slice(&text_transform.to_le_bytes());
- bytes.extend_from_slice(&font_family_id[0].to_le_bytes());
- bytes.extend_from_slice(&font_family_id[1].to_le_bytes());
- bytes.extend_from_slice(&font_family_id[2].to_le_bytes());
- bytes.extend_from_slice(&font_family_id[3].to_le_bytes());
- bytes.extend_from_slice(&font_family_style.to_le_bytes());
- bytes.extend_from_slice(&font_family_weight.to_le_bytes());
- bytes.extend_from_slice(&font_size.to_le_bytes());
- bytes.extend_from_slice(&font_weight.to_le_bytes());
- bytes.extend_from_slice(&font_variant_id[0].to_le_bytes());
- bytes.extend_from_slice(&font_variant_id[1].to_le_bytes());
- bytes.extend_from_slice(&font_variant_id[2].to_le_bytes());
- bytes.extend_from_slice(&font_variant_id[3].to_le_bytes());
- bytes.extend_from_slice(&line_height.to_le_bytes());
- bytes.extend_from_slice(&letter_spacing.to_le_bytes());
- bytes.extend_from_slice(&fill_bytes);
+ bytes.extend_from_slice(&text_align.to_le_bytes()); // 52 // 13
+ bytes.extend_from_slice(&text_direction.to_le_bytes()); // 56 // 14
+ bytes.extend_from_slice(&text_decoration.to_le_bytes()); // 60 // 15
+ bytes.extend_from_slice(&text_transform.to_le_bytes()); // 64 // 16
+ bytes.extend_from_slice(&font_family_id[0].to_le_bytes()); // 68 // 17
+ bytes.extend_from_slice(&font_family_id[1].to_le_bytes()); // 72 // 18
+ bytes.extend_from_slice(&font_family_id[2].to_le_bytes()); // 76 // 19
+ bytes.extend_from_slice(&font_family_id[3].to_le_bytes()); // 80 // 20
+ bytes.extend_from_slice(&font_family_style.to_le_bytes()); // 84 // 21
+ bytes.extend_from_slice(&font_family_weight.to_le_bytes()); // 88 // 22
+ bytes.extend_from_slice(&font_size.to_le_bytes()); // 92 // 23
+ bytes.extend_from_slice(&font_weight.to_le_bytes()); // 96 // 24
+ bytes.extend_from_slice(&font_variant_id[0].to_le_bytes()); // 100 // 25
+ bytes.extend_from_slice(&font_variant_id[1].to_le_bytes()); // 104 // 26
+ bytes.extend_from_slice(&font_variant_id[2].to_le_bytes()); // 108 // 27
+ bytes.extend_from_slice(&font_variant_id[3].to_le_bytes()); // 112 // 28
+ bytes.extend_from_slice(&line_height.to_le_bytes()); // 116 // 29
+ bytes.extend_from_slice(&letter_spacing.to_le_bytes()); // 120 // 30
+ bytes.extend_from_slice(&fill_bytes); // 124
mem::write_bytes(bytes)
})