Add waitForLayoutUpdate plugin method

This commit is contained in:
alonso.torres 2026-05-13 15:12:15 +02:00
parent c66ee1803f
commit 12189fae5c
11 changed files with 336 additions and 15 deletions

View File

@ -743,7 +743,7 @@
subtree-ids-by-id]
:or {ignore-constraints false ignore-snap-pixel false snap-ignore-axis nil undo-transation? true}
:as params}]
(ptk/reify ::apply-wasm-modifiesr
(ptk/reify ::apply-wasm-modifiers
ptk/WatchEvent
(watch [_ state _]
(let [translation?

View File

@ -119,6 +119,11 @@
:undo-group undo-group}))))
(rx/empty))))))
;; Signals that one or more :layout/update events have arrived and are
;; still waiting. Read by the plugin API `app.plugins.api/waitForLayoutUpdate`
;; to decide whether layout work is still in flight.
(defonce layout-pending (atom false))
(defn initialize-shape-layout
[]
(ptk/reify ::initialize-shape-layout
@ -129,18 +134,21 @@
;; FIXME: we don't need use types for simple signaling,
;; we can just use a keyword for it
(rx/filter (ptk/type? :layout/update))
(rx/tap #(reset! layout-pending true))
(rx/map deref)
;; We buffer the updates to the layout so if there are many changes at the same time
;; they are process together. It will get a better performance.
(rx/buffer-time 100)
(rx/filter #(d/not-empty? %))
(rx/tap #(reset! layout-pending false))
(rx/mapcat
(fn [data]
(->> (group-by :page-id data)
(map (fn [[page-id items]]
(let [ids (reduce #(into %1 (:ids %2)) #{} items)]
(update-layout-positions {:page-id page-id :ids ids})))))))
(rx/take-until stopper))))))
(rx/take-until stopper)
(rx/finalize #(reset! layout-pending false)))))))
(defn finalize-shape-layout
[]

View File

@ -59,6 +59,8 @@
(declare v2-update-text-editor-styles)
(declare v2-sync-wasm-text-layout)
(def ^:private ^:const stuck-timeout 10000)
;; -- Content helpers
(defn- v2-content-has-text?
@ -391,11 +393,18 @@
(update-content-range start end attrs))]
(assoc shape :content new-content)))
;; Signals that a :font-id change on a text shape (non-wasm render path) is
;; still waiting for the new position data to be recalculated and committed.
;; Read by the plugin API `app.plugins.api/waitForLayoutUpdate`
;; to decide whether layout work is still in flight.
(defonce font-pending (atom false))
(defn update-text-range
[id start end attrs]
(ptk/reify ::update-text-range
ptk/WatchEvent
(watch [_ state _]
(watch [_ state stream]
(let [objects (dsh/lookup-page-objects state)
shape (get objects id)
@ -412,7 +421,17 @@
(rx/of (dwsh/update-shapes shape-ids update-fn))
(if (features/active-feature? state "render-wasm/v1")
(rx/of (dwwt/resize-wasm-text-debounce id))
(rx/empty)))))))
(when (contains? attrs :font-id)
(reset! font-pending true)
(let [stopper (rx/filter (ptk/type? :app.main.data.workspace/finalize) stream)]
(->> stream
(rx/filter (ptk/type? ::commit-position-data))
(rx/take 1)
;; Timeout so the flag cannot get stuck
(rx/timeout stuck-timeout (rx/of :timeout))
(rx/take-until stopper)
(rx/finalize #(reset! font-pending false))
(rx/ignore))))))))))
(defn update-root-attrs
[{:keys [id attrs]}]
@ -844,7 +863,7 @@
(not (features/active-feature? state "text-editor-wasm/v1")))
(rx/of (v2-update-text-editor-styles id attrs)))
(when (features/active-feature? state "render-wasm/v1")
(if (features/active-feature? state "render-wasm/v1")
(rx/concat
;; Apply style to selected spans and sync content
(let [has-selection? (wasm.api/text-editor-has-selection?)]
@ -858,12 +877,43 @@
:update-name? true))))))))
;; Resize (with delay for font-id changes)
(if (contains? attrs :font-id)
(->> stream
(rx/filter (font-loaded-event? (:font-id attrs)))
(rx/take 1)
(rx/observe-on :async)
(rx/map #(dwwt/resize-wasm-text id)))
(rx/of (dwwt/resize-wasm-text id)))))))))
(let [objects (dsh/lookup-page-objects state)
shape (get objects id)]
(if (and (cfh/text-shape? shape)
(not= :fixed (:grow-type shape)))
;; Auto-height/auto-width: set resize-pending immediately so
;; waitForLayoutUpdate detects pending work. Dispatch resize to
;; trigger font loading (set-shape-text-content → store-fonts),
;; then re-dispatch after font-loaded for correct final geometry.
(do
(reset! dwwt/resize-pending true)
(rx/concat
(rx/of (dwwt/resize-wasm-text id))
(let [stopper (rx/filter (ptk/type? :app.main.data.workspace/finalize) stream)]
(->> stream
(rx/filter (font-loaded-event? (:font-id attrs)))
(rx/take 1)
;; Timeout if the font-loaded event doesn't arrive eventually
(rx/timeout stuck-timeout (rx/of :timeout))
(rx/take-until stopper)
(rx/observe-on :async)
(rx/map #(dwwt/resize-wasm-text id))
(rx/finalize #(reset! dwwt/resize-pending false))))))
;; Fixed: geometry doesn't change with font; no need to wait.
(rx/of (dwwt/resize-wasm-text id))))
(rx/of (dwwt/resize-wasm-text id))))
(when (contains? attrs :font-id)
(reset! font-pending true)
(let [stopper (rx/filter (ptk/type? :app.main.data.workspace/finalize) stream)]
(->> stream
(rx/filter (ptk/type? ::commit-position-data))
(rx/take 1)
;; Timeout so the flag cannot get stuck
(rx/timeout stuck-timeout (rx/of :timeout))
(rx/take-until stopper)
(rx/finalize #(reset! font-pending false))
(rx/ignore)))))))))
ptk/EffectEvent
(effect [_ state _]

View File

@ -125,6 +125,14 @@
:else
(rx/empty)))))))
;; Signals that a wasm text resize is still in flight: set to true when a
;; resize enters the debounce window below (or while `update-attrs` waits for
;; a font load, see app.main.data.workspace.texts) and reset to false once the
;; final resize is dispatched or the wait is cut short. Read by the plugin API
;; `waitForLayoutUpdate` (app.plugins.api) to decide whether text geometry
;; work is still in flight.
(defonce resize-pending (atom false))
;; This event will debounce the resize events so, if there are many, they
;; are processed at the same time and not one-by-one. This will improve
;; performance because it's better to make only one layout calculation instead
@ -150,13 +158,17 @@
(rx/merge
(->> stream
(rx/filter (ptk/type? ::resize-wasm-text-debounce-inner))
(rx/tap #(reset! resize-pending true))
(rx/debounce debounce-resize-text-time)
(rx/take 1)
(rx/map (fn [evt]
(resize-wasm-text-debounce-commit
(some-> evt meta :undo-group)
(some-> evt meta :undo-id))))
(rx/take-until stopper))
(rx/take-until stopper)
;; Don't leave the flag stuck if the stopper cuts the
;; wait short while a resize is still debouncing
(rx/finalize #(reset! resize-pending false)))
(rx/of (with-meta
(resize-wasm-text-debounce-inner id)
{:undo-group undo-group :undo-id undo-id})))

View File

@ -211,6 +211,8 @@
;; LOAD API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def on-loaded-font nil)
(defn ensure-loaded!
([font-id] (ensure-loaded! font-id nil))
([font-id variant-id]
@ -240,6 +242,8 @@
(let [on-load (fn [resolve]
(swap! loaded conj font-id)
(swap! loading dissoc font-id)
(when (fn? on-loaded-font)
(on-loaded-font font-id))
(resolve font-id))
load-p (-> (p/create

View File

@ -27,9 +27,18 @@
[app.util.text-editor :as ted]
[app.util.text-svg-position :as tsp]
[app.util.text.content :as content]
[potok.v2.core :as ptk]
[promesa.core :as p]
[rumext.v2 :as mf]))
;; Re-emit browser-side font loads as :font-loaded store events so consumers
;; filtering with `font-loaded-event?` (app.main.data.workspace.texts) also
;; wake up for fonts loaded through `fonts/ensure-loaded!`, not only for the
;; ones loaded by the wasm font store.
(set! fonts/on-loaded-font
(fn [font-id]
(st/emit! (ptk/data-event :font-loaded {:font-id font-id}))))
(defn fix-position
[shape]
(if-let [modifiers (:modifiers shape)]

View File

@ -27,7 +27,10 @@
[app.main.data.workspace.colors :as dwc]
[app.main.data.workspace.groups :as dwg]
[app.main.data.workspace.media :as dwm]
[app.main.data.workspace.modifiers :as dwmm]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.shape-layout :as dwsl]
[app.main.data.workspace.texts :as dwt]
[app.main.data.workspace.variants :as dwv]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.features :as features]
@ -54,7 +57,8 @@
[app.util.object :as obj]
[app.util.theme :as theme]
[beicon.v2.core :as rx]
[cuerdas.core :as str]))
[cuerdas.core :as str]
[potok.v2.core :as ptk]))
;;
;; PLUGINS PUBLIC API - The plugins will able to access this functions
@ -675,4 +679,67 @@
(se/add-event plugin-id)))
(shape/shape-proxy plugin-id variant-id))
(u/not-valid plugin-id :shapes "One of the components is not on the same page or is already a variant")))))))
(u/not-valid plugin-id :shapes "One of the components is not on the same page or is already a variant")))))
:waitForLayoutUpdate
(fn [timeout]
(js/Promise.
(fn [resolve reject]
(let [work-pending?
(fn []
;; `layout-pending`: flex/grid updates buffered in the 100ms
;; buffer-time window of `initialize-shape-layout`; cleared
;; when the buffer flushes and `update-layout-positions` is
;; dispatched.
;; `resize-pending`: wasm text resize debouncing or waiting
;; for a font load; cleared once the final resize is
;; dispatched.
;; `font-pending`: non-wasm font change waiting for the next
;; `commit-position-data`; cleared when it fires.
(boolean (or @dwsl/layout-pending
@dwwt/resize-pending
@dwt/font-pending)))
;; Progress signals: events that mark the completion of one of
;; the pending work kinds above (plus workspace finalization,
;; which clears the flags via the stoppers of their listeners).
;; They only trigger a re-check of the flags: several kinds of
;; work can be pending at once and one kind can queue another
;; (e.g. a text resize triggering a flex layout update), so we
;; resolve only when no flag remains set.
progress-event?
(fn [event]
(let [type (ptk/type event)]
(or (= type ::dwsl/update-layout-positions)
(= type ::dwmm/apply-wasm-modifiers)
(= type ::dwt/commit-position-data)
(= type :app.main.data.workspace/finalize))))]
(if-not (work-pending?)
;; Fast path: nothing pending, resolve right away.
(resolve)
(->> (rx/merge
;; Optional deadline: if a timeout was requested, emit
;; :timeout after `timeout` ms so the promise rejects when
;; updates stall.
(if timeout
(->> (rx/of :timeout)
(rx/delay timeout))
(rx/empty))
(->> st/stream
(rx/filter progress-event?)
;; Defer the re-check so the flag-clearing handlers
;; (and any synchronous follow-up work they queue)
;; observe the event before we read the flags.
(rx/observe-on :async)
(rx/filter #(not (work-pending?)))
(rx/map (constantly :ok))))
(rx/take 1)
(rx/subs!
(fn [value]
(if (= value :timeout)
(reject (js/Error. "waitForLayoutUpdate timeout"))
(resolve)))
reject)))))))))

