mirror of
https://github.com/penpot/penpot.git
synced 2026-06-29 02:32:04 +00:00
✨ Add waitForLayoutUpdate plugin method
This commit is contained in:
parent
5775e947ad
commit
17915e6717
@ -744,7 +744,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?
|
||||
|
||||
117
frontend/src/app/main/data/workspace/reflow.cljs
Normal file
117
frontend/src/app/main/data/workspace/reflow.cljs
Normal file
@ -0,0 +1,117 @@
|
||||
;; 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.reflow
|
||||
"Tracks the shape ids that have layout/reflow work in flight, broken down by
|
||||
the kind of work so we can tell which type of reflow is blocking each shape.
|
||||
|
||||
Pending work is stored as a nested refcount map `{shape-id -> {kind -> count}}`:
|
||||
a shape is pending while it has at least one kind with a count greater than
|
||||
zero. The count (instead of a plain set) is required because several reflow
|
||||
operations of the same kind can target the same shape while an earlier one is
|
||||
still in flight; each `mark-pending!` must be balanced by exactly one
|
||||
`mark-done!` of the same kind and ids so an operation finishing doesn't resolve
|
||||
waiters that are still blocked on another operation for the same shape.
|
||||
|
||||
Kinds correspond to the pipelines that schedule the work:
|
||||
:layout flex/grid layout reflow (shape-layout)
|
||||
:text-resize wasm text geometry resize (wasm-text)
|
||||
:font font change measurement (texts)"
|
||||
(:require
|
||||
[beicon.v2.core :as rx]))
|
||||
|
||||
;; Feeder subject receiving `{:op .. :kind .. :ids ..}` messages from
|
||||
;; mark-pending! / mark-done! / reset-pending!; scanned into the
|
||||
;; `pending-shapes` refcount map.
|
||||
(defonce ^:private reflow-input (rx/subject))
|
||||
|
||||
;; Increments the `kind` refcount of each id (adding the shape/kind starting at 1).
|
||||
(defn- inc-ids
|
||||
[acc kind ids]
|
||||
(reduce (fn [m id] (update-in m [id kind] (fnil inc 0))) acc ids))
|
||||
|
||||
;; Decrements the `kind` refcount of each id, dropping the kind once it reaches
|
||||
;; zero and the shape once it has no pending kinds left (decrementing an absent
|
||||
;; id/kind is a no-op).
|
||||
(defn- dec-ids
|
||||
[acc kind ids]
|
||||
(reduce (fn [m id]
|
||||
(let [n (dec (get-in m [id kind] 0))
|
||||
kinds (if (pos? n)
|
||||
(assoc (get m id) kind n)
|
||||
(dissoc (get m id) kind))]
|
||||
(if (seq kinds)
|
||||
(assoc m id kinds)
|
||||
(dissoc m id))))
|
||||
acc ids))
|
||||
|
||||
;; Applies one `{:op .. :kind .. :ids ..}` message to the pending map: :add
|
||||
;; increments, :remove decrements, :reset clears everything.
|
||||
(defn- reducer
|
||||
[acc {:keys [op kind ids]}]
|
||||
(case op
|
||||
:add (inc-ids acc kind ids)
|
||||
:remove (dec-ids acc kind ids)
|
||||
:reset {}
|
||||
acc))
|
||||
|
||||
;; Behaviour subject holding the current pending map `{shape-id -> {kind -> count}}`.
|
||||
;; It replays its current value synchronously to new subscribers, which gives
|
||||
;; `wait-for-layout-update` a free fast-path when there is nothing pending.
|
||||
(defonce ^:private pending-shapes
|
||||
(let [sub (rx/behavior-subject {})]
|
||||
(rx/sub! (->> reflow-input (rx/scan reducer {})) sub)
|
||||
sub))
|
||||
|
||||
;; NOTE: do not dedupe `ids` — multiplicity must be preserved so each
|
||||
;; `mark-pending!` is balanced by exactly one `mark-done!` of the same kind and ids.
|
||||
(defn mark-pending!
|
||||
[kind ids]
|
||||
(rx/push! reflow-input {:op :add :kind kind :ids ids}))
|
||||
|
||||
(defn mark-done!
|
||||
[kind ids]
|
||||
(rx/push! reflow-input {:op :remove :kind kind :ids ids}))
|
||||
|
||||
(defn reset-pending!
|
||||
[]
|
||||
(rx/push! reflow-input {:op :reset}))
|
||||
|
||||
(defn wait-for-layout-update
|
||||
"Returns a JS Promise that resolves when `shape-id` (or, when nil, every
|
||||
pending shape) has drained from the pending map. When `timeout` (ms) is
|
||||
provided and elapses first, the promise is rejected. The single-arity form
|
||||
waits for every pending shape (shape-id nil)."
|
||||
([timeout]
|
||||
(wait-for-layout-update nil timeout))
|
||||
([shape-id timeout]
|
||||
(js/Promise.
|
||||
(fn [resolve reject]
|
||||
(let [done? (fn [pending]
|
||||
(if shape-id
|
||||
(not (contains? pending shape-id))
|
||||
(empty? pending)))
|
||||
|
||||
settled (->> pending-shapes
|
||||
(rx/filter done?)
|
||||
(rx/map (constantly :ok)))
|
||||
|
||||
;; Race the settle signal against the optional deadline; whichever
|
||||
;; reacts first wins (and the loser is unsubscribed). Without a
|
||||
;; timeout we just wait on the settle signal.
|
||||
source (if timeout
|
||||
(rx/race (->> (rx/of :timeout)
|
||||
(rx/delay timeout))
|
||||
settled)
|
||||
settled)]
|
||||
(->> source
|
||||
(rx/take 1)
|
||||
(rx/subs!
|
||||
(fn [value]
|
||||
(if (= value :timeout)
|
||||
(reject (js/Error. "waitForLayoutUpdate timeout"))
|
||||
(resolve)))
|
||||
reject)))))))
|
||||
@ -26,6 +26,7 @@
|
||||
[app.main.data.workspace.colors :as cl]
|
||||
[app.main.data.workspace.grid-layout.editor :as dwge]
|
||||
[app.main.data.workspace.modifiers :as dwm]
|
||||
[app.main.data.workspace.reflow :as wrf]
|
||||
[app.main.data.workspace.selection :as dwse]
|
||||
[app.main.data.workspace.shapes :as dwsh]
|
||||
[app.main.data.workspace.undo :as dwu]
|
||||
@ -130,17 +131,25 @@
|
||||
;; we can just use a keyword for it
|
||||
(rx/filter (ptk/type? :layout/update))
|
||||
(rx/map deref)
|
||||
;; Mark the affected shapes as pending reflow so the plugin API
|
||||
;; `waitForLayoutUpdate` can wait until the buffered updates flush.
|
||||
(rx/tap #(wrf/mark-pending! :layout (:ids %)))
|
||||
;; 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? %))
|
||||
;; Balance the per-event marks: decrement once per buffered event
|
||||
;; (multiplicity preserved via mapcat, no deduping).
|
||||
(rx/tap #(wrf/mark-done! :layout (mapcat :ids %)))
|
||||
(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)
|
||||
;; On workspace teardown clear everything still pending.
|
||||
(rx/finalize wrf/reset-pending!))))))
|
||||
|
||||
(defn finalize-shape-layout
|
||||
[]
|
||||
|
||||
@ -27,6 +27,7 @@
|
||||
[app.main.data.workspace.common :as dwc]
|
||||
[app.main.data.workspace.libraries :as dwl]
|
||||
[app.main.data.workspace.modifiers :as dwm]
|
||||
[app.main.data.workspace.reflow :as wrf]
|
||||
[app.main.data.workspace.selection :as dws]
|
||||
[app.main.data.workspace.shapes :as dwsh]
|
||||
[app.main.data.workspace.transforms :as dwt]
|
||||
@ -59,6 +60,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 +394,12 @@
|
||||
(update-content-range start end attrs))]
|
||||
(assoc shape :content new-content)))
|
||||
|
||||
|
||||
(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 +416,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)
|
||||
(wrf/mark-pending! :font [id])
|
||||
(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 shape cannot stay pending forever
|
||||
(rx/timeout stuck-timeout (rx/of :timeout))
|
||||
(rx/take-until stopper)
|
||||
(rx/finalize #(wrf/mark-done! :font [id]))
|
||||
(rx/ignore))))))))))
|
||||
|
||||
(defn update-root-attrs
|
||||
[{:keys [id attrs]}]
|
||||
@ -808,13 +822,6 @@
|
||||
(rx/of (update-position-data id position-data))))
|
||||
(rx/empty))))))
|
||||
|
||||
(defn font-loaded-event?
|
||||
[font-id]
|
||||
(fn [event]
|
||||
(and
|
||||
(= :font-loaded (ptk/type event))
|
||||
(= (:font-id (deref event)) font-id))))
|
||||
|
||||
(defn update-attrs
|
||||
[id attrs]
|
||||
(ptk/reify ::update-attrs
|
||||
@ -844,7 +851,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 +865,45 @@
|
||||
: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: mark the shape 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.
|
||||
;; This path doesn't go through the resize debounce pipeline, so
|
||||
;; it owns its own mark-pending!/mark-done! pair.
|
||||
(do
|
||||
(wrf/mark-pending! :font [id])
|
||||
(rx/concat
|
||||
(rx/of (dwwt/resize-wasm-text id))
|
||||
(let [stopper (rx/filter (ptk/type? :app.main.data.workspace/finalize) stream)]
|
||||
(->> fonts/font-loaded-stream
|
||||
(rx/filter #(= % (:font-id attrs)))
|
||||
(rx/take 1)
|
||||
;; Timeout if the font load 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 #(wrf/mark-done! :font [id]))))))
|
||||
;; 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)
|
||||
(wrf/mark-pending! :font [id])
|
||||
(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 shape cannot stay pending forever
|
||||
(rx/timeout stuck-timeout (rx/of :timeout))
|
||||
(rx/take-until stopper)
|
||||
(rx/finalize #(wrf/mark-done! :font [id]))
|
||||
(rx/ignore)))))))))
|
||||
|
||||
ptk/EffectEvent
|
||||
(effect [_ state _]
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
[app.common.types.modifiers :as ctm]
|
||||
[app.main.data.helpers :as dsh]
|
||||
[app.main.data.workspace.modifiers :as dwm]
|
||||
[app.main.data.workspace.reflow :as wrf]
|
||||
[app.main.data.workspace.shapes :as dwsh]
|
||||
[app.main.data.workspace.undo :as dwu]
|
||||
[app.render-wasm.api :as wasm.api]
|
||||
@ -110,6 +111,10 @@
|
||||
apply-opts (cond-> {}
|
||||
(some? undo-group) (assoc :undo-group undo-group)
|
||||
extend-tx? (assoc :undo-transation? false))]
|
||||
;; Balance the per-invocation `mark-pending!` done in
|
||||
;; resize-wasm-text-debounce-inner: the batch carries one entry per
|
||||
;; conj (duplicates included), so this exactly clears them.
|
||||
(wrf/mark-done! :text-resize ids)
|
||||
(cond
|
||||
(not (empty? modifiers))
|
||||
(if extend-tx?
|
||||
@ -144,6 +149,12 @@
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ state stream]
|
||||
;; One mark per invocation (1:1 with the conj in UpdateEvent above);
|
||||
;; balanced by `mark-done!` of the full batch in
|
||||
;; resize-wasm-text-debounce-commit. The stopper below is workspace
|
||||
;; teardown, which clears everything via shape-layout's finalize, so no
|
||||
;; extra decrement is needed when the wait is cut short.
|
||||
(wrf/mark-pending! :text-resize [id])
|
||||
(if (= (::resize-wasm-text-debounce-event state) cur-event)
|
||||
(let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))]
|
||||
(rx/concat
|
||||
|
||||
@ -211,6 +211,11 @@
|
||||
;; LOAD API
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
;; Emits the font-id of every font that finishes loading (browser side). Lets
|
||||
;; consumers (e.g. app.main.data.workspace.texts) react to font loads without
|
||||
;; this namespace having to depend on the store/potok/ui.
|
||||
(defonce font-loaded-stream (rx/subject))
|
||||
|
||||
(defn ensure-loaded!
|
||||
([font-id] (ensure-loaded! font-id nil))
|
||||
([font-id variant-id]
|
||||
@ -240,6 +245,7 @@
|
||||
(let [on-load (fn [resolve]
|
||||
(swap! loaded conj font-id)
|
||||
(swap! loading dissoc font-id)
|
||||
(rx/push! font-loaded-stream font-id)
|
||||
(resolve font-id))
|
||||
|
||||
load-p (-> (p/create
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
[app.main.data.workspace.groups :as dwg]
|
||||
[app.main.data.workspace.media :as dwm]
|
||||
[app.main.data.workspace.pages :as dwpg]
|
||||
[app.main.data.workspace.reflow :as wrf]
|
||||
[app.main.data.workspace.selection :as dws]
|
||||
[app.main.data.workspace.variants :as dwv]
|
||||
[app.main.data.workspace.wasm-text :as dwwt]
|
||||
@ -699,4 +700,9 @@
|
||||
(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]
|
||||
;; Resolve once every shape with reflow work in flight has settled.
|
||||
(wrf/wait-for-layout-update timeout))))
|
||||
|
||||
@ -42,6 +42,7 @@
|
||||
[app.main.data.workspace.guides :as dwgu]
|
||||
[app.main.data.workspace.interactions :as dwi]
|
||||
[app.main.data.workspace.libraries :as dwl]
|
||||
[app.main.data.workspace.reflow :as wrf]
|
||||
[app.main.data.workspace.selection :as dws]
|
||||
[app.main.data.workspace.shape-layout :as dwsl]
|
||||
[app.main.data.workspace.shapes :as dwsh]
|
||||
@ -1008,6 +1009,11 @@
|
||||
:else
|
||||
(st/emit! (dwsh/delete-shapes #{id}))))
|
||||
|
||||
:waitForLayoutUpdate
|
||||
(fn [timeout]
|
||||
;; Resolve once this shape's pending reflow work has settled.
|
||||
(wrf/wait-for-layout-update id timeout))
|
||||
|
||||
;; Plugin data
|
||||
:getPluginData
|
||||
(fn [key]
|
||||
|
||||
@ -17,12 +17,12 @@
|
||||
[app.render-wasm.helpers :as h]
|
||||
[app.render-wasm.wasm :as wasm]
|
||||
[app.util.http :as http]
|
||||
[app.util.timers :as tm]
|
||||
[beicon.v2.core :as rx]
|
||||
[cuerdas.core :as str]
|
||||
[goog.object :as gobj]
|
||||
[lambdaisland.uri :as u]
|
||||
[okulary.core :as l]
|
||||
[potok.v2.core :as ptk]))
|
||||
[okulary.core :as l]))
|
||||
|
||||
(def ^:private fonts
|
||||
(l/derived :fonts st/state))
|
||||
@ -130,7 +130,7 @@
|
||||
mem (js/Uint8Array. (.-buffer heap) ptr size)]
|
||||
|
||||
(.set mem (js/Uint8Array. font-array-buffer))
|
||||
(st/emit! (ptk/data-event :font-loaded {:font-id (:font-id font-data)}))
|
||||
(rx/push! fonts/font-loaded-stream (:font-id font-data))
|
||||
(h/call wasm/internal-module "_store_font"
|
||||
(aget font-id-buffer 0)
|
||||
(aget font-id-buffer 1)
|
||||
@ -213,7 +213,10 @@
|
||||
font-data (assoc font-data :family-id-buffer id-buffer)
|
||||
font-stored? (font-stored? font-data emoji?)]
|
||||
(if font-stored?
|
||||
(st/async-emit! (ptk/data-event :font-loaded {:font-id (:font-id font-data)}))
|
||||
;; Defer so the consumer (which subscribes to font-loaded-stream after
|
||||
;; dispatching the resize that triggers this store) is listening when
|
||||
;; the already-stored font is reported as loaded.
|
||||
(tm/schedule #(rx/push! fonts/font-loaded-stream (:font-id font-data)))
|
||||
(fetch-font font-data uri emoji? fallback?)))))
|
||||
|
||||
(defn serialize-font-style
|
||||
|
||||
@ -9,12 +9,15 @@
|
||||
[app.common.math :as m]
|
||||
[app.common.test-helpers.files :as cthf]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.workspace.reflow :as wrf]
|
||||
[app.main.store :as st]
|
||||
[app.plugins.api :as api]
|
||||
[app.plugins.shape :as shape]
|
||||
[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*
|
||||
@ -402,3 +405,139 @@
|
||||
(let [shadows (.-shadows shape)]
|
||||
(t/is (array? shadows))
|
||||
(t/is (= 0 (.-length shadows)))))))))
|
||||
|
||||
;; ---- waitForLayoutUpdate tests ------------------------------------------
|
||||
;;
|
||||
;; `waitForLayoutUpdate` resolves once the shapes with reflow work in flight
|
||||
;; have drained from the `app.main.data.workspace.reflow` pending refcount map.
|
||||
;; The tests drive that map directly with `mark-pending!` / `mark-done!` instead
|
||||
;; of replaying internal pipeline events. A minimal store is still installed so
|
||||
;; `create-context` / `shape-proxy` can resolve the global st/state.
|
||||
|
||||
(def ^:private original-st-state st/state)
|
||||
(def ^:private original-st-stream st/stream)
|
||||
(def ^:private zero-id "00000000-0000-0000-0000-000000000000")
|
||||
|
||||
(t/use-fixtures :each
|
||||
{:before wrf/reset-pending!
|
||||
:after (fn []
|
||||
(wrf/reset-pending!)
|
||||
(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 nothing is pending the promise resolves immediately via the fast path
|
||||
;; (the behavior-subject replays the empty map on subscribe).
|
||||
(t/async done
|
||||
(make-test-store)
|
||||
(let [^js ctx (api/create-context zero-id)]
|
||||
(-> (.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-pending
|
||||
;; While a shape is pending the context promise stays unresolved; it resolves
|
||||
;; once that shape is marked done.
|
||||
(t/async done
|
||||
(make-test-store)
|
||||
(wrf/mark-pending! :layout [(uuid/next)])
|
||||
(let [^js ctx (api/create-context zero-id)
|
||||
resolved (atom false)]
|
||||
(-> (.waitForLayoutUpdate ctx)
|
||||
(.then (fn [] (reset! resolved true)))
|
||||
(.catch (fn [err]
|
||||
(t/is false (str "unexpected rejection: " err)))))
|
||||
(js/setTimeout
|
||||
(fn []
|
||||
(t/is (false? @resolved) "must not resolve while a shape is pending")
|
||||
;; Draining everything resolves the context wait.
|
||||
(wrf/reset-pending!)
|
||||
(js/setTimeout
|
||||
(fn []
|
||||
(t/is (true? @resolved) "resolves once nothing is pending")
|
||||
(done))
|
||||
20))
|
||||
20))))
|
||||
|
||||
(t/deftest test-wait-for-layout-update-per-shape
|
||||
;; `shape.waitForLayoutUpdate()` only waits for that shape: draining another
|
||||
;; shape must not resolve it; draining its own id does.
|
||||
(t/async done
|
||||
(make-test-store)
|
||||
(let [id-a (uuid/next)
|
||||
id-b (uuid/next)
|
||||
^js shape-a (shape/shape-proxy zero-id uuid/zero uuid/zero id-a)
|
||||
resolved (atom false)]
|
||||
(wrf/mark-pending! :layout [id-a id-b])
|
||||
(-> (.waitForLayoutUpdate shape-a)
|
||||
(.then (fn [] (reset! resolved true)))
|
||||
(.catch (fn [err]
|
||||
(t/is false (str "unexpected rejection: " err)))))
|
||||
;; Draining the other shape leaves A pending.
|
||||
(wrf/mark-done! :layout [id-b])
|
||||
(js/setTimeout
|
||||
(fn []
|
||||
(t/is (false? @resolved) "shape A wait must stay pending while A is pending")
|
||||
(wrf/mark-done! :layout [id-a])
|
||||
(js/setTimeout
|
||||
(fn []
|
||||
(t/is (true? @resolved) "resolves once shape A drains")
|
||||
(done))
|
||||
20))
|
||||
20))))
|
||||
|
||||
(t/deftest test-wait-for-layout-update-refcount
|
||||
;; Two overlapping operations on the same shape: a single mark-done! must not
|
||||
;; resolve the wait while the second operation is still in flight.
|
||||
(t/async done
|
||||
(make-test-store)
|
||||
(let [id (uuid/next)
|
||||
^js shape (shape/shape-proxy zero-id uuid/zero uuid/zero id)
|
||||
resolved (atom false)]
|
||||
(wrf/mark-pending! :layout [id])
|
||||
(wrf/mark-pending! :layout [id])
|
||||
(-> (.waitForLayoutUpdate shape)
|
||||
(.then (fn [] (reset! resolved true)))
|
||||
(.catch (fn [err]
|
||||
(t/is false (str "unexpected rejection: " err)))))
|
||||
;; First operation finishes; second is still pending.
|
||||
(wrf/mark-done! :layout [id])
|
||||
(js/setTimeout
|
||||
(fn []
|
||||
(t/is (false? @resolved) "must not resolve while a second op is in flight")
|
||||
(wrf/mark-done! :layout [id])
|
||||
(js/setTimeout
|
||||
(fn []
|
||||
(t/is (true? @resolved) "resolves once both operations drain")
|
||||
(done))
|
||||
20))
|
||||
20))))
|
||||
|
||||
(t/deftest test-wait-for-layout-update-timeout
|
||||
;; When the optional timeout fires before the shape drains the promise should
|
||||
;; reject with an Error whose message mentions "timeout".
|
||||
(t/async done
|
||||
(make-test-store)
|
||||
(wrf/mark-pending! :layout [(uuid/next)])
|
||||
(let [^js ctx (api/create-context zero-id)]
|
||||
(-> (.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)))))))
|
||||
|
||||
@ -17,6 +17,8 @@
|
||||
- **plugin-types**: Added `fixedWhenScrolling` property for shapes
|
||||
- **plugin-runtime:** `addToken` now resolves references against all token sets, allowing references to tokens in inactive sets
|
||||
- **plugin-types:** `TokenCatalog.addSet` now accepts an optional `active` flag to create an already-active set (sets are inactive by default)
|
||||
- **plugin-types**: Added `waitForLayoutUpdate` to wait until pending layout updates have finished
|
||||
- **plugin-types**: Added `waitForLayoutUpdate` to the `Shape` interface to wait until a single shape's pending layout updates have finished
|
||||
|
||||
## 1.4.2 (2026-01-21)
|
||||
|
||||
|
||||
22
plugins/libs/plugin-types/index.d.ts
vendored
22
plugins/libs/plugin-types/index.d.ts
vendored
@ -1343,6 +1343,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>;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -4019,6 +4030,17 @@ export interface ShapeBase extends PluginData {
|
||||
* Removes the shape from its parent.
|
||||
*/
|
||||
remove(): void;
|
||||
|
||||
/**
|
||||
* This method returns a promise that will be resolved when the pending
|
||||
* layout updates for this shape have finished. If no layout work is pending
|
||||
* for the shape the promise resolves immediately.
|
||||
* @param timeout Maximum time to wait, in milliseconds. If the timeout
|
||||
* elapses before the shape's layout settles, the promise is rejected.
|
||||
* Without a timeout the promise waits indefinitely.
|
||||
* @return The promise to be resolved when the shape's layout is updated
|
||||
*/
|
||||
waitForLayoutUpdate(timeout?: number): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user