diff --git a/common/src/app/common/files/changes.cljc b/common/src/app/common/files/changes.cljc index 8673ef81e3..673a3a854a 100644 --- a/common/src/app/common/files/changes.cljc +++ b/common/src/app/common/files/changes.cljc @@ -1191,9 +1191,9 @@ ; Check if the shape has changed any ; attribute that participates in components synchronization. (and (= (:type operation) :set) - (get ctk/sync-attrs (:attr operation)))) - any-sync? (some need-sync? operations)] - (when any-sync? + (contains? ctk/sync-attrs (:attr operation))))] + + (when (some need-sync? operations) (parents-frames id (:objects page)))))) (defmethod frames-changed :mov-objects diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index a162561d1a..4de95715f8 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -48,11 +48,12 @@ (def log-shape-ids #{}) (def log-container-ids #{}) -(def updatable-attrs (->> (seq (keys ctk/sync-attrs)) - ;; We don't update the flex-child attrs - (remove ctk/swap-keep-attrs) - ;; We don't do automatic update of the `layout-grid-cells` property. - (remove #(= :layout-grid-cells %)))) +(def updatable-attrs + (->> (keys ctk/sync-attrs) + ;; We don't update the flex-child attrs + (remove ctk/swap-keep-attrs) + ;; We don't do automatic update of the `layout-grid-cells` property. + (remove #(= :layout-grid-cells %)))) (defn enabled-shape? [id container] @@ -1646,19 +1647,22 @@ (defn- generate-update-tokens [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 - (let [attrs (->> (seq (keys ctk/sync-attrs)) - ;; We don't update the flex-child attrs - (remove #(= :layout-grid-cells %))) + (let [attrs + (->> (keys ctk/sync-attrs) + ;; We don't update the flex-child attrs + (remove #(= :layout-grid-cells %))) - applied-tokens (reduce (fn [applied-tokens attr] - (let [attr-group (get ctk/sync-attrs attr) - token-attrs (cto/shape-attr->token-attrs attr)] - (if (and (or (not omit-touched?) (not (touched attr-group))) - (or (empty? valid-attrs) (contains? valid-attrs attr))) - (into applied-tokens token-attrs) - applied-tokens))) - #{} - attrs)] + applied-tokens + (reduce (fn [applied-tokens attr] + (let [sync-group (or (ctk/resolve-sync-group (:type origin-shape) attr) + (ctk/resolve-sync-group (:type dest-shape) attr)) + token-attrs (cto/shape-attr->token-attrs attr)] + (if (and (or (not omit-touched?) (not (touched sync-group))) + (or (empty? valid-attrs) (contains? valid-attrs attr))) + (into applied-tokens token-attrs) + applied-tokens))) + #{} + attrs)] (cond-> changes (seq applied-tokens) (update-tokens container dest-shape origin-shape applied-tokens)))) @@ -1804,6 +1808,7 @@ uoperations '()] (let [attr (first attrs)] + (if (nil? attr) (cond-> changes (seq roperations) @@ -1813,55 +1818,60 @@ :always (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 - ;; :geometry-group and :content-group so, if the position-data - ;; changes but the geometry is touched we need to reset the position-data - ;; so it's calculated again - reset-pos-data? (and (cfh/text-shape? origin-shape) - (= attr :position-data) - (not= (:position-data origin-shape) (:position-data dest-shape)) - (touched :geometry-group)) + ;; :geometry-group and :content-group so, if the + ;; position-data changes but the geometry is touched + ;; we need to reset the position-data so it's + ;; calculated again + reset-pos-data? + (and (cfh/text-shape? origin-shape) + (= 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) ;; 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 ;; only update the untouched one text-content-change? - (and - omit-touched? - (cfh/text-shape? origin-shape) - (= :content attr) - (touched attr-group)) + (and omit-touched? + (cfh/text-shape? origin-shape) + (= :content attr) + (touched sync-group)) skip-operations? (or (= (get origin-shape attr) (get dest-shape attr)) - (and (touched attr-group) + (and (touched sync-group) omit-touched? ;; When it is a text-partial-change, we should generate operations ;; even when omit-touched? is true, but updating only the text or ;; the attributes, omiting the other part (not text-content-change?))) - attr-val (when-not skip-operations? - (cond - ;; If position data changes and the geometry group is touched - ;; we need to put to nil so we can regenerate it - reset-pos-data? - nil + attr-val + (when-not skip-operations? + (cond + ;; If position data changes and the geometry group is touched + ;; we need to put to nil so we can regenerate it + reset-pos-data? + nil - text-content-change? - (text-change-value (:content dest-shape) - (:content origin-shape) - touched) + text-content-change? + (text-change-value (:content dest-shape) + (:content origin-shape) + touched) - :else - (get origin-shape attr))) + :else + (get origin-shape attr))) ;; If the final attr-value is the actual value, skip - skip-operations? (or skip-operations? - (= attr-val (get dest-shape attr))) - + skip-operations? + (or skip-operations? + (= attr-val (get dest-shape attr))) ;; On a text-partial-change, we want to force a position-data reset ;; so it's calculated again @@ -2079,7 +2089,9 @@ roperations [{:type :set-touched :touched (:touched previous-shape)}] uoperations (list {:type :set-touched :touched (:touched current-shape)})] (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? (or ;; For auto text, avoid copying geometry-driven attrs on switch. @@ -2096,7 +2108,7 @@ (= (get previous-shape attr) (get origin-ref-shape attr)) ;; 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 ;; 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 ;; only update the untouched one text-change? - (and - (not skip-operations?) - (cfh/text-shape? current-shape) - (cfh/text-shape? previous-shape) - (= :content attr) - (touched attr-group)) + (and (not skip-operations?) + (cfh/text-shape? current-shape) + (cfh/text-shape? previous-shape) + (= :content attr) + (touched sync-group)) path-change? (and (= :path (:type current-shape)) @@ -2218,9 +2229,12 @@ (->> attrs (reduce (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 - (or (not (touched attr-group)) (not omit-touched?)) + (or (not (touched sync-group)) + (not omit-touched?)) (assoc attr (get origin attr))))) dest)))) diff --git a/common/src/app/common/types/component.cljc b/common/src/app/common/types/component.cljc index 01968f1373..d07ffaeb50 100644 --- a/common/src/app/common/types/component.cljc +++ b/common/src/app/common/types/component.cljc @@ -7,6 +7,7 @@ (ns app.common.types.component (:require [app.common.data :as d] + [app.common.exceptions :as ex] [app.common.schema :as sm] [app.common.time :as-alias ct] [app.common.types.page :as ctp] @@ -39,15 +40,17 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; 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 -;; group is marked as :touched. Then, if the shape is synced with the remote shape -;; in the main component, none of the attributes of the same group is changed. +;; When one attribute is modified in a shape inside a component, the +;; corresponding group is marked as :touched. Then, if the shape is synced with +;; the remote shape in the main component, none of the attributes of the same +;; group is changed. (def sync-attrs {:name :name-group :fills :fill-group :hide-fill-on-export :fill-group - :content :content-group + :content {:path :geometry-group + :text :content-group} :position-data :content-group :hidden :visibility-group :blocked :modifiable-group @@ -143,6 +146,18 @@ :layout-item-align-self :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? "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 @@ -150,7 +165,7 @@ Also when detaching a nested copy it also needs to trigger a synchronization, even though :shape-ref is not a synced attribute per se" [attr] - (or (get sync-attrs attr) + (or (contains? sync-attrs attr) (= :shape-ref attr) (= :applied-tokens attr))) @@ -356,15 +371,17 @@ (or (not (instance-head? shape)) (not (in-component-copy? parent)))))) -(defn all-touched-groups - [] - (into #{} (vals sync-attrs))) +(def ^:private all-touched-groups + (reduce-kv (fn [acc _ v] + (if (map? v) + (into acc (vals v)) + (conj acc v))) + #{} + sync-attrs)) (defn valid-touched-group? [group] - (try - (or (contains? (all-touched-groups) group) - (and (swap-slot? group) - (some? (group->swap-slot group)))) - (catch #?(:clj Throwable :cljs :default) _ - false))) + (ex/ignoring + (or (contains? all-touched-groups group) + (and (swap-slot? group) + (some? (group->swap-slot group)))))) diff --git a/common/src/app/common/types/container.cljc b/common/src/app/common/types/container.cljc index 324528854b..df9ce86be2 100644 --- a/common/src/app/common/types/container.cljc +++ b/common/src/app/common/types/container.cljc @@ -554,14 +554,14 @@ (let [old-applied-tokens (d/nilv (:applied-tokens shape) #{}) changed-token-attrs (filter #(not= (get old-applied-tokens %) (get new-applied-tokens %)) 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 %) changed-token-attrs) changed-groups (into #{} (comp (map ctt/token-attr->shape-attr) - (map #(get ctk/sync-attrs %)) - (filter some?)) + (keep #(ctk/resolve-sync-group shape-type %))) changed-token-attrs) changed-groups (if (and text-shape? @@ -577,8 +577,9 @@ The returned shape will contain a metadata associated with it indicating if shape is touched or not." [shape attr val & {:keys [ignore-touched ignore-geometry]}] - (let [group (get ctk/sync-attrs attr) - shape-val (get shape attr) + (let [type (get shape :type) + group (ctk/resolve-sync-group type attr) + shape-val (get shape attr) ignore? (or ignore-touched @@ -612,16 +613,20 @@ (not equal?) (not (and ignore-geometry is-geometry?))) - content-diff-type (when (and (= (:type shape) :text) (= attr :content)) - (cttx/get-diff-type (:content shape) val)) + content-diff-type + (when (and (= type :text) (= attr :content)) + (cttx/get-diff-type (:content shape) val)) - token-groups (if (= attr :applied-tokens) - (get-token-groups shape val) - #{}) + token-groups + (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 ;; Depending on the origin of the attribute change, we need or not to ;; set the "touched" flag for the group the attribute belongs to. diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index 4755b9fa15..a76bdf87dc 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -27,7 +27,7 @@ ;; --- Auxiliar Functions (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 #{:windows :linux :macos :other}) @@ -40,13 +40,17 @@ check-edge? (fn [] (str/includes? user-agent "edg")) check-safari? (fn [] (str/includes? user-agent "safari")) 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 ^boolean (check-edge?) :edge ^boolean (check-chrome?) :chrome ^boolean (check-firefox?) :firefox ^boolean (check-safari-16?) :safari-16 ^boolean (check-safari-17?) :safari-17 + ^boolean (check-safari-18?) :safari-18 + ^boolean (check-safari-26?) :safari-26 ^boolean (check-safari?) :safari :else :unknown))) @@ -212,7 +216,7 @@ (defn ^boolean check-browser? [candidate] (dm/assert! (contains? valid-browsers candidate)) (if (= candidate :safari) - (contains? #{:safari :safari-16 :safari-17} browser) + (contains? #{:safari :safari-16 :safari-17 :safari-18 :safari-26} browser) (= candidate browser))) (defn ^boolean check-platform? [candidate] diff --git a/frontend/src/app/main.cljs b/frontend/src/app/main.cljs index 68dfb71e51..25dfbed0d7 100644 --- a/frontend/src/app/main.cljs +++ b/frontend/src/app/main.cljs @@ -63,7 +63,7 @@ (ptk/reify ::initialize-rasterizer ptk/EffectEvent (effect [_ state _] - (when (feat/active-feature? state "render-wasm/v1") + (when-not (feat/active-feature? state "render-wasm/v1") (thr/init!))))) (defn initialize diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index dc9e5843bb..ef7bfd2f93 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -44,6 +44,7 @@ [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.specialized-panel :as dwsp] [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.undo :as dwu] [app.main.data.workspace.wasm-text :as dwwt] @@ -945,7 +946,12 @@ component (ctkl/get-component data component-id) page-id (:main-instance-page 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 ([shape-id file-id] (update-component-sync shape-id file-id nil)) @@ -964,9 +970,12 @@ (dwu/start-undo-transaction undo-id) (update-component shape-id undo-group) - ;; These two calls are necessary for properly sync thumbnails - ;; when a main component does not live in the same page - (update-component-thumbnail-sync state component-id file-id "frame") + ;; These calls are necessary for properly sync thumbnails + ;; when a main component does not live in the same page. + ;; 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") (sync-file current-file-id file-id :components component-id undo-group) @@ -991,13 +1000,21 @@ (dwu/commit-undo-transaction undo-id))))))) (defn update-component-thumbnail - "Update the thumbnail of the component with the given id, in the - current file and in the imported libraries." + "Persist the thumbnail of the component to the server. + 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] (ptk/reify ::update-component-thumbnail ptk/WatchEvent (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 [objects id shape-id] @@ -1373,9 +1390,18 @@ (->> (rx/from changed-components) (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 - ;; (for example, for undos) - (->> (rx/from changed-components) - (rx/map touch-component))) + ;; (for example, for undos). When WASM is active, also re-render the thumbnail + ;; so undo/redo visually updates component previews. + (->> (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))))) @@ -1390,18 +1416,32 @@ (rx/debounce 5000) (rx/tap #(log/trc :hint "buffer initialized")))] - (when (contains? cf/flags :component-thumbnails) - (->> (rx/merge - changes-s + (when (or (contains? cf/flags :component-thumbnails) + (features/active-feature? @st/state "render-wasm/v1")) + (let [wasm? (features/active-feature? @st/state "render-wasm/v1")] + (->> (rx/merge + changes-s - (->> changes-s - (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))))) + ;; WASM: render thumbnails immediately for instant UI feedback + (if wasm? + (->> changes-s + (rx/filter (ptk/type? ::component-changed)) + (rx/map deref) + (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 diff --git a/frontend/src/app/main/data/workspace/thumbnails.cljs b/frontend/src/app/main/data/workspace/thumbnails.cljs index 5edab10c27..b2854917af 100644 --- a/frontend/src/app/main/data/workspace/thumbnails.cljs +++ b/frontend/src/app/main/data/workspace/thumbnails.cljs @@ -118,7 +118,7 @@ (rx/ignore)))) (rx/empty))))))) -(defn- assoc-thumbnail +(defn assoc-thumbnail [object-id uri] (let [prev-uri* (volatile! nil)] (ptk/reify ::assoc-thumbnail diff --git a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs new file mode 100644 index 0000000000..44459e9df8 --- /dev/null +++ b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs @@ -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))))))) diff --git a/frontend/src/app/main/features.cljs b/frontend/src/app/main/features.cljs index c0f11c89fb..cc3f23052e 100644 --- a/frontend/src/app/main/features.cljs +++ b/frontend/src/app/main/features.cljs @@ -68,14 +68,16 @@ "false" false nil)) +(def wasm-url-override-ref + (l/derived wasm-url-override st/state)) + (defn active-feature? "Given a state and feature, check if feature is enabled." [state feature] (assert (contains? cfeat/supported-features feature) "feature not supported") - (let [wasm-override (when (= feature "render-wasm/v1") - (wasm-url-override state))] + (let [wasm-override (when (= feature "render-wasm/v1") (wasm-url-override state))] (cond (some? wasm-override) wasm-override @@ -110,8 +112,15 @@ (defn use-feature "A react hook that checks if feature is currently enabled" [feature] - (let [enabled-features (mf/deref features-ref)] - (contains? enabled-features feature))) + (let [enabled-features (mf/deref features-ref) + 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 "An event constructor for runtime feature toggle. diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs index 30f5dd100e..525b1d0613 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs @@ -205,6 +205,19 @@ (dwt/dispose! instance) (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] (let [fills (->> (tree-seq map? :children content) (mapcat :fills) @@ -225,7 +238,7 @@ "Text editor (HTML)" {::mf/wrap [mf/memo] ::mf/props :obj} - [{:keys [shape canvas-ref]}] + [{:keys [shape canvas-ref render-wasm?] :or {render-wasm? false}}] (let [content (:content shape) shape-id (dm/get-prop shape :id) fill-color (get-color-from-content content) @@ -244,6 +257,9 @@ text-color (or fill-color (get-default-text-color {:frame frame :background-color background-color}) color/black) + [align-top? align-center? align-bottom?] + (vertical-align-editor-classes content render-wasm?) + fonts (-> (mf/use-memo (mf/deps content) #(get-fonts content)) (h/use-equal-memo))] @@ -291,9 +307,9 @@ :grow-type-fixed (= (:grow-type shape) :fixed) :grow-type-auto-width (= (:grow-type shape) :auto-width) :grow-type-auto-height (= (:grow-type shape) :auto-height) - :align-top (= (:vertical-align content "top") "top") - :align-center (= (:vertical-align content) "center") - :align-bottom (= (:vertical-align content) "bottom"))) + :align-top align-top? + :align-center align-center? + :align-bottom align-bottom?)) :ref editor-ref :data-testid "text-editor-content" :data-x (dm/get-prop shape :x) @@ -348,7 +364,7 @@ ;; NOTE: this teoretically breaks hooks rules, but in practice ;; it is imposible to really break it maybe-zoom - (when (cf/check-browser? :safari-16) + (when (cf/check-browser? :safari) (mf/deref refs/selected-zoom)) shape (cond-> shape @@ -404,17 +420,23 @@ ;; Transform is necessary when there is a text overflow and the vertical ;; aligment is center or bottom. (and (not render-wasm?) - (not (cf/check-browser? :safari))) + (not (cf/check-browser? :safari-16))) (obj/merge! #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! #js {:height "100%" :display "flex" :flexDirection "column" :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) (obj/merge! #js {:position "fixed" @@ -435,4 +457,5 @@ [:div {:style style} [:& text-editor-html {:shape shape :canvas-ref canvas-ref + :render-wasm? render-wasm? :key (dm/str shape-id)}]]]])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs index 0c0b839864..66fd558ccd 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs @@ -24,6 +24,7 @@ [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.undo :as dwu] [app.main.data.workspace.variants :as dwv] + [app.main.features :as features] [app.main.refs :as refs] [app.main.render :refer [component-svg component-svg-thumbnail]] [app.main.store :as st] @@ -298,7 +299,8 @@ (inc retry))))] (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 {:thumbnail-uri thumbnail-uri :class class