View File

@ -9,12 +9,17 @@
[app.common.math :as m]
[app.common.test-helpers.files :as cthf]
[app.common.uuid :as uuid]
[app.main.data.workspace.modifiers :as dwmm]
[app.main.data.workspace.shape-layout :as dwsl]
[app.main.data.workspace.texts :as dwt]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.store :as st]
[app.plugins.api :as api]
[app.util.object :as obj]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.state :as ths]
[frontend-tests.helpers.wasm :as thw]))
[frontend-tests.helpers.wasm :as thw]
[potok.v2.core :as ptk]))
(t/deftest test-common-shape-properties
(thw/with-wasm-mocks*
@ -370,3 +375,152 @@
(t/is (pos? (thw/call-count :clean-modifiers)))
(t/is (pos? (thw/call-count :set-structure-modifiers)))
(t/is (pos? (thw/call-count :propagate-modifiers))))))))
;; ---- waitForLayoutUpdate tests ------------------------------------------
;;
;; Each test sets up a fresh potok store and replaces st/state / st/stream so
;; that st/emit! and waitForLayoutUpdate work against the same event bus.
;; The fixture below restores the original globals and clears the pending
;; flags so no state leaks between tests (or into other test namespaces).
(def ^:private original-st-state st/state)
(def ^:private original-st-stream st/stream)
(defn- reset-pending-flags!
[]
(reset! dwsl/layout-pending false)
(reset! dwwt/resize-pending false)
(reset! dwt/font-pending false))
(t/use-fixtures :each
{:before reset-pending-flags!
:after (fn []
(reset-pending-flags!)
(set! st/state original-st-state)
(set! st/stream original-st-stream))})
(defn- make-test-store
"Creates a minimal potok store with an empty state map and installs it as the
global st/state and st/stream for the duration of the calling test."
[]
(let [test-store (ptk/store {:state {} :on-error (fn [e] (js/console.error e))})]
(set! st/state test-store)
(set! st/stream (ptk/input-stream test-store))
test-store))
(t/deftest test-wait-for-layout-update-no-pending
;; When all pending flags are clear the promise should resolve immediately
;; via the fast path — no store event is needed.
(t/async done
(make-test-store)
(let [^js ctx (api/create-context "00000000-0000-0000-0000-000000000000")]
(-> (.waitForLayoutUpdate ctx)
(.then (fn []
(t/is true "resolved with no pending work")
(done)))
(.catch (fn [err]
(t/is false (str "unexpected rejection: " err))
(done)))))))
(t/deftest test-wait-for-layout-update-layout-pending
;; layout-pending=true signals a flex/grid recalculation in progress.
;; The promise should stay pending until ::update-layout-positions fires
;; with the flag already cleared (mirroring the real pipeline, where the
;; flag is reset when the buffer flushes, just before the event).
(t/async done
(make-test-store)
(reset! dwsl/layout-pending true)
(let [^js ctx (api/create-context "00000000-0000-0000-0000-000000000000")]
(-> (.waitForLayoutUpdate ctx)
(.then (fn []
(t/is true "resolved after update-layout-positions event")
(done)))
(.catch (fn [err]
(t/is false (str "unexpected rejection: " err))
(done))))
;; The Promise constructor ran synchronously so the subscription is
;; already live on st/stream. Emitting now triggers the re-check.
(reset! dwsl/layout-pending false)
(st/emit! (ptk/data-event ::dwsl/update-layout-positions)))))
(t/deftest test-wait-for-layout-update-resize-pending
;; resize-pending=true signals wasm text geometry being recalculated.
;; The promise should stay pending until ::apply-wasm-modifiers fires
;; with the flag already cleared.
(t/async done
(make-test-store)
(reset! dwwt/resize-pending true)
(let [^js ctx (api/create-context "00000000-0000-0000-0000-000000000000")]
(-> (.waitForLayoutUpdate ctx)
(.then (fn []
(t/is true "resolved after apply-wasm-modifiers event")
(done)))
(.catch (fn [err]
(t/is false (str "unexpected rejection: " err))
(done))))
(reset! dwwt/resize-pending false)
(st/emit! (ptk/data-event ::dwmm/apply-wasm-modifiers)))))
(t/deftest test-wait-for-layout-update-font-pending
;; font-pending=true signals a non-wasm CSS font measurement in progress.
;; The promise should stay pending until ::commit-position-data fires
;; with the flag already cleared.
(t/async done
(make-test-store)
(reset! dwt/font-pending true)
(let [^js ctx (api/create-context "00000000-0000-0000-0000-000000000000")]
(-> (.waitForLayoutUpdate ctx)
(.then (fn []
(t/is true "resolved after commit-position-data event")
(done)))
(.catch (fn [err]
(t/is false (str "unexpected rejection: " err))
(done))))
(reset! dwt/font-pending false)
(st/emit! (ptk/data-event ::dwt/commit-position-data)))))
(t/deftest test-wait-for-layout-update-rechecks-pending
;; A completion event must NOT resolve the promise while any pending flag
;; is still set. This covers the font-change resize flow, where an early
;; ::apply-wasm-modifiers (pre font-load resize) fires while resize-pending
;; is still true and only a later one marks the real completion.
(t/async done
(make-test-store)
(reset! dwwt/resize-pending true)
(let [^js ctx (api/create-context "00000000-0000-0000-0000-000000000000")
resolved (atom false)]
(-> (.waitForLayoutUpdate ctx)
(.then (fn [] (reset! resolved true)))
(.catch (fn [err]
(t/is false (str "unexpected rejection: " err)))))
;; Early apply-wasm-modifiers: the flag is still set, so the re-check
;; must keep the promise pending.
(st/emit! (ptk/data-event ::dwmm/apply-wasm-modifiers))
(js/setTimeout
(fn []
(t/is (false? @resolved) "must not resolve while resize-pending is set")
;; Final apply-wasm-modifiers with the flag cleared resolves it.
(reset! dwwt/resize-pending false)
(st/emit! (ptk/data-event ::dwmm/apply-wasm-modifiers))
(js/setTimeout
(fn []
(t/is (true? @resolved) "resolves once all flags are clear")
(done))
30))
30))))
(t/deftest test-wait-for-layout-update-timeout
;; When the optional timeout fires before any completion event the promise
;; should reject with an Error whose message mentions "timeout".
(t/async done
(make-test-store)
(reset! dwsl/layout-pending true)
(let [^js ctx (api/create-context "00000000-0000-0000-0000-000000000000")]
(-> (.waitForLayoutUpdate ctx 20)
(.then (fn []
(t/is false "expected rejection but promise resolved")
(done)))
(.catch (fn [^js err]
(t/is (instance? js/Error err) "rejection value should be an Error")
(t/is (some? (re-find #"timeout" (.-message err))) "error message should mention timeout")
(done)))))))

View File

@ -10,6 +10,7 @@
- **plugin-types**: Added `textBounds` property for text shapes
- **plugin-types**: Added flag `throwValidationErrors` to enable exceptions on validation
- **plugin-types**: Fix missing `webp` export format in `Export.type`
- **plugin-types**: Added `waitForLayoutUpdate` to wait until pending layout updates have finished
## 1.4.2 (2026-01-21)

View File

@ -1330,6 +1330,17 @@ export interface Context {
* @return The variant container created
*/
createVariantFromComponents(shapes: Board[]): VariantContainer;
/**
* This method returns a promise that will be resolved when all the
* pending layout updates have finished. If no layout work is pending
* the promise resolves immediately.
* @param timeout Maximum time to wait, in milliseconds. If the timeout
* elapses before the layout settles, the promise is rejected. Without a
* timeout the promise waits indefinitely.
* @return The promise to be resolved when the layout is updated
*/
waitForLayoutUpdate(timeout?: number): Promise<void>;
}
/**

View File

@ -388,6 +388,11 @@ export function createApi(
checkPermission('content:write');
return plugin.context.createVariantFromComponents(shapes);
},
waitForLayoutUpdate(timeout?: number): Promise<void> {
checkPermission('content:read');
return plugin.context.waitForLayoutUpdate(timeout);
},
};
return {