mirror of
https://github.com/penpot/penpot.git
synced 2026-04-28 20:58:06 +00:00
Merge remote-tracking branch 'origin/staging' into develop
This commit is contained in:
commit
56b28b5440
@ -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
|
||||
|
||||
@ -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))))
|
||||
|
||||
|
||||
@ -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))))))
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
118
frontend/src/app/main/data/workspace/thumbnails_wasm.cljs
Normal file
118
frontend/src/app/main/data/workspace/thumbnails_wasm.cljs
Normal 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)))))))
|
||||
@ -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.
|
||||
|
||||
@ -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)}]]]]))
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user