diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index add4f050b4..21d7df5d24 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -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? diff --git a/frontend/src/app/main/data/workspace/shape_layout.cljs b/frontend/src/app/main/data/workspace/shape_layout.cljs index 0e84539988..7bcbac390d 100644 --- a/frontend/src/app/main/data/workspace/shape_layout.cljs +++ b/frontend/src/app/main/data/workspace/shape_layout.cljs @@ -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 [] diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 30b2ecf40b..1cd0d2b062 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -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 _] diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs index 6468f487aa..052f7cbb0d 100644 --- a/frontend/src/app/main/data/workspace/wasm_text.cljs +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -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}))) diff --git a/frontend/src/app/main/fonts.cljs b/frontend/src/app/main/fonts.cljs index 02181c3db8..34f8195c47 100644 --- a/frontend/src/app/main/fonts.cljs +++ b/frontend/src/app/main/fonts.cljs @@ -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 diff --git a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs index a22b82f46a..adfb430357 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs @@ -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)] diff --git a/frontend/src/app/plugins/api.cljs b/frontend/src/app/plugins/api.cljs index 8c0d07f316..f94eb01bd6 100644 --- a/frontend/src/app/plugins/api.cljs +++ b/frontend/src/app/plugins/api.cljs @@ -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))))))))) diff --git a/frontend/test/frontend_tests/plugins/context_shapes_test.cljs b/frontend/test/frontend_tests/plugins/context_shapes_test.cljs index 26192557f7..9ea5ed259a 100644 --- a/frontend/test/frontend_tests/plugins/context_shapes_test.cljs +++ b/frontend/test/frontend_tests/plugins/context_shapes_test.cljs @@ -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))))))) diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 614a77407b..6452f75046 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -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) diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index 7293964814..a0676484ac 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -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; } /** diff --git a/plugins/libs/plugins-runtime/src/lib/api/index.ts b/plugins/libs/plugins-runtime/src/lib/api/index.ts index 885967a2f1..e4ea336940 100644 --- a/plugins/libs/plugins-runtime/src/lib/api/index.ts +++ b/plugins/libs/plugins-runtime/src/lib/api/index.ts @@ -388,6 +388,11 @@ export function createApi( checkPermission('content:write'); return plugin.context.createVariantFromComponents(shapes); }, + + waitForLayoutUpdate(timeout?: number): Promise { + checkPermission('content:read'); + return plugin.context.waitForLayoutUpdate(timeout); + }, }; return {