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

This commit is contained in:
Alejandro Alonso 2026-03-31 11:29:44 +02:00
commit 56b28b5440
12 changed files with 355 additions and 123 deletions

View File

@ -1191,9 +1191,9 @@
; Check if the shape has changed any ; Check if the shape has changed any
; attribute that participates in components synchronization. ; attribute that participates in components synchronization.
(and (= (:type operation) :set) (and (= (:type operation) :set)
(get ctk/sync-attrs (:attr operation)))) (contains? ctk/sync-attrs (:attr operation))))]
any-sync? (some need-sync? operations)]
(when any-sync? (when (some need-sync? operations)
(parents-frames id (:objects page)))))) (parents-frames id (:objects page))))))
(defmethod frames-changed :mov-objects (defmethod frames-changed :mov-objects

View File

@ -48,11 +48,12 @@
(def log-shape-ids #{}) (def log-shape-ids #{})
(def log-container-ids #{}) (def log-container-ids #{})
(def updatable-attrs (->> (seq (keys ctk/sync-attrs)) (def updatable-attrs
;; We don't update the flex-child attrs (->> (keys ctk/sync-attrs)
(remove ctk/swap-keep-attrs) ;; We don't update the flex-child attrs
;; We don't do automatic update of the `layout-grid-cells` property. (remove ctk/swap-keep-attrs)
(remove #(= :layout-grid-cells %)))) ;; We don't do automatic update of the `layout-grid-cells` property.
(remove #(= :layout-grid-cells %))))
(defn enabled-shape? (defn enabled-shape?
[id container] [id container]
@ -1646,19 +1647,22 @@
(defn- generate-update-tokens (defn- generate-update-tokens
[changes container dest-shape origin-shape touched omit-touched? valid-attrs] [changes container dest-shape origin-shape touched omit-touched? valid-attrs]
;; valid-attrs is a set of attrs to consider on the update. If it is nil, it will consider all the attrs ;; valid-attrs is a set of attrs to consider on the update. If it is nil, it will consider all the attrs
(let [attrs (->> (seq (keys ctk/sync-attrs)) (let [attrs
;; We don't update the flex-child attrs (->> (keys ctk/sync-attrs)
(remove #(= :layout-grid-cells %))) ;; We don't update the flex-child attrs
(remove #(= :layout-grid-cells %)))
applied-tokens (reduce (fn [applied-tokens attr] applied-tokens
(let [attr-group (get ctk/sync-attrs attr) (reduce (fn [applied-tokens attr]
token-attrs (cto/shape-attr->token-attrs attr)] (let [sync-group (or (ctk/resolve-sync-group (:type origin-shape) attr)
(if (and (or (not omit-touched?) (not (touched attr-group))) (ctk/resolve-sync-group (:type dest-shape) attr))
(or (empty? valid-attrs) (contains? valid-attrs attr))) token-attrs (cto/shape-attr->token-attrs attr)]
(into applied-tokens token-attrs) (if (and (or (not omit-touched?) (not (touched sync-group)))
applied-tokens))) (or (empty? valid-attrs) (contains? valid-attrs attr)))
#{} (into applied-tokens token-attrs)
attrs)] applied-tokens)))
#{}
attrs)]
(cond-> changes (cond-> changes
(seq applied-tokens) (seq applied-tokens)
(update-tokens container dest-shape origin-shape applied-tokens)))) (update-tokens container dest-shape origin-shape applied-tokens))))
@ -1804,6 +1808,7 @@
uoperations '()] uoperations '()]
(let [attr (first attrs)] (let [attr (first attrs)]
(if (nil? attr) (if (nil? attr)
(cond-> changes (cond-> changes
(seq roperations) (seq roperations)
@ -1813,55 +1818,60 @@
:always :always
(generate-update-tokens container dest-shape origin-shape touched omit-touched? nil)) (generate-update-tokens container dest-shape origin-shape touched omit-touched? nil))
(let [attr-group (get ctk/sync-attrs attr) (let [sync-group
(or (ctk/resolve-sync-group (:type origin-shape) attr)
(ctk/resolve-sync-group (:type dest-shape) attr))
;; position-data is a special case because can be affected by ;; position-data is a special case because can be affected by
;; :geometry-group and :content-group so, if the position-data ;; :geometry-group and :content-group so, if the
;; changes but the geometry is touched we need to reset the position-data ;; position-data changes but the geometry is touched
;; so it's calculated again ;; we need to reset the position-data so it's
reset-pos-data? (and (cfh/text-shape? origin-shape) ;; calculated again
(= attr :position-data) reset-pos-data?
(not= (:position-data origin-shape) (:position-data dest-shape)) (and (cfh/text-shape? origin-shape)
(touched :geometry-group)) (= attr :position-data)
(not= (:position-data origin-shape) (:position-data dest-shape))
(touched :geometry-group))
;; On texts, when we want to omit the touched attrs, both text (the actual letters) ;; On texts, when we want to omit the touched attrs, both text (the actual letters)
;; and attrs (bold, font, etc) are in the same attr :content. ;; and attrs (bold, font, etc) are in the same attr :content.
;; If only one of them is touched, we want to adress this case and ;; If only one of them is touched, we want to adress this case and
;; only update the untouched one ;; only update the untouched one
text-content-change? text-content-change?
(and (and omit-touched?
omit-touched? (cfh/text-shape? origin-shape)
(cfh/text-shape? origin-shape) (= :content attr)
(= :content attr) (touched sync-group))
(touched attr-group))
skip-operations? skip-operations?
(or (= (get origin-shape attr) (get dest-shape attr)) (or (= (get origin-shape attr) (get dest-shape attr))
(and (touched attr-group) (and (touched sync-group)
omit-touched? omit-touched?
;; When it is a text-partial-change, we should generate operations ;; When it is a text-partial-change, we should generate operations
;; even when omit-touched? is true, but updating only the text or ;; even when omit-touched? is true, but updating only the text or
;; the attributes, omiting the other part ;; the attributes, omiting the other part
(not text-content-change?))) (not text-content-change?)))
attr-val (when-not skip-operations? attr-val
(cond (when-not skip-operations?
;; If position data changes and the geometry group is touched (cond
;; we need to put to nil so we can regenerate it ;; If position data changes and the geometry group is touched
reset-pos-data? ;; we need to put to nil so we can regenerate it
nil reset-pos-data?
nil
text-content-change? text-content-change?
(text-change-value (:content dest-shape) (text-change-value (:content dest-shape)
(:content origin-shape) (:content origin-shape)
touched) touched)
:else :else
(get origin-shape attr))) (get origin-shape attr)))
;; If the final attr-value is the actual value, skip ;; If the final attr-value is the actual value, skip
skip-operations? (or skip-operations? skip-operations?
(= attr-val (get dest-shape attr))) (or skip-operations?
(= attr-val (get dest-shape attr)))
;; On a text-partial-change, we want to force a position-data reset ;; On a text-partial-change, we want to force a position-data reset
;; so it's calculated again ;; so it's calculated again
@ -2079,7 +2089,9 @@
roperations [{:type :set-touched :touched (:touched previous-shape)}] roperations [{:type :set-touched :touched (:touched previous-shape)}]
uoperations (list {:type :set-touched :touched (:touched current-shape)})] uoperations (list {:type :set-touched :touched (:touched current-shape)})]
(if-let [attr (first attrs)] (if-let [attr (first attrs)]
(let [attr-group (get ctk/sync-attrs attr) (let [sync-group
(ctk/resolve-sync-group (:type previous-shape) attr)
skip-operations? skip-operations?
(or (or
;; For auto text, avoid copying geometry-driven attrs on switch. ;; For auto text, avoid copying geometry-driven attrs on switch.
@ -2096,7 +2108,7 @@
(= (get previous-shape attr) (get origin-ref-shape attr)) (= (get previous-shape attr) (get origin-ref-shape attr))
;; If the attr is not touched, don't copy it ;; If the attr is not touched, don't copy it
(not (touched attr-group)) (not (touched sync-group))
;; If both variants (origin and destiny) don't have the same value ;; If both variants (origin and destiny) don't have the same value
;; for that attribute, don't copy it. ;; for that attribute, don't copy it.
@ -2120,12 +2132,11 @@
;; If only one of them is touched, we want to adress this case and ;; If only one of them is touched, we want to adress this case and
;; only update the untouched one ;; only update the untouched one
text-change? text-change?
(and (and (not skip-operations?)
(not skip-operations?) (cfh/text-shape? current-shape)
(cfh/text-shape? current-shape) (cfh/text-shape? previous-shape)
(cfh/text-shape? previous-shape) (= :content attr)
(= :content attr) (touched sync-group))
(touched attr-group))
path-change? path-change?
(and (= :path (:type current-shape)) (and (= :path (:type current-shape))
@ -2218,9 +2229,12 @@
(->> attrs (->> attrs
(reduce (reduce
(fn [dest attr] (fn [dest attr]
(let [attr-group (get ctk/sync-attrs attr)] (let [sync-group
(or (ctk/resolve-sync-group (:type origin) attr)
(ctk/resolve-sync-group (:type dest) attr))]
(cond-> dest (cond-> dest
(or (not (touched attr-group)) (not omit-touched?)) (or (not (touched sync-group))
(not omit-touched?))
(assoc attr (get origin attr))))) (assoc attr (get origin attr)))))
dest)))) dest))))

View File

@ -7,6 +7,7 @@
(ns app.common.types.component (ns app.common.types.component
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.time :as-alias ct] [app.common.time :as-alias ct]
[app.common.types.page :as ctp] [app.common.types.page :as ctp]
@ -39,15 +40,17 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Attributes that may be synced in components, and the group they belong to. ;; Attributes that may be synced in components, and the group they belong to.
;; When one attribute is modified in a shape inside a component, the corresponding ;; When one attribute is modified in a shape inside a component, the
;; group is marked as :touched. Then, if the shape is synced with the remote shape ;; corresponding group is marked as :touched. Then, if the shape is synced with
;; in the main component, none of the attributes of the same group is changed. ;; the remote shape in the main component, none of the attributes of the same
;; group is changed.
(def sync-attrs (def sync-attrs
{:name :name-group {:name :name-group
:fills :fill-group :fills :fill-group
:hide-fill-on-export :fill-group :hide-fill-on-export :fill-group
:content :content-group :content {:path :geometry-group
:text :content-group}
:position-data :content-group :position-data :content-group
:hidden :visibility-group :hidden :visibility-group
:blocked :modifiable-group :blocked :modifiable-group
@ -143,6 +146,18 @@
:layout-item-align-self :layout-item-align-self
:interactions}) :interactions})
(defn resolve-sync-group
"Makes a by type resolution of the sync group. This is necessary
because we have several properties that has different group
depending on the shape type. Per example the attr `:content` is used
by path and text shapes and the sync groups are different for each
shape type."
[type attr]
(when-let [group (get sync-attrs attr)]
(if (map? group)
(get group type)
group)))
(defn component-attr? (defn component-attr?
"Check if some attribute is one that is involved in component syncrhonization. "Check if some attribute is one that is involved in component syncrhonization.
Note that design tokens also are involved, although they go by an alternate Note that design tokens also are involved, although they go by an alternate
@ -150,7 +165,7 @@
Also when detaching a nested copy it also needs to trigger a synchronization, Also when detaching a nested copy it also needs to trigger a synchronization,
even though :shape-ref is not a synced attribute per se" even though :shape-ref is not a synced attribute per se"
[attr] [attr]
(or (get sync-attrs attr) (or (contains? sync-attrs attr)
(= :shape-ref attr) (= :shape-ref attr)
(= :applied-tokens attr))) (= :applied-tokens attr)))
@ -356,15 +371,17 @@
(or (not (instance-head? shape)) (or (not (instance-head? shape))
(not (in-component-copy? parent)))))) (not (in-component-copy? parent))))))
(defn all-touched-groups (def ^:private all-touched-groups
[] (reduce-kv (fn [acc _ v]
(into #{} (vals sync-attrs))) (if (map? v)
(into acc (vals v))
(conj acc v)))
#{}
sync-attrs))
(defn valid-touched-group? (defn valid-touched-group?
[group] [group]
(try (ex/ignoring
(or (contains? (all-touched-groups) group) (or (contains? all-touched-groups group)
(and (swap-slot? group) (and (swap-slot? group)
(some? (group->swap-slot group)))) (some? (group->swap-slot group))))))
(catch #?(:clj Throwable :cljs :default) _
false)))

View File

@ -554,14 +554,14 @@
(let [old-applied-tokens (d/nilv (:applied-tokens shape) #{}) (let [old-applied-tokens (d/nilv (:applied-tokens shape) #{})
changed-token-attrs (filter #(not= (get old-applied-tokens %) (get new-applied-tokens %)) changed-token-attrs (filter #(not= (get old-applied-tokens %) (get new-applied-tokens %))
ctt/all-keys) ctt/all-keys)
text-shape? (= (:type shape) :text) shape-type (get shape :type)
text-shape? (= shape-type :text)
attrs-in-text-content? (some #(ctt/attrs-in-text-content %) attrs-in-text-content? (some #(ctt/attrs-in-text-content %)
changed-token-attrs) changed-token-attrs)
changed-groups (into #{} changed-groups (into #{}
(comp (map ctt/token-attr->shape-attr) (comp (map ctt/token-attr->shape-attr)
(map #(get ctk/sync-attrs %)) (keep #(ctk/resolve-sync-group shape-type %)))
(filter some?))
changed-token-attrs) changed-token-attrs)
changed-groups (if (and text-shape? changed-groups (if (and text-shape?
@ -577,8 +577,9 @@
The returned shape will contain a metadata associated with it The returned shape will contain a metadata associated with it
indicating if shape is touched or not." indicating if shape is touched or not."
[shape attr val & {:keys [ignore-touched ignore-geometry]}] [shape attr val & {:keys [ignore-touched ignore-geometry]}]
(let [group (get ctk/sync-attrs attr) (let [type (get shape :type)
shape-val (get shape attr) group (ctk/resolve-sync-group type attr)
shape-val (get shape attr)
ignore? ignore?
(or ignore-touched (or ignore-touched
@ -612,16 +613,20 @@
(not equal?) (not equal?)
(not (and ignore-geometry is-geometry?))) (not (and ignore-geometry is-geometry?)))
content-diff-type (when (and (= (:type shape) :text) (= attr :content)) content-diff-type
(cttx/get-diff-type (:content shape) val)) (when (and (= type :text) (= attr :content))
(cttx/get-diff-type (:content shape) val))
token-groups (if (= attr :applied-tokens) token-groups
(get-token-groups shape val) (if (= attr :applied-tokens)
#{}) (get-token-groups shape val)
#{})
groups
(cond-> token-groups
(and group (not equal?))
(set/union #{group} content-diff-type))]
groups (cond-> token-groups
(and group (not equal?))
(set/union #{group} content-diff-type))]
(cond-> shape (cond-> shape
;; Depending on the origin of the attribute change, we need or not to ;; Depending on the origin of the attribute change, we need or not to
;; set the "touched" flag for the group the attribute belongs to. ;; set the "touched" flag for the group the attribute belongs to.

View File

@ -27,7 +27,7 @@
;; --- Auxiliar Functions ;; --- Auxiliar Functions
(def valid-browsers (def valid-browsers
#{:chrome :firefox :safari :safari-16 :safari-17 :edge :other}) #{:chrome :firefox :safari :safari-16 :safari-17 :safari-18 :safari-26 :edge :other})
(def valid-platforms (def valid-platforms
#{:windows :linux :macos :other}) #{:windows :linux :macos :other})
@ -40,13 +40,17 @@
check-edge? (fn [] (str/includes? user-agent "edg")) check-edge? (fn [] (str/includes? user-agent "edg"))
check-safari? (fn [] (str/includes? user-agent "safari")) check-safari? (fn [] (str/includes? user-agent "safari"))
check-safari-16? (fn [] (and (check-safari?) (str/includes? user-agent "version/16"))) check-safari-16? (fn [] (and (check-safari?) (str/includes? user-agent "version/16")))
check-safari-17? (fn [] (and (check-safari?) (str/includes? user-agent "version/17")))] check-safari-17? (fn [] (and (check-safari?) (str/includes? user-agent "version/17")))
check-safari-18? (fn [] (and (check-safari?) (str/includes? user-agent "version/18")))
check-safari-26? (fn [] (and (check-safari?) (str/includes? user-agent "version/26")))]
(cond (cond
^boolean (check-edge?) :edge ^boolean (check-edge?) :edge
^boolean (check-chrome?) :chrome ^boolean (check-chrome?) :chrome
^boolean (check-firefox?) :firefox ^boolean (check-firefox?) :firefox
^boolean (check-safari-16?) :safari-16 ^boolean (check-safari-16?) :safari-16
^boolean (check-safari-17?) :safari-17 ^boolean (check-safari-17?) :safari-17
^boolean (check-safari-18?) :safari-18
^boolean (check-safari-26?) :safari-26
^boolean (check-safari?) :safari ^boolean (check-safari?) :safari
:else :unknown))) :else :unknown)))
@ -212,7 +216,7 @@
(defn ^boolean check-browser? [candidate] (defn ^boolean check-browser? [candidate]
(dm/assert! (contains? valid-browsers candidate)) (dm/assert! (contains? valid-browsers candidate))
(if (= candidate :safari) (if (= candidate :safari)
(contains? #{:safari :safari-16 :safari-17} browser) (contains? #{:safari :safari-16 :safari-17 :safari-18 :safari-26} browser)
(= candidate browser))) (= candidate browser)))
(defn ^boolean check-platform? [candidate] (defn ^boolean check-platform? [candidate]

View File

@ -63,7 +63,7 @@
(ptk/reify ::initialize-rasterizer (ptk/reify ::initialize-rasterizer
ptk/EffectEvent ptk/EffectEvent
(effect [_ state _] (effect [_ state _]
(when (feat/active-feature? state "render-wasm/v1") (when-not (feat/active-feature? state "render-wasm/v1")
(thr/init!))))) (thr/init!)))))
(defn initialize (defn initialize

View File

@ -44,6 +44,7 @@
[app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.specialized-panel :as dwsp] [app.main.data.workspace.specialized-panel :as dwsp]
[app.main.data.workspace.thumbnails :as dwt] [app.main.data.workspace.thumbnails :as dwt]
[app.main.data.workspace.thumbnails-wasm :as dwt.wasm]
[app.main.data.workspace.transforms :as dwtr] [app.main.data.workspace.transforms :as dwtr]
[app.main.data.workspace.undo :as dwu] [app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.wasm-text :as dwwt] [app.main.data.workspace.wasm-text :as dwwt]
@ -945,7 +946,12 @@
component (ctkl/get-component data component-id) component (ctkl/get-component data component-id)
page-id (:main-instance-page component) page-id (:main-instance-page component)
root-id (:main-instance-id component)] root-id (:main-instance-id component)]
(dwt/update-thumbnail file-id page-id root-id tag "update-component-thumbnail-sync"))) (if (and (= tag "component")
(features/active-feature? state "render-wasm/v1"))
;; WASM: render immediately, UI only — server persist happens
;; on the debounced path (update-component-thumbnail)
(dwt.wasm/render-thumbnail file-id page-id root-id)
(dwt/update-thumbnail file-id page-id root-id tag "update-component-thumbnail-sync"))))
(defn update-component-sync (defn update-component-sync
([shape-id file-id] (update-component-sync shape-id file-id nil)) ([shape-id file-id] (update-component-sync shape-id file-id nil))
@ -964,9 +970,12 @@
(dwu/start-undo-transaction undo-id) (dwu/start-undo-transaction undo-id)
(update-component shape-id undo-group) (update-component shape-id undo-group)
;; These two calls are necessary for properly sync thumbnails ;; These calls are necessary for properly sync thumbnails
;; when a main component does not live in the same page ;; when a main component does not live in the same page.
(update-component-thumbnail-sync state component-id file-id "frame") ;; When WASM is active, skip the "frame" tag (SVG-based) since
;; component previews are rendered locally via WASM.
(when-not (features/active-feature? state "render-wasm/v1")
(update-component-thumbnail-sync state component-id file-id "frame"))
(update-component-thumbnail-sync state component-id file-id "component") (update-component-thumbnail-sync state component-id file-id "component")
(sync-file current-file-id file-id :components component-id undo-group) (sync-file current-file-id file-id :components component-id undo-group)
@ -991,13 +1000,21 @@
(dwu/commit-undo-transaction undo-id))))))) (dwu/commit-undo-transaction undo-id)))))))
(defn update-component-thumbnail (defn update-component-thumbnail
"Update the thumbnail of the component with the given id, in the "Persist the thumbnail of the component to the server.
current file and in the imported libraries." For WASM, the UI is already up-to-date from the immediate render in
update-component-thumbnail-sync, so this only persists.
For SVG, this does the full render + persist."
[component-id file-id] [component-id file-id]
(ptk/reify ::update-component-thumbnail (ptk/reify ::update-component-thumbnail
ptk/WatchEvent ptk/WatchEvent
(watch [_ state _] (watch [_ state _]
(rx/of (update-component-thumbnail-sync state component-id file-id "component"))))) (if (features/active-feature? state "render-wasm/v1")
(let [data (dsh/lookup-file-data state file-id)
component (ctkl/get-component data component-id)
page-id (:main-instance-page component)
root-id (:main-instance-id component)]
(rx/of (dwt.wasm/persist-thumbnail file-id page-id root-id)))
(rx/of (update-component-thumbnail-sync state component-id file-id "component"))))))
(defn- find-shape-index (defn- find-shape-index
[objects id shape-id] [objects id shape-id]
@ -1373,9 +1390,18 @@
(->> (rx/from changed-components) (->> (rx/from changed-components)
(rx/map #(component-changed % (:id old-data) undo-group)))) (rx/map #(component-changed % (:id old-data) undo-group))))
;; even if save-undo? is false, we need to update the :modified-date of the component ;; even if save-undo? is false, we need to update the :modified-date of the component
;; (for example, for undos) ;; (for example, for undos). When WASM is active, also re-render the thumbnail
(->> (rx/from changed-components) ;; so undo/redo visually updates component previews.
(rx/map touch-component))) (->> (mapcat (fn [component-id]
(if (features/active-feature? @st/state "render-wasm/v1")
(let [component (ctkl/get-component old-data component-id)]
[(touch-component component-id)
(dwt.wasm/render-thumbnail (:id old-data)
(:main-instance-page component)
(:main-instance-id component))])
[(touch-component component-id)]))
changed-components)
(rx/from)))
(rx/empty))))) (rx/empty)))))
@ -1390,18 +1416,32 @@
(rx/debounce 5000) (rx/debounce 5000)
(rx/tap #(log/trc :hint "buffer initialized")))] (rx/tap #(log/trc :hint "buffer initialized")))]
(when (contains? cf/flags :component-thumbnails) (when (or (contains? cf/flags :component-thumbnails)
(->> (rx/merge (features/active-feature? @st/state "render-wasm/v1"))
changes-s (let [wasm? (features/active-feature? @st/state "render-wasm/v1")]
(->> (rx/merge
changes-s
(->> changes-s ;; WASM: render thumbnails immediately for instant UI feedback
(rx/map deref) (if wasm?
(rx/buffer-until notifier-s) (->> changes-s
(rx/mapcat #(into #{} %)) (rx/filter (ptk/type? ::component-changed))
(rx/map (fn [[component-id file-id]] (rx/map deref)
(update-component-thumbnail component-id file-id))))) (rx/map (fn [[component-id file-id]]
(update-component-thumbnail-sync @st/state component-id file-id "component"))))
(rx/empty))
(rx/take-until stopper-s))))))) ;; Persist thumbnails to the server in batches after user
;; becomes inactive for 5 seconds.
(->> changes-s
(rx/filter (ptk/type? ::component-changed))
(rx/map deref)
(rx/buffer-until notifier-s)
(rx/mapcat #(into #{} %))
(rx/map (fn [[component-id file-id]]
(update-component-thumbnail component-id file-id)))))
(rx/take-until stopper-s))))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Backend interactions ;; Backend interactions

View File

@ -118,7 +118,7 @@
(rx/ignore)))) (rx/ignore))))
(rx/empty))))))) (rx/empty)))))))
(defn- assoc-thumbnail (defn assoc-thumbnail
[object-id uri] [object-id uri]
(let [prev-uri* (volatile! nil)] (let [prev-uri* (volatile! nil)]
(ptk/reify ::assoc-thumbnail (ptk/reify ::assoc-thumbnail

View File

@ -0,0 +1,118 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.data.workspace.thumbnails-wasm
"WASM-based component thumbnail rendering.
Renders component previews using the existing workspace WASM context
via render-shape-pixels (SurfaceId::Export), avoiding a separate
WASM module in the worker.
Two-phase design:
- render-thumbnail: immediate WASM render → UI update (no server)
- persist-thumbnail: pushes current data-uri to the server (debounced)"
(:require
[app.common.data.macros :as dm]
[app.common.logging :as l]
[app.common.thumbnails :as thc]
[app.common.time :as ct]
[app.main.data.workspace.thumbnails :as dwt]
[app.main.repo :as rp]
[app.render-wasm.api :as wasm.api]
[app.util.webapi :as wapi]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[potok.v2.core :as ptk]))
(l/set-level! :warn)
(defn- png-bytes->data-uri
"Converts a Uint8Array of PNG bytes to a data:image/png;base64 URI."
[png-bytes]
(let [blob (wapi/create-blob png-bytes "image/png")
reader (js/FileReader.)]
(js/Promise.
(fn [resolve reject]
(set! (.-onload reader)
(fn [] (resolve (.-result reader))))
(set! (.-onerror reader)
(fn [e] (reject e)))
(.readAsDataURL reader blob)))))
(defn- render-component-pixels
"Renders a component frame using the workspace WASM context.
Returns an observable that emits a data-uri string.
Deferred by one animation frame so that process-shape-changes!
has time to sync all child shapes to WASM memory first."
[frame-id]
(rx/create
(fn [subs]
(js/requestAnimationFrame
(fn [_]
(try
(let [png-bytes (wasm.api/render-shape-pixels frame-id 1)]
(if (or (nil? png-bytes) (zero? (.-length png-bytes)))
(do (js/console.error "[thumbnails] render-shape-pixels returned empty for" (str frame-id))
(rx/end! subs))
(.then (png-bytes->data-uri png-bytes)
(fn [data-uri]
(rx/push! subs data-uri)
(rx/end! subs))
(fn [err]
(rx/error! subs err)))))
(catch :default err
(rx/error! subs err)))))
nil)))
(defn render-thumbnail
"Renders a component thumbnail via WASM and updates the UI immediately.
Does NOT persist to the server — persistence is handled separately
by `persist-thumbnail` on a debounced schedule."
[file-id page-id frame-id]
(let [object-id (thc/fmt-object-id file-id page-id frame-id "component")]
(ptk/reify ::render-thumbnail
cljs.core/IDeref
(-deref [_] object-id)
ptk/WatchEvent
(watch [_ _ stream]
(let [tp (ct/tpoint-ms)]
(->> (render-component-pixels frame-id)
(rx/map
(fn [data-uri]
(l/dbg :hint "component thumbnail rendered (wasm)"
:elapsed (dm/str (tp) "ms"))
(dwt/assoc-thumbnail object-id data-uri)))
(rx/catch (fn [err]
(js/console.error "[thumbnails] error rendering component thumbnail" err)
(rx/empty)))
(rx/take-until
(->> stream
(rx/filter (ptk/type? ::dwt/clear-thumbnail))
(rx/filter #(= (deref %) object-id))))))))))
(defn persist-thumbnail
"Persists the current component thumbnail data-uri to the server.
Expects that `render-thumbnail` has already been called so the
data-uri is present in app state. If not, this is a no-op."
[file-id page-id frame-id]
(let [object-id (thc/fmt-object-id file-id page-id frame-id "component")]
(ptk/reify ::persist-thumbnail
ptk/WatchEvent
(watch [_ state _]
(let [data-uri (dm/get-in state [:thumbnails object-id])]
(if (and (some? data-uri)
(str/starts-with? data-uri "data:"))
(let [blob (wapi/data-uri->blob data-uri)]
(->> (rp/cmd! :create-file-object-thumbnail
{:file-id file-id
:object-id object-id
:media blob
:tag "component"})
(rx/catch rx/empty)
(rx/ignore)))
(rx/empty)))))))

View File

@ -68,14 +68,16 @@
"false" false "false" false
nil)) nil))
(def wasm-url-override-ref
(l/derived wasm-url-override st/state))
(defn active-feature? (defn active-feature?
"Given a state and feature, check if feature is enabled." "Given a state and feature, check if feature is enabled."
[state feature] [state feature]
(assert (contains? cfeat/supported-features feature) (assert (contains? cfeat/supported-features feature)
"feature not supported") "feature not supported")
(let [wasm-override (when (= feature "render-wasm/v1") (let [wasm-override (when (= feature "render-wasm/v1") (wasm-url-override state))]
(wasm-url-override state))]
(cond (cond
(some? wasm-override) (some? wasm-override)
wasm-override wasm-override
@ -110,8 +112,15 @@
(defn use-feature (defn use-feature
"A react hook that checks if feature is currently enabled" "A react hook that checks if feature is currently enabled"
[feature] [feature]
(let [enabled-features (mf/deref features-ref)] (let [enabled-features (mf/deref features-ref)
(contains? enabled-features feature))) wasm-override (mf/deref wasm-url-override-ref)
wasm-override (when (= feature "render-wasm/v1") wasm-override)]
(cond
(some? wasm-override)
wasm-override
:else
(contains? enabled-features feature))))
(defn toggle-feature (defn toggle-feature
"An event constructor for runtime feature toggle. "An event constructor for runtime feature toggle.

View File

@ -205,6 +205,19 @@
(dwt/dispose! instance) (dwt/dispose! instance)
(st/emit! (dwt/update-editor nil))))) (st/emit! (dwt/update-editor nil)))))
(defn vertical-align-editor-classes
"Returns `[align-top? align-center? align-bottom?]` for the text editor root
flex layout. When `render-wasm?` is true, the `foreignObject` is already
positioned using the same vertical offset as Skia (`content_rect`); applying
`justify-content` center/end here would double the offset and misalign the DOM
editor with the rendered text, caret, and selection."
[content render-wasm?]
(if render-wasm?
[true false false]
[(= (:vertical-align content "top") "top")
(= (:vertical-align content) "center")
(= (:vertical-align content) "bottom")]))
(defn get-color-from-content [content] (defn get-color-from-content [content]
(let [fills (->> (tree-seq map? :children content) (let [fills (->> (tree-seq map? :children content)
(mapcat :fills) (mapcat :fills)
@ -225,7 +238,7 @@
"Text editor (HTML)" "Text editor (HTML)"
{::mf/wrap [mf/memo] {::mf/wrap [mf/memo]
::mf/props :obj} ::mf/props :obj}
[{:keys [shape canvas-ref]}] [{:keys [shape canvas-ref render-wasm?] :or {render-wasm? false}}]
(let [content (:content shape) (let [content (:content shape)
shape-id (dm/get-prop shape :id) shape-id (dm/get-prop shape :id)
fill-color (get-color-from-content content) fill-color (get-color-from-content content)
@ -244,6 +257,9 @@
text-color (or fill-color (get-default-text-color {:frame frame text-color (or fill-color (get-default-text-color {:frame frame
:background-color background-color}) color/black) :background-color background-color}) color/black)
[align-top? align-center? align-bottom?]
(vertical-align-editor-classes content render-wasm?)
fonts fonts
(-> (mf/use-memo (mf/deps content) #(get-fonts content)) (-> (mf/use-memo (mf/deps content) #(get-fonts content))
(h/use-equal-memo))] (h/use-equal-memo))]
@ -291,9 +307,9 @@
:grow-type-fixed (= (:grow-type shape) :fixed) :grow-type-fixed (= (:grow-type shape) :fixed)
:grow-type-auto-width (= (:grow-type shape) :auto-width) :grow-type-auto-width (= (:grow-type shape) :auto-width)
:grow-type-auto-height (= (:grow-type shape) :auto-height) :grow-type-auto-height (= (:grow-type shape) :auto-height)
:align-top (= (:vertical-align content "top") "top") :align-top align-top?
:align-center (= (:vertical-align content) "center") :align-center align-center?
:align-bottom (= (:vertical-align content) "bottom"))) :align-bottom align-bottom?))
:ref editor-ref :ref editor-ref
:data-testid "text-editor-content" :data-testid "text-editor-content"
:data-x (dm/get-prop shape :x) :data-x (dm/get-prop shape :x)
@ -348,7 +364,7 @@
;; NOTE: this teoretically breaks hooks rules, but in practice ;; NOTE: this teoretically breaks hooks rules, but in practice
;; it is imposible to really break it ;; it is imposible to really break it
maybe-zoom maybe-zoom
(when (cf/check-browser? :safari-16) (when (cf/check-browser? :safari)
(mf/deref refs/selected-zoom)) (mf/deref refs/selected-zoom))
shape (cond-> shape shape (cond-> shape
@ -404,17 +420,23 @@
;; Transform is necessary when there is a text overflow and the vertical ;; Transform is necessary when there is a text overflow and the vertical
;; aligment is center or bottom. ;; aligment is center or bottom.
(and (not render-wasm?) (and (not render-wasm?)
(not (cf/check-browser? :safari))) (not (cf/check-browser? :safari-16)))
(obj/merge! (obj/merge!
#js {:transform (dm/fmt "translate(%px, %px)" (- (dm/get-prop shape :x) x) (- (dm/get-prop shape :y) y))}) #js {:transform (dm/fmt "translate(%px, %px)" (- (dm/get-prop shape :x) x) (- (dm/get-prop shape :y) y))})
(cf/check-browser? :safari-17) (and (cf/check-browser? :safari) (not (cf/check-browser? :safari-16)))
(obj/merge! (obj/merge!
#js {:height "100%" #js {:height "100%"
:display "flex" :display "flex"
:flexDirection "column" :flexDirection "column"
:justifyContent (shape->justify shape)}) :justifyContent (shape->justify shape)})
(or (cf/check-browser? :safari-26) (cf/check-browser? :safari-18))
(obj/merge!
#js {:position "fixed"
:transform-origin "top left"
:transform (dm/fmt "scale(%)" maybe-zoom)})
(cf/check-browser? :safari-16) (cf/check-browser? :safari-16)
(obj/merge! (obj/merge!
#js {:position "fixed" #js {:position "fixed"
@ -435,4 +457,5 @@
[:div {:style style} [:div {:style style}
[:& text-editor-html {:shape shape [:& text-editor-html {:shape shape
:canvas-ref canvas-ref :canvas-ref canvas-ref
:render-wasm? render-wasm?
:key (dm/str shape-id)}]]]])) :key (dm/str shape-id)}]]]]))

View File

@ -24,6 +24,7 @@
[app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.undo :as dwu] [app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.variants :as dwv] [app.main.data.workspace.variants :as dwv]
[app.main.features :as features]
[app.main.refs :as refs] [app.main.refs :as refs]
[app.main.render :refer [component-svg component-svg-thumbnail]] [app.main.render :refer [component-svg component-svg-thumbnail]]
[app.main.store :as st] [app.main.store :as st]
@ -298,7 +299,8 @@
(inc retry))))] (inc retry))))]
(if (and (some? thumbnail-uri) (if (and (some? thumbnail-uri)
(contains? cf/flags :component-thumbnails)) (or (contains? cf/flags :component-thumbnails)
(features/active-feature? @st/state "render-wasm/v1")))
[:& component-svg-thumbnail [:& component-svg-thumbnail
{:thumbnail-uri thumbnail-uri {:thumbnail-uri thumbnail-uri
:class class :class class