Add component synchronization to waitForLayoutUpdate

This commit is contained in:
alonso.torres 2026-07-28 15:00:55 +02:00 committed by Andrey Antukh
parent 83a3d099f6
commit 3405aede14
23 changed files with 1589 additions and 408 deletions

View File

@ -40,6 +40,7 @@
[app.main.data.workspace.groups :as dwg] [app.main.data.workspace.groups :as dwg]
[app.main.data.workspace.notifications :as-alias dwn] [app.main.data.workspace.notifications :as-alias dwn]
[app.main.data.workspace.pages :as-alias dwpg] [app.main.data.workspace.pages :as-alias dwpg]
[app.main.data.workspace.reflow :as wrf]
[app.main.data.workspace.selection :as dws] [app.main.data.workspace.selection :as dws]
[app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.specialized-panel :as dwsp] [app.main.data.workspace.specialized-panel :as dwsp]
@ -1129,6 +1130,16 @@
(def valid-asset-types (def valid-asset-types
#{:colors :components :typographies}) #{:colors :components :typographies})
(defn- sync-file-pending-ids
[file-id changes]
;; Track the file and every changed page object.
(into #{file-id}
(comp
(filter :page-id)
(keep :id)
(remove uuid/zero?))
(:redo-changes changes)))
(defn set-updating-library (defn set-updating-library
[updating?] [updating?]
(ptk/reify ::set-updating-library (ptk/reify ::set-updating-library
@ -1138,6 +1149,32 @@
(assoc state :updating-library true) (assoc state :updating-library true)
(dissoc state :updating-library))))) (dissoc state :updating-library)))))
(defn- sync-file-frontend-events
[file-id changes updated-frames undo-group]
(rx/concat
(rx/of (set-updating-library false)
(ntf/hide {:tag :sync-dialog}))
(when (seq (:redo-changes changes))
(rx/of (dch/commit-changes changes)))
(when-not (empty? updated-frames)
(let [frames-by-page (group-by :page-id updated-frames)]
(rx/merge
;; Emit one layout/update event for each page.
(->> frames-by-page
(map (fn [[page-id frames]]
(ptk/data-event :layout/update
{:page-id page-id
:ids (map :id frames)
:undo-group undo-group})))
(rx/from))
(->> (rx/from updated-frames)
(rx/mapcat
(fn [shape]
(rx/of
(dwt/clear-thumbnail file-id (:page-id shape) (:id shape) "frame")
(when-not (= (:frame-id shape) uuid/zero)
(dwt/clear-thumbnail file-id (:page-id shape) (:frame-id shape) "frame")))))))))))
(defn sync-file (defn sync-file
"Synchronize the given file from the given library. Walk through all "Synchronize the given file from the given library. Walk through all
shapes in all pages in the file that use some color, typography or shapes in all pages in the file that use some color, typography or
@ -1196,35 +1233,20 @@
updated-frames (->> changes updated-frames (->> changes
:redo-changes :redo-changes
(mapcat find-frames) (mapcat find-frames)
distinct)] distinct)
pending-ids (sync-file-pending-ids file-id changes)
frontend-sync
(sync-file-frontend-events
file-id changes updated-frames undo-group)]
(log/debug :msg "SYNC-FILE finished" :js/rchanges (log-changes (log/debug :msg "SYNC-FILE finished" :js/rchanges (log-changes
(:redo-changes changes) (:redo-changes changes)
ldata)) ldata))
(rx/concat (rx/concat
(rx/of (set-updating-library false) ;; Keep the sync pending until its layout work starts.
(ntf/hide {:tag :sync-dialog})) (wrf/with-pending :sync-file pending-ids frontend-sync)
(when (seq (:redo-changes changes))
(rx/of (dch/commit-changes changes)))
(when-not (empty? updated-frames)
(let [frames-by-page (->> updated-frames
(group-by :page-id))]
(rx/merge
;; Emit one layout/update event for each page
(rx/from
(map (fn [[page-id frames]]
(ptk/data-event :layout/update
{:page-id page-id
:ids (map :id frames)
:undo-group undo-group}))
frames-by-page))
(->> (rx/from updated-frames)
(rx/mapcat
(fn [shape]
(rx/of
(dwt/clear-thumbnail file-id (:page-id shape) (:id shape) "frame")
(when-not (= (:frame-id shape) uuid/zero)
(dwt/clear-thumbnail file-id (:page-id shape) (:frame-id shape) "frame")))))))))
(when (not= file-id library-id) (when (not= file-id library-id)
;; When we have just updated the library file, give some time for the ;; When we have just updated the library file, give some time for the
@ -1400,66 +1422,88 @@
(rx/buffer 2 1) (rx/buffer 2 1)
(rx/map first)) (rx/map first))
changes-s ;; Barriers open before async inspection and close after detection.
pending-sync-barriers* (atom #{})
start-sync-barrier
(fn [{:keys [file-id save-undo?] :as event}]
(let [task (when (and save-undo? (uuid? file-id))
(wrf/start! :sync-file [file-id]))]
(when task
(swap! pending-sync-barriers* conj task))
[event task]))
finish-sync-barrier!
(fn [task]
(when task
(wrf/finish! task)
(swap! pending-sync-barriers* disj task)))
commits-s
(->> stream (->> stream
(rx/filter dch/commit?) (rx/filter dch/commit?)
(rx/map deref) (rx/map deref)
(rx/filter #(= :local (:source %))) (rx/filter #(= :local (:source %)))
;; Translation commits never propagate component changes.
(rx/filter (complement :translation?))
;; Keep waits pending while component changes are checked.
(rx/map start-sync-barrier)
(rx/observe-on :async)) (rx/observe-on :async))
check-changes get-component-events
(fn [[event old-data]] (fn [[event old-data]]
(cond (let [{:keys [file-id changes save-undo? undo-group]} event
(nil? old-data) changed-components
(rx/empty) (when (and old-data
(or (nil? file-id) (= file-id (:id old-data))))
(into #{}
(mapcat (partial ch/components-changed old-data))
changes))]
(cond
(empty? changed-components)
(rx/empty)
(:translation? event) save-undo?
(rx/empty) (do
(log/info :hint "detected component changes"
:ids (map str changed-components)
:undo-group undo-group)
(->> (rx/from changed-components)
(rx/map #(component-changed
% (:id old-data) undo-group))))
:else :else
(let [{:keys [file-id changes save-undo? undo-group]} event ;; Undos only bump :modified-at.
(->> (rx/from changed-components)
(rx/map touch-component)))))
changed-components component-events-s
(when (or (nil? file-id) (= file-id (:id old-data))) (->> commits-s
(->> changes
(map (partial ch/components-changed old-data))
(reduce into #{})))]
(if (d/not-empty? changed-components)
(if save-undo?
(do (log/info :hint "detected component changes"
:ids (map str changed-components)
:undo-group undo-group)
(->> (rx/from changed-components)
(rx/map #(component-changed % (:id old-data) undo-group))))
;; save-undo? false (undos): just bump :modified-at
(->> (rx/from changed-components)
(rx/map touch-component)))
(rx/empty)))))
changes-s
(->> changes-s
(rx/with-latest-from workspace-buffer-s) (rx/with-latest-from workspace-buffer-s)
(rx/mapcat check-changes) (rx/mapcat
(fn [[[event task] old-data]]
(->> (get-component-events [event old-data])
(rx/finalize #(finish-sync-barrier! task)))))
;; Close barriers left behind when the page shuts down.
(rx/finalize #(wrf/finish-tasks! @pending-sync-barriers*))
(rx/share)) (rx/share))
notifier-s notifier-s
(->> changes-s (->> component-events-s
(rx/debounce 5000) (rx/debounce 5000)
(rx/tap #(log/trc :hint "buffer initialized")))] (rx/tap #(log/trc :hint "buffer initialized")))]
(when (or (contains? cf/flags :component-thumbnails) (when (or (contains? cf/flags :component-thumbnails)
(features/active-feature? state "render-wasm/v1")) (features/active-feature? state "render-wasm/v1"))
(->> (rx/merge (->> (rx/merge
changes-s component-events-s
;; WASM only: render the thumbnail on every component ;; WASM only: render the thumbnail on every component
;; change so single edits (fill, etc.) update instantly. ;; change so single edits (fill, etc.) update instantly.
;; Non-WASM persists on every render, so it stays on the ;; Non-WASM persists on every render, so it stays on the
;; debounced path below to avoid per-edit backend posts. ;; debounced path below to avoid per-edit backend posts.
(if (features/active-feature? state "render-wasm/v1") (if (features/active-feature? state "render-wasm/v1")
(->> changes-s (->> component-events-s
(rx/filter (ptk/type? ::component-changed)) (rx/filter (ptk/type? ::component-changed))
(rx/map deref) (rx/map deref)
(rx/map render-component-thumbnail-event)) (rx/map render-component-thumbnail-event))
@ -1467,7 +1511,7 @@
;; Persist to the server in batches, 5s after the user ;; Persist to the server in batches, 5s after the user
;; goes idle. ;; goes idle.
(->> changes-s (->> component-events-s
(rx/filter (ptk/type? ::component-changed)) (rx/filter (ptk/type? ::component-changed))
(rx/map deref) (rx/map deref)
(rx/buffer-until notifier-s) (rx/buffer-until notifier-s)
@ -1476,7 +1520,7 @@
(update-component-thumbnail component-id file-id)))) (update-component-thumbnail component-id file-id))))
;; Undo/redo emit touch-component instead. ;; Undo/redo emit touch-component instead.
(->> changes-s (->> component-events-s
(rx/filter (ptk/type? ::touch-component)) (rx/filter (ptk/type? ::touch-component))
(rx/map deref) (rx/map deref)
(rx/map render-component-thumbnail-event))) (rx/map render-component-thumbnail-event)))
@ -1631,5 +1675,3 @@
(rx/mapcat (fn [_] (rx/mapcat (fn [_]
(rp/cmd! :get-file-libraries {:file-id file-id}))) (rp/cmd! :get-file-libraries {:file-id file-id})))
(rx/map (partial cleanup-unlinked-libraries file-id)))))) (rx/map (partial cleanup-unlinked-libraries file-id))))))

View File

@ -14,6 +14,7 @@
[app.main.broadcast :as mbc] [app.main.broadcast :as mbc]
[app.main.data.plugins :as dp] [app.main.data.plugins :as dp]
[app.main.data.profile :as du] [app.main.data.profile :as du]
[app.main.data.workspace :as-alias dw]
[app.main.store :as st] [app.main.store :as st]
[app.plugins.register :as preg] [app.plugins.register :as preg]
[app.util.timers :as ts] [app.util.timers :as ts]
@ -132,7 +133,7 @@
(assoc :host (str (u/join cf/public-uri "plugins/mcp/")))) (assoc :host (str (u/join cf/public-uri "plugins/mcp/"))))
stopper-s (rx/merge stopper-s (rx/merge
(rx/filter (ptk/type? :app.main.data.workspace/finalize-workspace) stream) (rx/filter (ptk/type? ::dw/finalize-workspace) stream)
(rx/filter (ptk/type? ::stop-mcp-plugin) stream)) (rx/filter (ptk/type? ::stop-mcp-plugin) stream))
extension #js {:getToken (constantly token) extension #js {:getToken (constantly token)
@ -202,7 +203,7 @@
ptk/WatchEvent ptk/WatchEvent
(watch [_ state stream] (watch [_ state stream]
(let [stopper-s (rx/merge (let [stopper-s (rx/merge
(rx/filter (ptk/type? :app.main.data.workspace/finalize-workspace) stream) (rx/filter (ptk/type? ::dw/finalize-workspace) stream)
(rx/filter (ptk/type? ::init) stream)) (rx/filter (ptk/type? ::init) stream))
session-id (get state :session-id) session-id (get state :session-id)

View File

@ -5,11 +5,13 @@
;; Copyright (c) KALEIDOS INC ;; Copyright (c) KALEIDOS INC
(ns app.main.data.workspace.reflow (ns app.main.data.workspace.reflow
"Tracks the shape ids that have layout/reflow work in flight, broken down by "Tracks the ids that have layout/reflow work in flight, broken down by the
the kind of work so we can tell which type of reflow is blocking each shape. kind of work so we can tell which type of reflow is blocking each id.
Pending work is stored as `{shape-id -> {kind -> #{task-id}}}`. Every producer Pending work is stored as `{id -> {kind -> #{task-id}}}`, where ids are page
opens an exact task with `start!` and closes that same task with `finish!`. object ids plus, for `:sync-file`, the id of the file being synced. Every
producer opens an exact task with `start!` and closes that same task with
`finish!`.
Tasks belong to a workspace generation, so a delayed completion from a Tasks belong to a workspace generation, so a delayed completion from a
finalized workspace cannot drain work opened after the workspace reloads. finalized workspace cannot drain work opened after the workspace reloads.
@ -21,8 +23,10 @@
:layout flex/grid layout reflow (shape-layout) :layout flex/grid layout reflow (shape-layout)
:text-resize text geometry resize (wasm-text, texts) :text-resize text geometry resize (wasm-text, texts)
:text-measure DOM text measurement (texts) :text-measure DOM text measurement (texts)
:text-position DOM text fragment geometry (texts)
:text-bridge change awaiting its pipeline (texts) :text-bridge change awaiting its pipeline (texts)
:font font change measurement (texts)" :font font change measurement (texts)
:sync-file component/library propagation (libraries)"
(:require (:require
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
[promesa.core :as p])) [promesa.core :as p]))
@ -55,22 +59,35 @@
acc acc
ids))) ids)))
;; Single-task operations are wrapped as batches before reaching the reducer.
(defn- reducer (defn- reducer
[acc {:keys [op task ids]}] [acc {:keys [op tasks ids]}]
(case op (case op
:add (add-task acc task) :add (reduce add-task acc tasks)
:remove (remove-task acc task) :remove (reduce remove-task acc tasks)
:cancel (apply dissoc acc ids) :cancel (apply dissoc acc ids)
:reset {} :reset {}
acc)) acc))
;; Behaviour subject holding `{shape-id -> {kind -> #{task-id}}}`. ;; Holds pending tasks and replays them to new waiters.
;; It replays its current value synchronously to new subscribers, which gives ;; Reloads rebuild the scan with the latest reducer.
;; `wait-for-layout-update` a free fast-path when there is nothing pending. (def ^:private pending-shapes (rx/behavior-subject {}))
(defonce ^:private pending-shapes
(let [sub (rx/behavior-subject {})] (defonce ^:private pending-subscription (atom nil))
(rx/sub! (->> reflow-input (rx/scan reducer {})) sub)
sub)) (defn- install-pending-subscription!
[]
;; Settle the old scan before installing the new one.
(swap! workspace-generation inc)
(rx/push! reflow-input {:op :reset})
(when-let [subscription @pending-subscription]
(rx/dispose! subscription))
(reset! pending-subscription
(rx/sub! (->> reflow-input (rx/scan reducer {}))
pending-shapes))
(rx/push! reflow-input {:op :reset}))
(install-pending-subscription!)
(defn task (defn task
"Creates an opaque task token without opening it." "Creates an opaque task token without opening it."
@ -80,24 +97,42 @@
:kind kind :kind kind
:ids (into #{} ids)}) :ids (into #{} ids)})
(defn- push-tasks!
[op tasks]
;; Empty and stale tasks must not affect the active workspace.
(let [generation @workspace-generation
tasks (into [] (filter #(and (seq (:ids %))
(= (:generation %) generation)))
tasks)]
(when (seq tasks)
(rx/push! reflow-input {:op op :tasks tasks}))
tasks))
(defn- start-tasks!
"Opens task tokens in one pending-map update."
[tasks]
(push-tasks! :add tasks))
(defn start! (defn start!
"Opens and returns a task. The one-argument form opens a token created with "Opens and returns a task. The one-argument form opens a token created with
`task`; the two-argument form creates and opens it in one step." `task`; the two-argument form creates and opens it in one step."
([task] ([task]
(when (and (seq (:ids task)) (push-tasks! :add [task])
(= (:generation task) @workspace-generation))
(rx/push! reflow-input {:op :add :task task}))
task) task)
([kind ids] ([kind ids]
(start! (task kind ids)))) (start! (task kind ids))))
(defn finish-tasks!
"Closes task tokens from the active workspace generation in one update."
[tasks]
(push-tasks! :remove tasks)
nil)
(defn finish! (defn finish!
"Closes `task` if it belongs to the active workspace generation. Repeated or "Closes `task` if it belongs to the active workspace generation. Repeated or
stale completion is a no-op." stale completion is a no-op."
[{:keys [generation ids] :as task}] [task]
(when (and (seq ids) (finish-tasks! [task]))
(= generation @workspace-generation))
(rx/push! reflow-input {:op :remove :task task})))
(defn reset-pending! (defn reset-pending!
"Starts a new workspace generation and forgets every task from the old one." "Starts a new workspace generation and forgets every task from the old one."
@ -136,59 +171,77 @@
(finish! task) (finish! task)
(throw cause))))) (throw cause)))))
(defn pending-signal (defn bridge-pending
"Emits once any of `kinds` is pending for any of `ids`, then completes. "Keeps each id pending until matching work starts."
Emits right away when that work is already in flight." [ids target-kinds bridge-kind]
[ids kinds] (let [ids (into #{} ids)]
(letfn [(id-pending? [pending id] (if (empty? ids)
(some (partial contains? (get pending id)) kinds)) (rx/empty)
(rx/create
(fn [subs]
;; Separate tasks let renderer work release each shape independently.
(let [tasks-by-id
(into {} (map (fn [id] [id (task bridge-kind [id])])) ids)
(any-pending? [pending] remaining
(some (partial id-pending? pending) ids))] (atom ids)
(->> pending-shapes
(rx/filter any-pending?)
(rx/take 1))))
;; Ceiling for callers that pass no timeout, so a pipeline that never drains release!
;; its marks rejects the promise rather than leaving it unsettled. (fn [released]
(def ^:private default-timeout 30000) (let [released (into #{} (filter @remaining) released)]
(when (seq released)
(finish-tasks! (map tasks-by-id released))
(swap! remaining #(apply disj % released))
(when (empty? @remaining)
(rx/end! subs)))))
(defn wait-for-layout-update matching-task-ids
"Returns a JS Promise that resolves when every id in `shape-ids` has drained (fn [tasks]
from the pending map. A nil `shape-ids` waits for every pending shape; an (into #{}
empty one has nothing to wait for and resolves right away. The promise is (comp
rejected when `timeout` (ms) elapses first; a nil `timeout` uses (filter #(contains? target-kinds (:kind %)))
`default-timeout`. (mapcat :ids)
(filter ids))
tasks))
;; Listen before opening bridges so synchronous work is not missed.
lifecycle-sub
(rx/sub!
reflow-input
(fn [{:keys [op tasks ids]}]
(case op
:add
(release! (matching-task-ids tasks))
:cancel
(release! ids)
:reset
(release! @remaining)
nil)))
_
(start-tasks! (vals tasks-by-id))]
(fn []
(rx/dispose! lifecycle-sub)
(when (seq @remaining)
(finish-tasks! (map tasks-by-id @remaining))
(reset! remaining #{})))))))))
(defn settled
"Observable that emits once every id in `ids` has drained from the pending
map, then completes. A nil `ids` waits for every pending id; an empty one has
nothing to wait for. Replays on subscribe, so an already drained map emits
immediately.
Callers waiting on one shape pass its whole subtree: reflow work lands either Callers waiting on one shape pass its whole subtree: reflow work lands either
on the shape (a board laying out its children) or on its descendants (a group on the shape (a board laying out its children) or on its descendants (a group
whose texts are re-measured)." whose texts are re-measured)."
([timeout] [ids]
(wait-for-layout-update nil timeout)) (let [done? (if (some? ids)
([shape-ids timeout] (fn [pending] (not-any? #(contains? pending %) ids))
(js/Promise. empty?)]
(fn [resolve reject] (->> pending-shapes
(let [timeout (or timeout default-timeout) (rx/filter done?)
(rx/take 1))))
done? (if (some? shape-ids)
(fn [pending] (not-any? #(contains? pending %) shape-ids))
empty?)
settled (->> pending-shapes
(rx/filter done?)
(rx/map (constantly :ok)))
;; Race the settle signal against the deadline; the loser is
;; unsubscribed. `settled` replays on subscribe, so an already
;; drained map wins even against a 1ms deadline.
source (rx/race (->> (rx/of :timeout)
(rx/delay timeout))
settled)]
(->> source
(rx/take 1)
(rx/subs!
(fn [value]
(if (= value :timeout)
(reject (js/Error. "waitForLayoutUpdate timeout"))
(resolve)))
reject)))))))

View File

@ -0,0 +1,141 @@
;; 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 Sucursal en España SL
(ns app.main.data.workspace.reflow.signals
"Decides which reflow signals a shape update raises: `:layout/update` for the
shapes whose layout attrs changed, `:text/reflow` for the texts the renderer
has to re-measure.
Which text attrs matter depends on the renderer: the DOM one measures every
changed text, so its own geometry counts as a change; wasm only resizes
auto-sized texts from their content."
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.changes-builder :as pcb]
[app.common.files.helpers :as cfh]
[app.common.math :as mth]
[app.main.features :as features]))
;; If anything a translation can mutate is added here, drop the
;; `(when-not translation? …)` guard in `update-shapes`.
(def ^:private update-layout-attr? #{:hidden})
;; Text attrs that can start async renderer work.
(def ^:private text-reflow-attr?
#{:content :grow-type :x :y :width :height})
(def ^:private wasm-text-reflow-attr?
#{:content :grow-type})
(def ^:private dom-text-geometry-reflow-attr?
#{:x :y :width :height})
(defn- renderer-text-reflow-attr?
[state]
(if (features/active-feature? state "render-wasm/v1")
wasm-text-reflow-attr?
text-reflow-attr?))
(defn- reflow-attr?
[state attr]
(or (update-layout-attr? attr)
((renderer-text-reflow-attr? state) attr)))
;; Caller metadata can rule out reflow before objects are compared.
(defn- reflow-candidate?
[attr? {:keys [attrs translation? update-layout?]
:or {update-layout? true}}]
(and update-layout?
(not translation?)
(or (nil? attrs)
(some attr? attrs))))
(defn- text-reflow-changed?
[state shape changed-shape changed]
;; Match the DOM renderer's geometry checks.
(let [wasm? (features/active-feature? state "render-wasm/v1")
reflow-attr? (renderer-text-reflow-attr? state)]
(some
(fn [attr]
(and (reflow-attr? attr)
(or wasm?
(not (dom-text-geometry-reflow-attr? attr))
(not (mth/close? (get shape attr)
(get changed-shape attr))))))
changed)))
(defn- async-text-reflow?
"Whether `shape` enters an asynchronous text geometry pipeline. The HTML
renderer measures every changed text; WASM only resizes auto-sized texts.
A grow-type transition is included because `shape` is the value before the
update and may still be fixed."
[state shape changed]
(and (cfh/text-shape? shape)
(or (not (features/active-feature? state "render-wasm/v1"))
(not= :fixed (:grow-type shape))
(contains? changed :grow-type))))
(defn- get-reflow-changes
[state objects changed-objects ids {:keys [attrs] :as props}]
;; Reuse built objects so update functions only run once.
(let [reflow-attr? (partial reflow-attr? state)]
(when (reflow-candidate? reflow-attr? props)
(into []
(comp
(map (d/getf objects))
(keep (fn [shape]
(let [changed-shape (get changed-objects (:id shape))
changed (pcb/changed-attrs
shape objects (constantly changed-shape)
{:attrs attrs})]
(when (some reflow-attr? changed)
[shape changed-shape changed])))))
ids))))
(defn- get-layout-reflow-ids
[reflow-changes]
(->> reflow-changes
(into [] (comp (filter (fn [[_ _ changed]] (some update-layout-attr? changed)))
(map (comp :id first))))
(not-empty)))
(defn- get-text-reflow-ids
[state page-id reflow-changes]
;; Track measurable texts on the active page.
(when (= page-id (get state :current-page-id))
(let [edition (dm/get-in state [:workspace-local :edition])]
(->> reflow-changes
(into [] (comp (filter (fn [[shape changed-shape changed]]
(and (async-text-reflow? state shape changed)
(text-reflow-changed?
state shape changed-shape changed))))
(map (comp :id first))
(remove #(= % edition))))
(not-empty)))))
(defn reflow-ids
"Ids a shape update has to signal: `:layout-ids` for `:layout/update`,
`:text-ids` for `:text/reflow`. Both are nil when nothing changed.
Both sets come from one comparison pass, so `update-fn` and the attribute
diff only run once per shape."
[state page-id objects changed-objects ids props]
(let [reflow-changes (get-reflow-changes state objects changed-objects ids props)]
{:layout-ids (get-layout-reflow-ids reflow-changes)
:text-ids (get-text-reflow-ids state page-id reflow-changes)}))
(defn text-reflow-candidate?
"Whether `props` can start renderer text work, judged from the caller metadata
alone. Cheap pre-filter for callers that buffer updates before they have
objects to compare."
[state props]
(reflow-candidate? (renderer-text-reflow-attr? state) props))
(defn new-text-reflow?
"Whether a newly added `shape` enters an asynchronous text geometry pipeline."
[state shape]
(async-text-reflow? state shape nil))

View File

@ -29,6 +29,7 @@
[app.main.data.workspace.undo :as dwu] [app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.viewport-wasm :as dwvw] [app.main.data.workspace.viewport-wasm :as dwvw]
[app.main.data.workspace.zoom :as dwz] [app.main.data.workspace.zoom :as dwz]
[app.main.features :as features]
[app.main.refs :as refs] [app.main.refs :as refs]
[app.main.router :as rt] [app.main.router :as rt]
[app.main.streams :as ms] [app.main.streams :as ms]
@ -452,6 +453,16 @@
(gpt/subtract new-pos pt-obj))))) (gpt/subtract new-pos pt-obj)))))
(defn- get-new-dom-text-ids
[state changes]
(when-not (features/active-feature? state "render-wasm/v1")
(->> (:redo-changes changes)
(keep (fn [{:keys [type obj]}]
(when (and (= type :add-obj)
(cfh/text-shape? obj))
(:id obj))))
(not-empty))))
(defn duplicate-shapes (defn duplicate-shapes
[ids & {:keys [move-delta? alt-duplication? change-selection? return-ref] [ids & {:keys [move-delta? alt-duplication? change-selection? return-ref]
:or {move-delta? false alt-duplication? false change-selection? true return-ref nil}}] :or {move-delta? false alt-duplication? false change-selection? true return-ref nil}}]
@ -493,6 +504,9 @@
(map #(get-in % [:obj :id])) (map #(get-in % [:obj :id]))
(into (d/ordered-set))) (into (d/ordered-set)))
new-dom-text-ids
(get-new-dom-text-ids state changes)
id-duplicated (first new-ids) id-duplicated (first new-ids)
frames (into #{} frames (into #{}
@ -531,6 +545,11 @@
;; Warning: This order is important for the focus mode. ;; Warning: This order is important for the focus mode.
(->> (rx/of (->> (rx/of
(dwu/start-undo-transaction undo-id) (dwu/start-undo-transaction undo-id)
;; Track cloned texts before they mount.
(when new-dom-text-ids
(ptk/data-event :text/reflow
{:ids new-dom-text-ids
:page-id (:id page)}))
(dch/commit-changes changes) (dch/commit-changes changes)
(when change-selection? (when change-selection?
(select-shapes new-ids)) (select-shapes new-ids))

View File

@ -23,6 +23,7 @@
[app.main.data.changes :as dch] [app.main.data.changes :as dch]
[app.main.data.event :as ev] [app.main.data.event :as ev]
[app.main.data.helpers :as dsh] [app.main.data.helpers :as dsh]
[app.main.data.workspace :as-alias dw]
[app.main.data.workspace.colors :as cl] [app.main.data.workspace.colors :as cl]
[app.main.data.workspace.grid-layout.editor :as dwge] [app.main.data.workspace.grid-layout.editor :as dwge]
[app.main.data.workspace.modifiers :as dwm] [app.main.data.workspace.modifiers :as dwm]
@ -131,14 +132,14 @@
(->> stream (->> stream
(rx/filter (ptk/type? ::dwsh/update-shapes-buffer-commit)) (rx/filter (ptk/type? ::dwsh/update-shapes-buffer-commit))
(rx/take 1) (rx/take 1)
(rx/take-until (rx/filter (ptk/type? :app.main.data.workspace/finalize) stream)) (rx/take-until (rx/filter (ptk/type? ::dw/finalize-workspace) stream))
;; No events are derived from this ;; No events are derived from this
(rx/ignore)) (rx/ignore))
(rx/empty))] (rx/empty))]
(cond->> (rx/concat update-positions-stream drain-stream) (cond->> (rx/concat update-positions-stream drain-stream)
(d/not-empty? reflow-tasks) (d/not-empty? reflow-tasks)
(rx/finalize #(run! wrf/finish! reflow-tasks))))))) (rx/finalize #(wrf/finish-tasks! reflow-tasks)))))))
(defn- without-root-board (defn- without-root-board
[ids] [ids]

View File

@ -24,34 +24,12 @@
[app.main.data.workspace.collapse :as dwco] [app.main.data.workspace.collapse :as dwco]
[app.main.data.workspace.edition :as dwe] [app.main.data.workspace.edition :as dwe]
[app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.reflow :as wrf]
[app.main.data.workspace.reflow.signals :as wrfs]
[app.main.data.workspace.selection :as dws] [app.main.data.workspace.selection :as dws]
[app.main.data.workspace.undo :as dwu] [app.main.data.workspace.undo :as dwu]
[app.main.features :as features]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
[potok.v2.core :as ptk])) [potok.v2.core :as ptk]))
;; If anything a translation can mutate is added here, drop the
;; `(when-not translation? …)` guard in `update-shapes` below.
(def ^:private update-layout-attr? #{:hidden})
;; Text attrs whose change makes the DOM text pipeline re-measure the shape.
(def ^:private text-reflow-attr? #{:content :grow-type})
(defn- reflow-attr?
[attr]
(or (update-layout-attr? attr) (text-reflow-attr? attr)))
(defn- async-text-reflow?
"Whether `shape` enters an asynchronous text geometry pipeline. The HTML
renderer measures every changed text; WASM only resizes auto-sized texts.
A grow-type transition is included because `shape` is the value before the
update and may still be fixed."
[state shape changed]
(and (cfh/text-shape? shape)
(or (not (features/active-feature? state "render-wasm/v1"))
(not= :fixed (:grow-type shape))
(contains? changed :grow-type))))
(defn- add-undo-group (defn- add-undo-group
[changes state] [changes state]
(let [undo (:workspace-undo state) (let [undo (:workspace-undo state)
@ -82,15 +60,35 @@
(update [_ state] (update [_ state]
(assoc state ::update-shapes-buffer false)))) (assoc state ::update-shapes-buffer false))))
(defn- get-buffered-text-reflow-event
[state page-id ids]
(when (= page-id (get state :current-page-id))
;; Analyze accumulated objects through the same path as immediate updates.
(let [objects (dsh/lookup-page-objects state page-id)
changed-objects (-> (get-in state [::update-shapes-buffer-changes page-id])
(pcb/lookup-objects))
{:keys [text-ids]}
(wrfs/reflow-ids state page-id objects changed-objects ids nil)]
(when text-ids
(ptk/data-event :text/reflow {:ids text-ids :page-id page-id})))))
(defn update-shapes-buffer-commit (defn update-shapes-buffer-commit
[] []
(ptk/reify ::update-shapes-buffer-commit (ptk/reify ::update-shapes-buffer-commit
ptk/WatchEvent ptk/WatchEvent
(watch [_ state _] (watch [_ state _]
(->> (get state ::update-shapes-buffer-changes) (let [text-reflow-events
(vals) (->> (get state ::update-shapes-buffer-text-candidates)
(map dch/commit-changes) (keep (fn [[page-id ids]]
(rx/from))))) (get-buffered-text-reflow-event state page-id ids))))
commits
(->> (get state ::update-shapes-buffer-changes)
(vals)
(map dch/commit-changes))]
;; Open bridges before commits start rendering.
(rx/concat (rx/from text-reflow-events)
(rx/from commits))))))
;; Looks for the objects data in the state, if there is an "in progress" ;; Looks for the objects data in the state, if there is an "in progress"
;; update-shapes-buffer will return the objeccts inside the current changes ;; update-shapes-buffer will return the objeccts inside the current changes
@ -111,7 +109,8 @@
(update-shapes-buffer ids update-fn nil)) (update-shapes-buffer ids update-fn nil))
([ids update-fn ([ids update-fn
{:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id {:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id
ignore-touched undo-group with-objects? changed-sub-attr translation?] ignore-touched undo-group with-objects? changed-sub-attr
translation?]
:or {reg-objects? false :or {reg-objects? false
save-undo? true save-undo? true
stack-undo? false stack-undo? false
@ -126,9 +125,14 @@
(assoc state ::update-shapes-buffer-event cur-event) (assoc state ::update-shapes-buffer-event cur-event)
(let [page-id (or page-id (get state :current-page-id)) (let [page-id (or page-id (get state :current-page-id))
objects (dsh/lookup-page-objects state page-id)] objects (lookup-changed-objects state page-id)
(-> state text-ids
(into #{}
(filter #(cfh/text-shape? objects %))
ids)
state
(update-in (update-in
state
[::update-shapes-buffer-changes page-id] [::update-shapes-buffer-changes page-id]
(fn [changes] (fn [changes]
(-> (or changes (-> (or changes
@ -148,7 +152,15 @@
:ignore-touched ignore-touched :ignore-touched ignore-touched
:with-objects? with-objects?}) :with-objects? with-objects?})
(cond-> reg-objects? (pcb/resize-parents ids)) (cond-> reg-objects? (pcb/resize-parents ids))
(pcb/set-translation? translation?)))))))) (pcb/set-translation? translation?))))]
;; Check buffered text candidates when the buffer is committed.
(if (or (empty? text-ids)
(not (wrfs/text-reflow-candidate? state props)))
state
(update-in state
[::update-shapes-buffer-text-candidates page-id]
(fnil into #{})
text-ids)))))
ptk/WatchEvent ptk/WatchEvent
(watch [_ state stream] (watch [_ state stream]
@ -165,6 +177,7 @@
(rx/of #(dissoc % (rx/of #(dissoc %
::update-shapes-buffer-changes ::update-shapes-buffer-changes
::update-shapes-buffer-text-candidates
::update-shapes-buffer-event)))) ::update-shapes-buffer-event))))
(rx/empty))))))) (rx/empty)))))))
@ -174,14 +187,12 @@
([ids update-fn ([ids update-fn
{:as props {:as props
:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id :keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id
ignore-touched undo-group with-objects? changed-sub-attr translation? ignore-touched undo-group with-objects? changed-sub-attr translation?]
update-layout?]
:or {reg-objects? false :or {reg-objects? false
save-undo? true save-undo? true
stack-undo? false stack-undo? false
ignore-touched false ignore-touched false
with-objects? false with-objects? false}}]
update-layout? true}}]
(assert (every? uuid? ids) "expect a coll of uuid for `ids`") (assert (every? uuid? ids) "expect a coll of uuid for `ids`")
(assert (fn? update-fn) "the `update-fn` should be a valid function") (assert (fn? update-fn) "the `update-fn` should be a valid function")
@ -197,49 +208,6 @@
objects (dsh/lookup-page-objects state page-id) objects (dsh/lookup-page-objects state page-id)
ids (into [] (filter some?) ids) ids (into [] (filter some?) ids)
;; Pairs of [shape changed-attrs] for the shapes whose change
;; matters to a reflow, feeding both id sets below.
xf-reflow
(comp
(map (d/getf objects))
(keep (fn [shape]
(let [changed (pcb/changed-attrs shape objects update-fn
{:attrs attrs :with-objects? with-objects?})]
(when (some reflow-attr? changed)
[shape changed])))))
;; `changed-attrs` runs `update-fn` in full for every shape, which
;; can be expensive (e.g. `update-bool-shape` recalculates the whole
;; boolean path in WASM). Skip the pass entirely when we can prove it
;; cannot match: when the caller declares `attrs`, `changed-attrs`
;; filters its result to that set, so if no reflow attr is present
;; the check is always empty.
reflow-changes
(when-not (or translation?
(not update-layout?)
(and (some? attrs)
(not (some reflow-attr? attrs))))
(into [] xf-reflow ids))
update-layout-ids
(->> reflow-changes
(into [] (comp (filter (fn [[_ changed]] (some update-layout-attr? changed)))
(map (comp :id first))))
(not-empty))
;; Text shapes the DOM pipeline has to re-measure, narrowed to what
;; it actually measures: the active page, never the edited shape.
text-reflow-ids
(when (= page-id (get state :current-page-id))
(let [edition (dm/get-in state [:workspace-local :edition])]
(->> reflow-changes
(into [] (comp (filter (fn [[shape changed]]
(and (async-text-reflow? state shape changed)
(some text-reflow-attr? changed))))
(map (comp :id first))
(remove #(= % edition))))
(not-empty))))
changes changes
(-> (pcb/empty-changes it page-id) (-> (pcb/empty-changes it page-id)
(pcb/set-save-undo? save-undo?) (pcb/set-save-undo? save-undo?)
@ -257,6 +225,12 @@
(pcb/set-undo-group undo-group)) (pcb/set-undo-group undo-group))
(pcb/set-translation? translation?)) (pcb/set-translation? translation?))
changed-objects
(pcb/lookup-objects changes)
{:keys [layout-ids text-ids]}
(wrfs/reflow-ids state page-id objects changed-objects ids props)
changes changes
(add-undo-group changes state)] (add-undo-group changes state)]
@ -264,8 +238,8 @@
;; Announces the texts still to be re-measured, so a reflow wait ;; Announces the texts still to be re-measured, so a reflow wait
;; covers the render that measures them. Goes before the commit, ;; covers the render that measures them. Goes before the commit,
;; which is what triggers that render. ;; which is what triggers that render.
(if text-reflow-ids (if text-ids
(rx/of (ptk/data-event :text/reflow {:ids text-reflow-ids :page-id page-id})) (rx/of (ptk/data-event :text/reflow {:ids text-ids :page-id page-id}))
(rx/empty)) (rx/empty))
(if (seq (:redo-changes changes)) (if (seq (:redo-changes changes))
@ -274,8 +248,8 @@
(rx/empty)) (rx/empty))
;; Update layouts for properties marked ;; Update layouts for properties marked
(if update-layout-ids (if layout-ids
(rx/of (ptk/data-event :layout/update {:ids update-layout-ids})) (rx/of (ptk/data-event :layout/update {:ids layout-ids}))
(rx/empty))))))))) (rx/empty)))))))))
(defn add-shape (defn add-shape
@ -321,7 +295,7 @@
(rx/of (dwu/start-undo-transaction undo-id) (rx/of (dwu/start-undo-transaction undo-id)
;; A new text has no geometry until the pipeline measures it, ;; A new text has no geometry until the pipeline measures it,
;; so it raises the same signal an edit does. ;; so it raises the same signal an edit does.
(when (async-text-reflow? state shape nil) (when (wrfs/new-text-reflow? state shape)
(ptk/data-event :text/reflow {:ids [(:id shape)] :page-id page-id})) (ptk/data-event :text/reflow {:ids [(:id shape)] :page-id page-id}))
(dch/commit-changes changes) (dch/commit-changes changes)
(when-not no-update-layout? (when-not no-update-layout?

View File

@ -24,9 +24,11 @@
[app.main.data.changes :as dch] [app.main.data.changes :as dch]
[app.main.data.event :as ev] [app.main.data.event :as ev]
[app.main.data.helpers :as dsh] [app.main.data.helpers :as dsh]
[app.main.data.workspace :as-alias dw]
[app.main.data.workspace.common :as dwc] [app.main.data.workspace.common :as dwc]
[app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.modifiers :as dwm] [app.main.data.workspace.modifiers :as dwm]
[app.main.data.workspace.pages :as-alias dwpg]
[app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.reflow :as wrf]
[app.main.data.workspace.selection :as dws] [app.main.data.workspace.selection :as dws]
[app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.shapes :as dwsh]
@ -66,15 +68,19 @@
"Marks `ids` pending until the text pipeline marks its own work: "Marks `ids` pending until the text pipeline marks its own work:
`:text-measure` in the DOM renderer, `:text-resize` in wasm. Emits nothing." `:text-measure` in the DOM renderer, `:text-resize` in wasm. Emits nothing."
[ids] [ids]
(->> (rx/from ids) (wrf/bridge-pending ids #{:text-measure :text-resize} :text-bridge))
;; Each id owns its bridge. Starting work for one text must not release
;; siblings that the renderer has not picked up yet. (defn- page-finalize?
(rx/mapcat [event]
(fn [id] (= ::dwpg/finalize-page (ptk/type event)))
(->> (wrf/pending-signal [id] #{:text-measure :text-resize})
(rx/ignore) (defn- text-work-stopper
(wrf/with-pending :text-bridge [id])))) [stream]
(rx/ignore))) (rx/filter
(fn [event]
(or (= ::dw/finalize-workspace (ptk/type event))
(page-finalize? event)))
stream))
(defn initialize-text-reflow (defn initialize-text-reflow
"Tracks the texts the DOM pipeline still has to re-measure, so a reflow wait "Tracks the texts the DOM pipeline still has to re-measure, so a reflow wait
@ -83,11 +89,15 @@
(ptk/reify ::initialize-text-reflow (ptk/reify ::initialize-text-reflow
ptk/WatchEvent ptk/WatchEvent
(watch [_ _ stream] (watch [_ _ stream]
(let [stopper (rx/filter (ptk/type? ::finalize-text-reflow) stream)] (let [stopper (rx/filter (ptk/type? ::finalize-text-reflow) stream)
page-stopper (rx/filter page-finalize? stream)]
(->> stream (->> stream
(rx/filter (ptk/type? :text/reflow)) (rx/filter (ptk/type? :text/reflow))
(rx/map deref) (rx/map deref)
(rx/mapcat (fn [{:keys [ids]}] (bridge-to-measurement ids))) (rx/merge-map
(fn [{:keys [ids]}]
(->> (bridge-to-measurement ids)
(rx/take-until page-stopper))))
(rx/take-until stopper)))))) (rx/take-until stopper))))))
(defn finalize-text-reflow (defn finalize-text-reflow
@ -110,28 +120,51 @@
:else :else
[]))) [])))
(defn- await-font-faces
"Waits for missing WASM faces, then resizes the affected texts."
[stream face-keys ids]
(let [resize-stream (->> (rx/from ids) (rx/map dwwt/resize-wasm-text))]
(if (empty? face-keys)
resize-stream
(->> (rx/merge wasm.fonts/font-stored-stream
wasm.fonts/font-storage-failed-stream)
(rx/filter face-keys)
(rx/scan disj face-keys)
(rx/filter empty?)
(rx/take 1)
(rx/take-until (text-work-stopper stream))
(rx/observe-on :async)
(rx/mapcat (constantly resize-stream))
(wrf/with-pending :font ids)))))
(defn- pending-font-faces
[ids]
(let [objects (dsh/lookup-page-objects @st/state)]
(into #{}
(comp
(map #(get objects %))
(keep :content)
(mapcat wasm.fonts/get-content-fonts)
(map wasm.fonts/make-font-data)
(remove wasm.fonts/font-ready?)
(map wasm.fonts/font-data-key))
ids)))
(defn- await-font-resize (defn- await-font-resize
"Marks `ids` as pending font work and dispatches their wasm resize once wasm "Waits for missing font faces, then resizes `ids`."
can measure with `font-id`, draining the marks afterwards. The fetch of that [stream ids]
font is started by the wasm shape sync of the content change these shapes
receive, so measuring before it lands would use the fallback font."
[stream font-id ids]
(if (empty? ids) (if (empty? ids)
(rx/empty) (rx/empty)
(let [stopper (rx/filter (ptk/type? :app.main.data.workspace/finalize) stream)] (->> (rx/of ::await-fonts)
(->> wasm.fonts/font-stored-stream (rx/mapcat
(rx/filter #(= % font-id)) (fn [_]
(rx/take 1) (await-font-faces stream (pending-font-faces ids) ids))))))
(rx/take-until stopper)
(rx/observe-on :async)
(rx/mapcat (fn [_] (rx/from (mapv dwwt/resize-wasm-text ids))))
(wrf/with-pending :font ids)))))
(defn- await-html-font (defn- await-html-font
"Keeps legacy DOM text pending while its new font is loading. The DOM "Keeps legacy DOM text pending while its new font is loading. The DOM
measurement also awaits this promise, so the font task bridges the state measurement also awaits this promise, so the font task bridges the state
update to the renderer commit without relying on a fixed settle delay." update to the renderer commit without relying on a fixed settle delay."
[font-id font-variant-id ids] [stream font-id font-variant-id ids]
(if (or (nil? font-id) (empty? ids)) (if (or (nil? font-id) (empty? ids))
(rx/empty) (rx/empty)
(->> (rx/of ::load-font) (->> (rx/of ::load-font)
@ -140,6 +173,7 @@
;; gap before the task is visible to waiters. ;; gap before the task is visible to waiters.
(rx/mapcat (fn [_] (rx/mapcat (fn [_]
(rx/from (fonts/ensure-loaded! font-id font-variant-id)))) (rx/from (fonts/ensure-loaded! font-id font-variant-id))))
(rx/take-until (text-work-stopper stream))
(rx/ignore) (rx/ignore)
(wrf/with-pending :font ids)))) (wrf/with-pending :font ids))))
@ -525,7 +559,7 @@
[id start end attrs] [id start end attrs]
(ptk/reify ::update-text-range (ptk/reify ::update-text-range
ptk/WatchEvent ptk/WatchEvent
(watch [_ state _] (watch [_ state stream]
(let [objects (dsh/lookup-page-objects state) (let [objects (dsh/lookup-page-objects state)
shape (get objects id) shape (get objects id)
@ -547,7 +581,7 @@
(rx/map dwwt/resize-wasm-text-debounce)) (rx/map dwwt/resize-wasm-text-debounce))
(contains? attrs :font-id) (contains? attrs :font-id)
(await-html-font (:font-id attrs) (:font-variant-id attrs) text-ids) (await-html-font stream (:font-id attrs) (:font-variant-id attrs) text-ids)
:else :else
(rx/empty))))))) (rx/empty)))))))
@ -798,7 +832,7 @@
(watch [_ state stream] (watch [_ state stream]
(wrf/start! reflow-task) (wrf/start! reflow-task)
(if (= (::resize-text-debounce-event state) cur-event) (if (= (::resize-text-debounce-event state) cur-event)
(let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))] (let [stopper (->> stream (rx/filter (ptk/type? ::dw/finalize-workspace)))]
(rx/concat (rx/concat
(rx/merge (rx/merge
(->> stream (->> stream
@ -809,7 +843,7 @@
(rx/take-until stopper)) (rx/take-until stopper))
(rx/of (resize-text id new-width new-height))) (rx/of (resize-text id new-width new-height)))
(rx/of (fn [state] (rx/of (fn [state]
(run! wrf/finish! (::resize-text-reflow-tasks state)) (wrf/finish-tasks! (::resize-text-reflow-tasks state))
(dissoc state (dissoc state
::resize-text-debounce-props ::resize-text-debounce-props
::resize-text-reflow-tasks ::resize-text-reflow-tasks
@ -877,7 +911,7 @@
ptk/WatchEvent ptk/WatchEvent
(watch [_ state stream] (watch [_ state stream]
(if (= (::update-text-modifier-debounce-event state) cur-event) (if (= (::update-text-modifier-debounce-event state) cur-event)
(let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))] (let [stopper (->> stream (rx/filter (ptk/type? ::dw/finalize-workspace)))]
(rx/concat (rx/concat
(rx/merge (rx/merge
(->> stream (->> stream
@ -925,40 +959,49 @@
ptk/WatchEvent ptk/WatchEvent
(watch [_ state _] (watch [_ state _]
(let [position-data (::update-position-data state)] (let [position-data (::update-position-data state)]
(rx/concat (rx/of (dwsh/update-shapes
(rx/of (dwsh/update-shapes (keys position-data)
(keys position-data) (fn [shape]
(fn [shape] (-> shape
(-> shape (assoc :position-data (get position-data (:id shape)))))
(assoc :position-data (get position-data (:id shape))))) {:stack-undo? true :reg-objects? false}))))))
{:stack-undo? true :reg-objects? false}))
(rx/of (fn [state]
(dissoc state ::update-position-data-debounce ::update-position-data))))))))
(defn update-position-data (defn update-position-data
[id position-data] [id position-data]
(let [cur-event (js/Symbol)] (let [cur-event (js/Symbol)
reflow-task (wrf/task :text-position [id])]
(ptk/reify ::update-position-data (ptk/reify ::update-position-data
ptk/UpdateEvent ptk/UpdateEvent
(update [_ state] (update [_ state]
(let [state (assoc-in state [:workspace-text-modifier id :position-data] position-data)] (let [state (assoc-in state [:workspace-text-modifier id :position-data] position-data)]
(if (nil? (::update-position-data-debounce state)) (-> state
(assoc state ::update-position-data-debounce cur-event) (update ::update-position-data-reflow-tasks (fnil conj []) reflow-task)
(assoc-in state [::update-position-data id] position-data)))) (cond-> (nil? (::update-position-data-debounce state))
(assoc ::update-position-data-debounce cur-event))
(cond-> (some? (::update-position-data-debounce state))
(assoc-in [::update-position-data id] position-data)))))
ptk/WatchEvent ptk/WatchEvent
(watch [_ state stream] (watch [_ state stream]
(wrf/start! reflow-task)
(if (= (::update-position-data-debounce state) cur-event) (if (= (::update-position-data-debounce state) cur-event)
(let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))] (let [stopper (text-work-stopper stream)]
(rx/merge (rx/concat
(->> stream (rx/merge
(rx/filter (ptk/type? ::update-position-data)) (->> stream
(rx/debounce 50) (rx/filter (ptk/type? ::update-position-data))
(rx/take 1) (rx/debounce 50)
(rx/map #(commit-position-data)) (rx/take 1)
(rx/take-until stopper)) (rx/map #(commit-position-data))
(rx/of (update-position-data id position-data)))) (rx/take-until stopper))
(rx/of (update-position-data id position-data)))
(rx/of (fn [state]
(wrf/finish-tasks! (::update-position-data-reflow-tasks state))
(dissoc state
::update-position-data-debounce
::update-position-data
::update-position-data-reflow-tasks)))))
(rx/empty)))))) (rx/empty))))))
(defn update-attrs (defn update-attrs
@ -1009,7 +1052,7 @@
(let [auto-ids (into [] (remove #(= :fixed (:grow-type (get objects %)))) text-ids)] (let [auto-ids (into [] (remove #(= :fixed (:grow-type (get objects %)))) text-ids)]
(if (contains? attrs :font-id) (if (contains? attrs :font-id)
;; The geometry depends on the font, so wait until wasm has it. ;; The geometry depends on the font, so wait until wasm has it.
(await-font-resize stream (:font-id attrs) auto-ids) (await-font-resize stream auto-ids)
;; No font change: measurable right away. ;; No font change: measurable right away.
(->> (rx/from auto-ids) (->> (rx/from auto-ids)
(rx/map dwwt/resize-wasm-text))))) (rx/map dwwt/resize-wasm-text)))))
@ -1018,6 +1061,7 @@
;; but font loading starts before that render commits. ;; but font loading starts before that render commits.
(if (contains? attrs :font-id) (if (contains? attrs :font-id)
(await-html-font (await-html-font
stream
(:font-id attrs) (:font-id attrs)
(:font-variant-id attrs) (:font-variant-id attrs)
text-ids) text-ids)

View File

@ -16,6 +16,7 @@
[app.common.geom.point :as gpt] [app.common.geom.point :as gpt]
[app.common.types.modifiers :as ctm] [app.common.types.modifiers :as ctm]
[app.main.data.helpers :as dsh] [app.main.data.helpers :as dsh]
[app.main.data.workspace :as-alias dw]
[app.main.data.workspace.modifiers :as dwm] [app.main.data.workspace.modifiers :as dwm]
[app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.reflow :as wrf]
[app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.shapes :as dwsh]
@ -159,7 +160,7 @@
(watch [_ state stream] (watch [_ state stream]
(wrf/start! reflow-task) (wrf/start! reflow-task)
(if (= (::resize-wasm-text-debounce-event state) cur-event) (if (= (::resize-wasm-text-debounce-event state) cur-event)
(let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))] (let [stopper (->> stream (rx/filter (ptk/type? ::dw/finalize-workspace)))]
(rx/concat (rx/concat
(rx/merge (rx/merge
(->> stream (->> stream
@ -179,7 +180,7 @@
;; pending until the resize is applied. All exact tasks in the ;; pending until the resize is applied. All exact tasks in the
;; batch are retained in state and finished by the cleanup. ;; batch are retained in state and finished by the cleanup.
(rx/of (fn [state] (rx/of (fn [state]
(run! wrf/finish! (::resize-wasm-text-reflow-tasks state)) (wrf/finish-tasks! (::resize-wasm-text-reflow-tasks state))
(dissoc state (dissoc state
::resize-wasm-text-debounce-ids ::resize-wasm-text-debounce-ids
::resize-wasm-text-reflow-tasks ::resize-wasm-text-reflow-tasks
@ -198,15 +199,15 @@
content (dm/get-in objects [id :content]) content (dm/get-in objects [id :content])
fonts (wasm.fonts/get-content-fonts content) fonts (wasm.fonts/get-content-fonts content)
fonts-loaded? fonts-ready?
(->> fonts (->> fonts
(every? (every?
(fn [font] (fn [font]
(let [font-data (wasm.fonts/make-font-data font)] (let [font-data (wasm.fonts/make-font-data font)]
(wasm.fonts/font-stored? font-data (:emoji? font-data)))))) (wasm.fonts/font-ready? font-data)))))
resize-wasm-stream resize-wasm-stream
(if fonts-loaded? (if fonts-ready?
(let [pass-opts (when (or (some? undo-group) (some? undo-id)) (let [pass-opts (when (or (some? undo-group) (some? undo-id))
(cond-> {} (cond-> {}
(some? undo-group) (assoc :undo-group undo-group) (some? undo-group) (assoc :undo-group undo-group)
@ -232,15 +233,32 @@
(watch [_ state stream] (watch [_ state stream]
(let [resize-stream (let [resize-stream
(->> (rx/from ids) (->> (rx/from ids)
(rx/map #(resize-wasm-text-debounce % opts)))] (rx/map #(resize-wasm-text-debounce % opts)))
buffer-finished-stream
(->> (rx/merge
(->> stream
(rx/filter (ptk/type? ::dwsh/update-shapes-buffer-commit))
(rx/map (constantly :commit)))
;; Let a buffered commit beat the stop signal.
(->> stream
(rx/filter (ptk/type? ::dwsh/update-shapes-buffer-stop))
(rx/observe-on :async)
(rx/map (constantly :stop)))
(->> stream
(rx/filter (ptk/type? ::dw/finalize-workspace))
(rx/map (constantly :finalize))))
(rx/take 1))]
(if (::dwsh/update-shapes-buffer state) (if (::dwsh/update-shapes-buffer state)
;; If we're in the middle of a token propagation we wait until is finished to ;; If we're in the middle of a token propagation we wait until is finished to
;; recalculate the text sizes. The shapes stay pending for that whole wait, ;; recalculate the text sizes. The shapes stay pending for that whole wait,
;; since the per-shape debounce only marks them once dispatched. ;; since the per-shape debounce only marks them once dispatched.
(wrf/with-pending (wrf/with-pending
:text-resize ids :text-resize ids
(->> stream (->> buffer-finished-stream
(rx/filter (ptk/type? ::dwsh/update-shapes-buffer-commit)) (rx/mapcat
(rx/take 1) (fn [reason]
(rx/mapcat (constantly resize-stream)))) (if (= reason :finalize)
(rx/empty)
resize-stream)))))
resize-stream)))))) resize-stream))))))

View File

@ -18,6 +18,7 @@
[app.util.globals :as globals] [app.util.globals :as globals]
[app.util.http :as http] [app.util.http :as http]
[app.util.object :as obj] [app.util.object :as obj]
[app.util.timers :as tm]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
[cuerdas.core :as str] [cuerdas.core :as str]
[okulary.core :as l] [okulary.core :as l]
@ -243,8 +244,10 @@
(defmulti ^:private load-font :backend) (defmulti ^:private load-font :backend)
(defmethod load-font :default (defmethod load-font :default
[{:keys [backend] :as font}] [{:keys [backend ::on-failed] :as font}]
(log/wrn :msg "no implementation found for" :backend backend)) (log/wrn :msg "no implementation found for" :backend backend)
(when (fn? on-failed)
(on-failed (ex-info "unsupported font backend" {:backend backend}))))
(defmethod load-font :builtin (defmethod load-font :builtin
[{:keys [id ::on-loaded] :as font}] [{:keys [id ::on-loaded] :as font}]
@ -269,23 +272,30 @@
(let [base (u/join cf/public-uri "internal/gfonts/font")] (let [base (u/join cf/public-uri "internal/gfonts/font")]
(str/replace css "https://fonts.gstatic.com/s" (dm/str base)))) (str/replace css "https://fonts.gstatic.com/s" (dm/str base))))
(defn- fetch-gfont-css (defn- request-gfont-css
[url] [url]
(->> (http/send! {:method :get :uri url :mode :cors :response-type :text}) (->> (http/send! {:method :get :uri url :mode :cors :response-type :text})
(rx/map :body) (rx/map :body)))
(rx/catch (fn [err]
(log/wrn :hint "cannot find the font" :cause err) (defn- fetch-gfont-css
[url]
(->> (request-gfont-css url)
(rx/catch (fn [cause]
;; Keep CSS streams alive when a font cannot load.
(log/wrn :hint "cannot find the font" :cause cause)
(rx/empty))))) (rx/empty)))))
(defmethod load-font :google (defmethod load-font :google
[{:keys [id ::on-loaded] :as font}] [{:keys [id ::on-loaded ::on-failed] :as font}]
(when (globals/browser?) (when (globals/browser?)
(log/dbg :hint "load-font" :font-id id :backend "google") (log/dbg :hint "load-font" :font-id id :backend "google")
(let [url (generate-gfonts-url font)] (let [url (generate-gfonts-url font)]
(->> (fetch-gfont-css url) ;; Keep raw errors so the loader can use its fallback.
(->> (request-gfont-css url)
(rx/map process-gfont-css) (rx/map process-gfont-css)
(rx/tap #(on-loaded id)) (rx/tap #(on-loaded id))
(rx/subs! (partial add-font-css! id))) (rx/subs! (partial add-font-css! id)
#(when (fn? on-failed) (on-failed %))))
nil))) nil)))
;; --- LOADER: CUSTOM ;; --- LOADER: CUSTOM
@ -358,15 +368,30 @@
;; First caller, we create the promise and then wait ;; First caller, we create the promise and then wait
:else :else
(let [on-load (fn [resolve] (let [settle! (fn [resolve loaded?]
(swap! loaded conj font-id) ;; Defer cleanup until a synchronous load is cached.
(swap! loading dissoc font-id) (tm/schedule
(resolve font-id)) #(do
(when loaded?
(swap! loaded conj font-id))
(swap! loading dissoc font-id)
(resolve font-id))))
on-load (fn [resolve]
(settle! resolve true))
on-failed
(fn [resolve cause]
(log/wrn :hint "font load failed; using fallback"
:font-id font-id
:cause cause)
(settle! resolve false))
load-p (-> (p/create load-p (-> (p/create
(fn [resolve _] (fn [resolve _]
(-> font (-> font
(assoc ::on-loaded (partial on-load resolve)) (assoc ::on-loaded (partial on-load resolve))
(assoc ::on-failed (partial on-failed resolve))
(load-font)))) (load-font))))
;; We need to wait for the font to be loaded ;; We need to wait for the font to be loaded
(p/then (partial p/delay 120)))] (p/then (partial p/delay 120)))]

View File

@ -12,6 +12,7 @@
[app.common.geom.point :as gpt] [app.common.geom.point :as gpt]
[app.common.geom.shapes :as gsh] [app.common.geom.shapes :as gsh]
[app.common.geom.shapes.text :as gsht] [app.common.geom.shapes.text :as gsht]
[app.common.logging :as log]
[app.common.math :as mth] [app.common.math :as mth]
[app.common.types.modifiers :as ctm] [app.common.types.modifiers :as ctm]
[app.common.types.text :as txt] [app.common.types.text :as txt]
@ -96,9 +97,12 @@
(st/emit! (dwt/resize-text id width height))))) (st/emit! (dwt/resize-text id width height)))))
(st/emit! (dwt/clean-text-modifier id)))) (st/emit! (dwt/clean-text-modifier id))))
;; Swallowed so a text whose position data cannot be computed still ;; Always clear the task and log measurement errors.
;; settles and still reports its measurement as finished. (p/catch (fn [cause]
(p/catch (fn [_] nil)))) (log/error :hint "Could not measure text shape"
:shape-id id
:cause cause)
nil))))
(defn- update-text-modifier (defn- update-text-modifier
[{:keys [grow-type id] :as shape} node] [{:keys [grow-type id] :as shape} node]

View File

@ -28,7 +28,6 @@
[app.main.data.workspace.groups :as dwg] [app.main.data.workspace.groups :as dwg]
[app.main.data.workspace.media :as dwm] [app.main.data.workspace.media :as dwm]
[app.main.data.workspace.pages :as dwpg] [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.selection :as dws]
[app.main.data.workspace.variants :as dwv] [app.main.data.workspace.variants :as dwv]
[app.main.data.workspace.wasm-text :as dwwt] [app.main.data.workspace.wasm-text :as dwwt]
@ -47,6 +46,7 @@
[app.plugins.local-storage :as local-storage] [app.plugins.local-storage :as local-storage]
[app.plugins.page :as page] [app.plugins.page :as page]
[app.plugins.parser :as parser] [app.plugins.parser :as parser]
[app.plugins.reflow :as wrfp]
[app.plugins.shape :as shape] [app.plugins.shape :as shape]
[app.plugins.system-events :as se] [app.plugins.system-events :as se]
[app.plugins.user :as user] [app.plugins.user :as user]
@ -416,7 +416,10 @@
(cb/with-objects (:objects page)) (cb/with-objects (:objects page))
(cb/add-object shape))] (cb/add-object shape))]
(st/emit! (ch/commit-changes changes) ;; Track the commit until the renderer starts.
(st/emit! (ptk/data-event :text/reflow {:ids [(:id shape)]
:page-id (:id page)})
(ch/commit-changes changes)
(se/event plugin-id "create-shape" :type :text)) (se/event plugin-id "create-shape" :type :text))
(when (features/active-feature? @st/state "render-wasm/v1") (when (features/active-feature? @st/state "render-wasm/v1")
@ -734,10 +737,5 @@
:waitForLayoutUpdate :waitForLayoutUpdate
(fn [timeout] (fn [timeout]
;; Always a promise, so a bad argument travels as a rejection. ;; Resolves once every shape with reflow work in flight has settled.
(if (u/valid-timeout? timeout) (wrfp/wait-for-layout-update timeout))))
;; Resolves once every shape with reflow work in flight has settled.
(wrf/wait-for-layout-update timeout)
(js/Promise.
(fn [_ reject]
(u/reject-not-valid reject :waitForLayoutUpdate timeout)))))))

View File

@ -0,0 +1,77 @@
;; 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.plugins.reflow
"Promise adapter for the plugin `waitForLayoutUpdate` methods. Owns the
argument validation, the default deadline and the rejection shape; the
workspace only reports when its pending work has drained."
(:require
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.uuid :as uuid]
[app.main.data.workspace.reflow :as wrf]
[beicon.v2.core :as rx]))
;; Ceiling for callers that pass no timeout, so a pipeline that never drains
;; its marks rejects the promise rather than leaving it unsettled.
(def ^:private default-timeout 30000)
;; Largest value a signed 32-bit timer accepts.
(def ^:private max-timeout 2147483647)
(defn- valid-timeout?
"Checks that a plugin timeout fits a signed 32-bit timer."
[value]
(or (nil? value)
(and (number? value)
(pos? value)
(<= value max-timeout)
(js/Number.isFinite value))))
(defn- reject-invalid!
[reject value]
(let [msg (dm/str "[PENPOT PLUGIN] Value not valid: " value
". Code: " :waitForLayoutUpdate)]
(.error js/console msg)
(reject (js/Error. msg))))
(defn shape-wait-ids
"Ids a per-shape wait covers: the shape subtree, its ancestors, and the file
its components sync from."
[objects file-id id]
(-> (into #{} (cfh/get-children-ids-with-self objects id))
(into (cfh/get-parent-ids objects id))
(conj file-id)
(disj uuid/zero)))
(defn wait-for-layout-update
"Returns a JS Promise that resolves once every id in `ids` has drained from
the workspace pending map. A nil `ids` waits for every pending id; an empty
one has nothing to wait for and resolves right away.
The promise is rejected when `timeout` (ms) is not a valid timer value, or
when it elapses first; a nil `timeout` uses `default-timeout`."
([timeout]
(wait-for-layout-update nil timeout))
([ids timeout]
(js/Promise.
(fn [resolve reject]
(if-not (valid-timeout? timeout)
(reject-invalid! reject timeout)
;; Race the settle signal against the deadline; the loser is
;; unsubscribed. `settled` replays on subscribe, so an already drained
;; map wins even against a 1ms deadline.
(->> (rx/race (->> (rx/of :timeout)
(rx/delay (or timeout default-timeout)))
(->> (wrf/settled ids)
(rx/map (constantly :ok))))
(rx/take 1)
(rx/subs!
(fn [value]
(if (= value :timeout)
(reject (js/Error. "waitForLayoutUpdate timeout"))
(resolve)))
reject)))))))

View File

@ -43,7 +43,6 @@
[app.main.data.workspace.guides :as dwgu] [app.main.data.workspace.guides :as dwgu]
[app.main.data.workspace.interactions :as dwi] [app.main.data.workspace.interactions :as dwi]
[app.main.data.workspace.libraries :as dwl] [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.selection :as dws]
[app.main.data.workspace.shape-layout :as dwsl] [app.main.data.workspace.shape-layout :as dwsl]
[app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.shapes :as dwsh]
@ -58,6 +57,7 @@
[app.plugins.format :as format] [app.plugins.format :as format]
[app.plugins.grid :as grid] [app.plugins.grid :as grid]
[app.plugins.parser :as parser] [app.plugins.parser :as parser]
[app.plugins.reflow :as wrfp]
[app.plugins.register :as r] [app.plugins.register :as r]
[app.plugins.ruler-guides :as rg] [app.plugins.ruler-guides :as rg]
[app.plugins.shadows :as shadows] [app.plugins.shadows :as shadows]
@ -1057,15 +1057,11 @@
:waitForLayoutUpdate :waitForLayoutUpdate
(fn [timeout] (fn [timeout]
;; Always a promise, so a bad argument travels as a rejection. ;; Wait for layout work that can affect this shape.
(if (u/valid-timeout? timeout) (let [objects (u/locate-objects file-id page-id)]
;; Resolves once the reflow work of this shape's subtree has (wrfp/wait-for-layout-update
;; settled: it can be marked on the shape or on its descendants. (wrfp/shape-wait-ids objects file-id id)
(let [objects (u/locate-objects file-id page-id)] timeout)))
(wrf/wait-for-layout-update (cfh/get-children-ids-with-self objects id) timeout))
(js/Promise.
(fn [_ reject]
(u/reject-not-valid reject :waitForLayoutUpdate timeout)))))
;; Plugin data ;; Plugin data
:getPluginData :getPluginData

View File

@ -499,10 +499,10 @@
(u/not-valid plugin-id :growType "Cannot modify a page that is not currently active") (u/not-valid plugin-id :growType "Cannot modify a page that is not currently active")
:else :else
(st/emit! (do
(dwsh/update-shapes [id] #(assoc % :grow-type value)) (st/emit! (dwsh/update-shapes [id] #(assoc % :grow-type value)))
(when (features/active-feature? @st/state "render-wasm/v1") (when (features/active-feature? @st/state "render-wasm/v1")
(st/emit! (dwwt/resize-wasm-text-debounce id)))))))} (st/emit! (dwwt/resize-wasm-text-debounce id)))))))}
{:name "fontId" {:name "fontId"
:get #(-> % u/proxy->shape text-props :font-id format/format-mixed) :get #(-> % u/proxy->shape text-props :font-id format/format-mixed)

View File

@ -291,14 +291,6 @@
(throw-not-valid code value) (throw-not-valid code value)
(display-not-valid code value))) (display-not-valid code value)))
(defn valid-timeout?
"A plugin timeout argument: omitted, or a finite positive number of msecs."
[value]
(or (nil? value)
(and (number? value)
(pos? value)
(js/Number.isFinite value))))
(defn reject-not-valid (defn reject-not-valid
[reject code value] [reject code value]
(let [msg (dm/str "[PENPOT PLUGIN] Value not valid: " value ". Code: " code)] (let [msg (dm/str "[PENPOT PLUGIN] Value not valid: " value ". Code: " code)]

View File

@ -30,11 +30,30 @@
(def ^:private custom-fonts (def ^:private custom-fonts
(l/derived :fonts st/state)) (l/derived :fonts st/state))
;; Emits the font-id of every font whose glyphs wasm can already shape and ;; Emits every font face that WASM can measure.
;; measure with. The browser-side loading of `app.main.fonts` is a separate
;; signal: it only says the DOM can render the font.
(defonce font-stored-stream (rx/subject)) (defonce font-stored-stream (rx/subject))
;; Emits failed font faces so layout can fall back.
(defonce font-storage-failed-stream (rx/subject))
;; Stores faces that currently use WASM fallbacks.
(defonce ^:private failed-font-data-keys (atom #{}))
(defn font-data-key
"Returns the identity WASM uses to distinguish stored faces in one family."
[font-data]
(select-keys font-data [:font-id :weight :style :emoji?]))
(defn- clear-font-storage-failure!
[font-data]
(swap! failed-font-data-keys disj (font-data-key font-data)))
(defn- report-font-storage-failed!
[font-data]
(let [key (font-data-key font-data)]
(swap! failed-font-data-keys conj key)
(rx/push! font-storage-failed-stream key)))
(def ^:private default-font-size 14) (def ^:private default-font-size 14)
(def ^:private default-line-height 1.2) (def ^:private default-line-height 1.2)
(def ^:private default-letter-spacing 0.0) (def ^:private default-letter-spacing 0.0)
@ -157,39 +176,91 @@
(:style font-data) (:style font-data)
emoji? emoji?
fallback?) fallback?)
(clear-font-storage-failure! font-data)
;; Reported after the store call: subscribers react by measuring text. ;; Reported after the store call: subscribers react by measuring text.
(rx/push! font-stored-stream (:font-id font-data)) (rx/push! font-stored-stream (font-data-key font-data))
true))) true)))
;; Tracks fonts currently being fetched: {url -> fallback?} ;; Tracks every font face waiting on each shared request.
;; When the same font is requested as both primary and fallback,
;; the fallback flag is upgraded to true so it gets registered
;; in WASM's fallback_fonts set.
(def fetching (atom {})) (def fetching (atom {}))
(defn- register-font-fetch!
[font-url font-data emoji? fallback?]
(let [key (font-data-key font-data)]
(clear-font-storage-failure! font-data)
(swap! fetching
update-in
[font-url key]
(fn [request]
{:font-data font-data
:emoji? emoji?
:fallback? (or fallback? (:fallback? request))}))))
(defn- take-font-fetches!
[font-url]
(let [requests (vals (get @fetching font-url))]
(swap! fetching dissoc font-url)
requests))
(defn- fail-font-fetches!
[font-url cause]
(let [requests (take-font-fetches! font-url)]
(log/error :hint "Could not fetch font"
:font-url font-url
:cause cause)
(doseq [{:keys [font-data]} requests]
(report-font-storage-failed! font-data))))
(defn- store-font-fetch!
[body {:keys [font-data emoji? fallback?]}]
(try
(let [stored? (store-font-buffer font-data body emoji? fallback?)]
(when-not stored?
(report-font-storage-failed! font-data))
stored?)
(catch :default cause
(log/error :hint "Could not store font"
:font-id (:font-id font-data)
:cause cause)
(report-font-storage-failed! font-data)
false)))
(defn- fetch-font (defn- fetch-font
[font-data font-url emoji? fallback?] [font-data font-url emoji? fallback?]
(if (contains? @fetching font-url) (cond
(do (when fallback? (swap! fetching assoc font-url true)) (nil? font-url)
nil) ;; Fail missing font assets without sharing a nil request.
(do (do
(swap! fetching assoc font-url fallback?) (clear-font-storage-failure! font-data)
(tm/schedule #(report-font-storage-failed! font-data))
nil)
(contains? @fetching font-url)
(do
(register-font-fetch! font-url font-data emoji? fallback?)
nil)
:else
(do
(register-font-fetch! font-url font-data emoji? fallback?)
{:key font-url {:key font-url
:callback :callback
(fn [] (fn []
(->> (http/send! {:method :get (try
:uri font-url (->> (http/send! {:method :get
:response-type :buffer}) :uri font-url
(rx/map (fn [{:keys [body]}] :response-type :buffer})
(let [fallback? (get @fetching font-url fallback?)] (rx/map
(swap! fetching dissoc font-url) (fn [{:keys [body]}]
(store-font-buffer font-data body emoji? fallback?)))) (let [requests (take-font-fetches! font-url)]
(rx/catch (fn [cause] (mapv (partial store-font-fetch! body) requests))))
(swap! fetching dissoc font-url) (rx/catch
(log/error :hint "Could not fetch font" (fn [cause]
:font-url font-url (fail-font-fetches! font-url cause)
:cause cause) (rx/empty))))
(rx/empty)))))}))) (catch :default cause
(fail-font-fetches! font-url cause)
(rx/empty))))})))
(defn- google-font-ttf-url (defn- google-font-ttf-url
[font-id font-variant-id font-weight font-style] [font-id font-variant-id font-weight font-style]
@ -220,9 +291,15 @@
(:style font-data) (:style font-data)
emoji?)))) emoji?))))
(defn font-ready?
"Returns true when WASM can lay out with the requested face or its fallback."
[font-data]
(or (contains? @failed-font-data-keys (font-data-key font-data))
(font-stored? font-data (:emoji? font-data))))
(defn- store-font-id (defn- store-font-id
[font-data asset-id emoji? fallback?] [font-data asset-id emoji? fallback?]
(when asset-id (if asset-id
(let [uri (font-id->ttf-url (let [uri (font-id->ttf-url
(:font-id font-data) asset-id (:font-id font-data) asset-id
(:font-variant-id font-data) (:font-variant-id font-data)
@ -234,8 +311,16 @@
(if font-stored? (if font-stored?
;; Deferred so consumers, which subscribe after dispatching the sync ;; Deferred so consumers, which subscribe after dispatching the sync
;; that lands here, are listening when an already-stored font reports. ;; that lands here, are listening when an already-stored font reports.
(tm/schedule #(rx/push! font-stored-stream (:font-id font-data))) (do
(fetch-font font-data uri emoji? fallback?))))) (clear-font-storage-failure! font-data)
(tm/schedule #(rx/push! font-stored-stream (font-data-key font-data))))
(fetch-font font-data uri emoji? fallback?)))
;; Report missing font assets asynchronously.
(do
(clear-font-storage-failure! font-data)
(tm/schedule
#(report-font-storage-failed! font-data))
nil)))
(defn serialize-font-style (defn serialize-font-style
[font-style] [font-style]

View File

@ -6,12 +6,22 @@
(ns frontend-tests.data.workspace-reflow-test (ns frontend-tests.data.workspace-reflow-test
"Tests the reflow tasks the layout and text pipelines feed to "Tests the reflow tasks the layout and text pipelines feed to
`app.main.data.workspace.reflow`, which is what plugin waits observe." `app.main.data.workspace.reflow`, which is what plugin waits observe. The
promise view of the settle signal lives in `app.plugins.reflow`; these tests
use it because it is the wait the plugin API ships."
(:require (:require
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.reflow :as wrf]
[app.main.data.workspace.shape-layout :as dwsl] [app.main.data.workspace.shape-layout :as dwsl]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.texts :as dwtxt] [app.main.data.workspace.texts :as dwtxt]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.fonts :as fonts]
[app.plugins.reflow :as pwrf]
[app.render-wasm.api.fonts :as wasm.fonts]
[app.util.globals :as globals]
[app.util.http :as http]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true] [cljs.test :as t :include-macros true]
[potok.v2.core :as ptk])) [potok.v2.core :as ptk]))
@ -43,7 +53,7 @@
(t/async done (t/async done
(let [store (start-pipeline!)] (let [store (start-pipeline!)]
(ptk/emit! store (ptk/data-event :layout/update {:ids [uuid/zero]})) (ptk/emit! store (ptk/data-event :layout/update {:ids [uuid/zero]}))
(-> (wrf/wait-for-layout-update nil 20) (-> (pwrf/wait-for-layout-update nil 20)
(.then #(t/is true "resolved with no pending work")) (.then #(t/is true "resolved with no pending work"))
(.catch #(t/is false "a root-only update was marked as pending work")) (.catch #(t/is false "a root-only update was marked as pending work"))
(.then (fn [] (.then (fn []
@ -59,16 +69,41 @@
_ (wrf/reset-pending!) _ (wrf/reset-pending!)
current-task (wrf/start! :text-measure [id])] current-task (wrf/start! :text-measure [id])]
(wrf/finish! stale-task) (wrf/finish! stale-task)
(-> (wrf/wait-for-layout-update [id] 20) (-> (pwrf/wait-for-layout-update [id] 20)
(.then #(t/is false "a stale completion drained current work")) (.then #(t/is false "a stale completion drained current work"))
(.catch #(t/is true "current work stayed pending")) (.catch #(t/is true "current work stayed pending"))
(.then (fn [] (.then (fn []
(wrf/finish! current-task) (wrf/finish! current-task)
(wrf/wait-for-layout-update [id] 100))) (pwrf/wait-for-layout-update [id] 100)))
(.then #(t/is true "the exact current task drained normally")) (.then #(t/is true "the exact current task drained normally"))
(.catch #(t/is false "the current task did not drain")) (.catch #(t/is false "the current task did not drain"))
(.then (fn [] (done))))))) (.then (fn [] (done)))))))
(t/deftest reinstalling-the-pending-scan-resets-work-and-keeps-tracking
;; Reinstall the pending scan with the latest reducer.
(t/async done
(let [id (uuid/next)
stale (wrf/start! :text-measure [id])
current* (atom nil)]
(#'wrf/install-pending-subscription!)
(-> (pwrf/wait-for-layout-update [id] 100)
(.then #(t/is true "reinstalling the scan reset its previous generation"))
(.catch #(t/is false "the replaced scan kept stale work pending"))
(.then
(fn []
(reset! current* (wrf/start! :text-measure [id]))
(pwrf/wait-for-layout-update [id] 20)))
(.then #(t/is false "the replacement scan did not track new work"))
(.catch #(t/is true "the replacement scan tracked new work"))
(.then
(fn []
(wrf/finish! stale)
(wrf/finish! @current*)
(pwrf/wait-for-layout-update [id] 100)))
(.then #(t/is true "the replacement scan drained its exact task"))
(.catch #(t/is false "the replacement scan did not drain"))
(.then (fn [] (done)))))))
(t/deftest pending-promise-finishes-at-the-operation-boundary (t/deftest pending-promise-finishes-at-the-operation-boundary
;; Imperative render work is pending from before its thunk starts until the ;; Imperative render work is pending from before its thunk starts until the
;; exact promise returned by that thunk settles; no timer is involved. ;; exact promise returned by that thunk settles; no timer is involved.
@ -82,12 +117,12 @@
(fn [] (fn []
(reset! started? true) (reset! started? true)
(js/Promise. (fn [resolve _] (reset! resolve* resolve))))) (js/Promise. (fn [resolve _] (reset! resolve* resolve)))))
(-> (wrf/wait-for-layout-update [id] 20) (-> (pwrf/wait-for-layout-update [id] 20)
(.then #(t/is false "resolved while the render operation was pending")) (.then #(t/is false "resolved while the render operation was pending"))
(.catch #(t/is @started? "the task was opened before running the operation")) (.catch #(t/is @started? "the task was opened before running the operation"))
(.then (fn [] (.then (fn []
(@resolve*) (@resolve*)
(wrf/wait-for-layout-update [id] 100))) (pwrf/wait-for-layout-update [id] 100)))
(.then #(t/is true "resolved as soon as the render operation settled")) (.then #(t/is true "resolved as soon as the render operation settled"))
(.catch #(t/is false "the settled render operation stayed pending")) (.catch #(t/is false "the settled render operation stayed pending"))
(.then (fn [] (done))))))) (.then (fn [] (done)))))))
@ -98,7 +133,7 @@
(wrf/run-pending! :text-measure [id] #(throw (js/Error. "boom"))) (wrf/run-pending! :text-measure [id] #(throw (js/Error. "boom")))
(catch :default _)) (catch :default _))
(t/async done (t/async done
(-> (wrf/wait-for-layout-update [id] 100) (-> (pwrf/wait-for-layout-update [id] 100)
(.then #(t/is true "a synchronous failure drained its exact task")) (.then #(t/is true "a synchronous failure drained its exact task"))
(.catch #(t/is false "a synchronous failure leaked pending work")) (.catch #(t/is false "a synchronous failure leaked pending work"))
(.then (fn [] (done))))))) (.then (fn [] (done)))))))
@ -110,10 +145,10 @@
task-a (wrf/start! :text-bridge [id-a]) task-a (wrf/start! :text-bridge [id-a])
task-b (wrf/start! :text-bridge [id-b])] task-b (wrf/start! :text-bridge [id-b])]
(wrf/cancel-shapes! [id-a]) (wrf/cancel-shapes! [id-a])
(-> (wrf/wait-for-layout-update [id-a] 100) (-> (pwrf/wait-for-layout-update [id-a] 100)
(.then #(t/is true "deleted shape work was cancelled")) (.then #(t/is true "deleted shape work was cancelled"))
(.catch #(t/is false "deleted shape work stayed pending")) (.catch #(t/is false "deleted shape work stayed pending"))
(.then #(wrf/wait-for-layout-update [id-b] 20)) (.then #(pwrf/wait-for-layout-update [id-b] 20))
(.then #(t/is false "cancelling one shape drained its sibling")) (.then #(t/is false "cancelling one shape drained its sibling"))
(.catch #(t/is true "sibling work stayed pending")) (.catch #(t/is true "sibling work stayed pending"))
(.then (fn [] (.then (fn []
@ -130,28 +165,281 @@
(ptk/emit! store (ptk/data-event :text/reflow {:ids [id-a id-b]})) (ptk/emit! store (ptk/data-event :text/reflow {:ids [id-a id-b]}))
(let [task-a (wrf/start! :text-measure [id-a])] (let [task-a (wrf/start! :text-measure [id-a])]
(wrf/finish! task-a)) (wrf/finish! task-a))
(-> (wrf/wait-for-layout-update [id-b] 20) (-> (pwrf/wait-for-layout-update [id-b] 20)
(.then #(t/is false "the first text released its sibling bridge")) (.then #(t/is false "the first text released its sibling bridge"))
(.catch #(t/is true "the sibling bridge stayed pending")) (.catch #(t/is true "the sibling bridge stayed pending"))
(.then (fn [] (.then (fn []
(let [task-b (wrf/start! :text-measure [id-b])] (let [task-b (wrf/start! :text-measure [id-b])]
(wrf/finish! task-b)) (wrf/finish! task-b))
(wrf/wait-for-layout-update [id-b] 100))) (pwrf/wait-for-layout-update [id-b] 100)))
(.then #(t/is true "the sibling drained after its own measurement")) (.then #(t/is true "the sibling drained after its own measurement"))
(.catch #(t/is false "the sibling never drained")) (.catch #(t/is false "the sibling never drained"))
(.then (fn [] (.then (fn []
(stop-text-pipeline! store) (stop-text-pipeline! store)
(done))))))) (done)))))))
(t/deftest text-bridge-observes-out-of-order-work
;; Start all bridges before matching work can finish.
(t/async done
(let [store (start-text-pipeline!)
id-a (uuid/next)
id-b (uuid/next)]
(ptk/emit! store (ptk/data-event :text/reflow {:ids [id-a id-b]}))
(let [task-b (wrf/start! :text-measure [id-b])]
(wrf/finish! task-b))
(let [task-a (wrf/start! :text-measure [id-a])]
(wrf/finish! task-a))
(-> (pwrf/wait-for-layout-update [id-a id-b] 100)
(.then #(t/is true "both out-of-order bridges observed their work"))
(.catch #(t/is false "a bridge missed work that started out of order"))
(.then (fn []
(stop-text-pipeline! store)
(done)))))))
(t/deftest text-bridge-does-not-consume-preexisting-work
;; Ignore matching work that started before the bridge.
(t/async done
(let [store (start-text-pipeline!)
id (uuid/next)
prior-task (wrf/start! :text-measure [id])]
(ptk/emit! store (ptk/data-event :text/reflow {:ids [id]}))
(wrf/finish! prior-task)
(-> (pwrf/wait-for-layout-update [id] 20)
(.then #(t/is false "preexisting work released the new bridge"))
(.catch #(t/is true "the new bridge remained pending"))
(.then (fn []
(let [current-task (wrf/start! :text-measure [id])]
(wrf/finish! current-task))
(pwrf/wait-for-layout-update [id] 100)))
(.then #(t/is true "work started after the bridge drained it"))
(.catch #(t/is false "the causal measurement did not drain the bridge"))
(.then (fn []
(stop-text-pipeline! store)
(done)))))))
(t/deftest cancelling-a-bridge-does-not-block-later-reflow-events
(t/async done
(let [store (start-text-pipeline!)
id-a (uuid/next)
id-b (uuid/next)]
(ptk/emit! store (ptk/data-event :text/reflow {:ids [id-a]}))
(wrf/cancel-shapes! [id-a])
(ptk/emit! store (ptk/data-event :text/reflow {:ids [id-b]}))
(-> (pwrf/wait-for-layout-update [id-b] 20)
(.then #(t/is false "the later bridge was not opened"))
(.catch #(t/is true "the later bridge stayed pending"))
(.then (fn []
(let [task-b (wrf/start! :text-measure [id-b])]
(wrf/finish! task-b))
(pwrf/wait-for-layout-update [id-b] 100)))
(.then #(t/is true "the later bridge drained after its own work"))
(.catch #(t/is false "the cancelled bridge blocked the pipeline"))
(.then (fn []
(stop-text-pipeline! store)
(done)))))))
(t/deftest finalizing-a-page-cancels-its-text-bridges
(t/async done
(let [store (start-text-pipeline!)
id (uuid/next)]
(ptk/emit! store (ptk/data-event :text/reflow {:ids [id]}))
(ptk/emit! store (ptk/data-event :app.main.data.workspace.pages/finalize-page))
(-> (pwrf/wait-for-layout-update [id] 100)
(.then #(t/is true "page teardown drained the unmeasured text bridge"))
(.catch #(t/is false "page teardown left text work pending"))
(.then (fn []
(stop-text-pipeline! store)
(done)))))))
(t/deftest failed-wasm-font-storage-falls-back-and-drains
(t/async done
(let [id (uuid/next)
font-key {:font-id "gfont-does-not-load"
:weight 400
:style 0
:emoji? false}
stream (rx/subject)
events (atom [])]
(->> (#'dwtxt/await-font-faces stream #{font-key} [id])
(rx/subs! #(swap! events conj %)))
(#'wasm.fonts/report-font-storage-failed! font-key)
(-> (pwrf/wait-for-layout-update [id] 100)
(.then (fn []
(t/is (= 1 (count @events))
"font failure dispatches one fallback resize")
(t/is (wasm.fonts/font-ready? font-key)
"the resize gate accepts the failed face's fallback")
(done)))
(.catch (fn [_]
(t/is false "font failure leaked pending work")
(done)))))))
(t/deftest failed-dom-font-load-falls-back-and-drains
(t/async done
(let [id (uuid/next)
font-id "gfont-layout-failure-test"]
(swap! fonts/fontsdb assoc font-id
{:id font-id
:backend :google
:family "Layout Failure Test"
:variants [{:id "regular"}]})
(swap! fonts/loaded disj font-id)
(swap! fonts/loading dissoc font-id)
(with-redefs [globals/browser? (constantly true)
http/send! (fn [_] (rx/throw (js/Error. "font fetch failed")))]
(wrf/run-pending! :font [id] #(fonts/ensure-loaded! font-id)))
(-> (pwrf/wait-for-layout-update [id] 500)
(.then (fn []
(t/is (not (contains? @fonts/loading font-id))
"a failed load must not remain cached as loading")))
(.catch #(t/is false "failed DOM font load leaked pending work"))
(.then (fn []
(swap! fonts/fontsdb dissoc font-id)
(swap! fonts/loaded disj font-id)
(swap! fonts/loading dissoc font-id)
(done)))))))
(t/deftest failed-google-font-css-does-not-abort-shared-consumers
(t/async done
(let [font-id "gfont-optional-css-test"
values (atom [])
cleanup #(swap! fonts/fontsdb dissoc font-id)]
(swap! fonts/fontsdb assoc font-id
{:id font-id
:backend :google
:family "Optional CSS Test"
:variants [{:id "regular"}]})
(with-redefs [http/send! (fn [_] (rx/throw (js/Error. "font css fetch failed")))]
(->> (fonts/fetch-font-css {:font-id font-id})
(rx/subs!
#(swap! values conj %)
(fn [_]
(cleanup)
(t/is false "an optional font CSS failure escaped the shared helper")
(done))
(fn []
(cleanup)
(t/is (empty? @values)
"a failed optional font contributes no CSS")
(done))))))))
(t/deftest deduplicated-wasm-font-failure-settles-every-face
(t/async done
(let [font-url "https://example.test/shared-font.ttf"
regular {:font-id "gfont-shared-regular"
:weight 400
:style 0
:emoji? false}
bold {:font-id "gfont-shared-bold"
:weight 700
:style 0
:emoji? false}]
(with-redefs [http/send! (fn [_] (rx/throw (js/Error. "shared fetch failed")))]
(let [request (#'wasm.fonts/fetch-font regular font-url false false)
duplicate (#'wasm.fonts/fetch-font bold font-url false false)]
(t/is (some? request) "the first face owns the shared fetch")
(t/is (nil? duplicate) "the second face reuses the shared fetch")
(->> ((:callback request))
(rx/subs!
(fn [_])
(fn [_]
(t/is false "the shared fetch failure escaped its fallback")
(done))
(fn []
(t/is (wasm.fonts/font-ready? regular)
"the first face settled through fallback")
(t/is (wasm.fonts/font-ready? bold)
"the deduplicated face settled through fallback")
(done)))))))))
(t/deftest missing-wasm-font-url-settles-without-entering-fetch-map
(t/async done
(let [font-data {:font-id "gfont-missing-url"
:weight 400
:style 0
:emoji? false}]
(t/is (nil? (#'wasm.fonts/fetch-font font-data nil false false))
"a missing URL starts no request")
(t/is (not (contains? @wasm.fonts/fetching nil))
"missing URLs are not deduplicated under nil")
(js/setTimeout
(fn []
(t/is (wasm.fonts/font-ready? font-data)
"the missing face settled through fallback")
(done))
0))))
(t/deftest wasm-font-resize-waits-for-every-face
(t/async done
(let [id (uuid/next)
regular-key {:font-id "gfont-mixed"
:weight 400
:style 0
:emoji? false}
bold-key {:font-id "gfont-mixed"
:weight 700
:style 0
:emoji? false}
stream (rx/subject)
events (atom [])]
(->> (#'dwtxt/await-font-faces stream #{regular-key bold-key} [id])
(rx/subs! #(swap! events conj %)))
(rx/push! wasm.fonts/font-stored-stream regular-key)
(-> (pwrf/wait-for-layout-update [id] 20)
(.then #(t/is false "the first face released the font task"))
(.catch #(t/is true "the second face remained pending"))
(.then (fn []
(rx/push! wasm.fonts/font-storage-failed-stream bold-key)
(pwrf/wait-for-layout-update [id] 100)))
(.then (fn []
(t/is (= 1 (count @events))
"all faces settling dispatches exactly one resize")
(done)))
(.catch (fn [_]
(t/is false "the complete face set did not drain")
(done)))))))
(t/deftest buffered-wasm-resize-releases-on-stop-without-a-commit
(t/async done
(let [store (ptk/store {:state {} :on-error #(js/console.error %)})
id (uuid/next)]
(ptk/emit! store (dwsh/update-shapes-buffer-start))
(ptk/emit! store (dwwt/resize-wasm-text-all [id]))
(-> (pwrf/wait-for-layout-update [id] 20)
(.then #(t/is false "the buffered resize was not marked pending"))
(.catch #(t/is true "the resize stayed pending while the buffer was open"))
(.then (fn []
(ptk/emit! store (dwsh/update-shapes-buffer-stop))
(pwrf/wait-for-layout-update [id] 500)))
(.then #(t/is true "buffer stop released the fallback resize"))
(.catch #(t/is false "buffer stop without a commit leaked pending work"))
(.then (fn [] (done)))))))
(t/deftest buffered-wasm-resize-releases-on-workspace-finalize
(t/async done
(let [store (ptk/store {:state {} :on-error #(js/console.error %)})
id (uuid/next)]
(ptk/emit! store (dwsh/update-shapes-buffer-start))
(ptk/emit! store (dwwt/resize-wasm-text-all [id]))
(-> (pwrf/wait-for-layout-update [id] 20)
(.then #(t/is false "the buffered resize was not marked pending"))
(.catch #(t/is true "the resize stayed pending while the buffer was open"))
(.then (fn []
(ptk/emit! store (ptk/data-event :app.main.data.workspace/finalize-workspace))
(pwrf/wait-for-layout-update [id] 500)))
(.then #(t/is true "workspace finalization released the buffered resize"))
(.catch #(t/is false "workspace finalization leaked pending work"))
(.then (fn [] (done)))))))
(t/deftest layout-update-is-pending-until-the-buffer-flushes (t/deftest layout-update-is-pending-until-the-buffer-flushes
;; A shape id is marked on arrival and drained when the update is processed. ;; A shape id is marked on arrival and drained when the update is processed.
(t/async done (t/async done
(let [store (start-pipeline!)] (let [store (start-pipeline!)]
(ptk/emit! store (ptk/data-event :layout/update {:ids [(uuid/next) uuid/zero]})) (ptk/emit! store (ptk/data-event :layout/update {:ids [(uuid/next) uuid/zero]}))
(-> (wrf/wait-for-layout-update nil 20) (-> (pwrf/wait-for-layout-update nil 20)
(.then #(t/is false "resolved while the update was still buffered")) (.then #(t/is false "resolved while the update was still buffered"))
(.catch #(t/is true "stayed pending until the flush")) (.catch #(t/is true "stayed pending until the flush"))
(.then #(wrf/wait-for-layout-update nil 5000)) (.then #(pwrf/wait-for-layout-update nil 5000))
(.then #(t/is true "resolved once the update was processed")) (.then #(t/is true "resolved once the update was processed"))
(.catch #(t/is false "the pipeline never drained its mark")) (.catch #(t/is false "the pipeline never drained its mark"))
(.then (fn [] (.then (fn []

View File

@ -11,9 +11,11 @@
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.reflow :as wrf]
[app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.texts :as dwtxt]
[app.main.data.workspace.wasm-text :as dwwt] [app.main.data.workspace.wasm-text :as dwwt]
[app.main.store :as st] [app.main.store :as st]
[app.plugins.api :as api] [app.plugins.api :as api]
[app.plugins.reflow :as pwrf]
[app.plugins.shape :as shape] [app.plugins.shape :as shape]
[app.util.object :as obj] [app.util.object :as obj]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
@ -445,6 +447,24 @@
(set! st/stream (ptk/input-stream test-store)) (set! st/stream (ptk/input-stream test-store))
test-store)) test-store))
(t/deftest test-update-shapes-invokes-update-function-once
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)
{:renderer :svg})
_ (set! st/state store)
_ (set! st/stream (ptk/input-stream store))
^js ctx (api/create-context zero-id)
^js rect (.createRectangle ctx)
id (obj/get rect "$id")
calls (atom 0)]
(ptk/emit! store
(dwsh/update-shapes
[id]
(fn [shape]
(swap! calls inc)
(assoc shape :opacity 0.5))))
(t/is (= 1 @calls) "the update function ran once for the committed shape")
(t/is (= 0.5 (.-opacity rect)) "the single computed result was committed")))
(t/deftest test-wait-for-layout-update-no-pending (t/deftest test-wait-for-layout-update-no-pending
;; When nothing is pending the promise resolves immediately via the fast path ;; When nothing is pending the promise resolves immediately via the fast path
;; (the behavior-subject replays the empty map on subscribe). ;; (the behavior-subject replays the empty map on subscribe).
@ -459,6 +479,209 @@
(t/is false (str "unexpected rejection: " err)) (t/is false (str "unexpected rejection: " err))
(done))))))) (done)))))))
(t/deftest test-create-text-bridges-dom-measurement
(t/async done
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)
{:renderer :svg})]
(set! st/state store)
(set! st/stream (ptk/input-stream store))
(ptk/emit! store (dwtxt/initialize-text-reflow))
(let [^js ctx (api/create-context zero-id)
^js text (.createText ctx "Measure me")
id (obj/get text "$id")]
(-> (.waitForLayoutUpdate ctx 20)
(.then #(t/is false "createText resolved before DOM measurement started"))
(.catch #(t/is true "createText stayed bridged to DOM measurement"))
(.then (fn []
(let [task (wrf/start! :text-measure [id])]
(wrf/finish! task))
(.waitForLayoutUpdate ctx 100)))
(.then #(t/is true "the bridge drained after measurement started"))
(.catch #(t/is false "the createText bridge did not drain"))
(.then (fn []
(ptk/emit! store (dwtxt/finalize-text-reflow))
(done))))))))
(t/deftest test-dom-position-data-stays-pending-until-commit
(t/async done
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)
{:renderer :svg})]
(set! st/state store)
(set! st/stream (ptk/input-stream store))
(ptk/emit! store (dwtxt/initialize-text-reflow))
(let [^js ctx (api/create-context zero-id)
^js text (.createText ctx "Position me")
id (obj/get text "$id")
task (wrf/start! :text-measure [id])
position-data
[{:x 10 :y 20 :width 30 :height 12}]]
(wrf/finish! task)
(-> (.waitForLayoutUpdate text 100)
(.then
(fn []
(ptk/emit! store (dwtxt/update-position-data id position-data))
(-> (.waitForLayoutUpdate text 20)
(.then (constantly false))
(.catch (constantly true)))))
(.then
(fn [timed-out?]
(t/is timed-out?
"position data stayed pending across its debounce")
(.waitForLayoutUpdate text 500)))
(.then
(fn []
(let [bounds (.-textBounds text)]
(t/is (= 30 (obj/get bounds "width"))
"the wait exposed the committed text bounds"))
(ptk/emit! store (dwtxt/finalize-text-reflow))
(done)))
(.catch
(fn [cause]
(ptk/emit! store (dwtxt/finalize-text-reflow))
(t/is false (str "position-data wait did not settle: " cause))
(done))))))))
(t/deftest test-buffered-text-update-bridges-dom-measurement
(t/async done
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)
{:renderer :svg})]
(set! st/state store)
(set! st/stream (ptk/input-stream store))
(ptk/emit! store (dwtxt/initialize-text-reflow))
(let [^js ctx (api/create-context zero-id)
^js text (.createText ctx "Before")
id (obj/get text "$id")
task (wrf/start! :text-measure [id])]
(wrf/finish! task)
(-> (.waitForLayoutUpdate text 100)
(.then
(fn []
(ptk/emit! store (dwsh/update-shapes-buffer-start))
(set! (.-characters text) "After")
(ptk/emit! store (dwsh/update-shapes-buffer-stop))
(.waitForLayoutUpdate text 20)))
(.then #(t/is false "buffered update resolved before DOM measurement"))
(.catch #(t/is true "buffered update stayed bridged to DOM measurement"))
(.then
(fn []
(let [task (wrf/start! :text-measure [id])]
(wrf/finish! task))
(.waitForLayoutUpdate text 100)))
(.then #(t/is true "the buffered update bridge drained"))
(.catch #(t/is false "the buffered update bridge did not drain"))
(.then
(fn []
(ptk/emit! store (dwtxt/finalize-text-reflow))
(done))))))))
(t/deftest test-wasm-grow-type-wait-observes-its-resize
(t/async done
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1))]
(set! st/state store)
(set! st/stream (ptk/input-stream store))
(ptk/emit! store (dwtxt/initialize-text-reflow))
(let [^js ctx (api/create-context zero-id)
^js text (.createText ctx "Resize after grow type")]
(-> (.waitForLayoutUpdate text 500)
(.then
(fn []
(set! (.-growType text) "fixed")
(.waitForLayoutUpdate text 500)))
(.then
(fn []
(t/is (= "fixed" (.-growType text))
"the grow-type bridge drained after its WASM resize")
(ptk/emit! store (dwtxt/finalize-text-reflow))
(done)))
(.catch
(fn [cause]
(ptk/emit! store (dwtxt/finalize-text-reflow))
(t/is false (str "grow-type wait did not settle: " cause))
(done))))))))
(t/deftest test-cloned-text-bridges-dom-measurement
(t/async done
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)
{:renderer :svg})]
(set! st/state store)
(set! st/stream (ptk/input-stream store))
(ptk/emit! store (dwtxt/initialize-text-reflow))
(let [^js ctx (api/create-context zero-id)
^js text (.createText ctx "Clone me")
id (obj/get text "$id")
task (wrf/start! :text-measure [id])]
(wrf/finish! task)
(-> (.waitForLayoutUpdate text 100)
(.then
(fn []
(let [^js clone (.clone text)
clone-id (obj/get clone "$id")]
(-> (.waitForLayoutUpdate clone 20)
(.then #(t/is false "clone resolved before DOM measurement"))
(.catch #(t/is true "clone stayed bridged to DOM measurement"))
(.then
(fn []
(let [task (wrf/start! :text-measure [clone-id])]
(wrf/finish! task))
(.waitForLayoutUpdate clone 100)))))))
(.then #(t/is true "the cloned text bridge drained"))
(.catch #(t/is false "the cloned text bridge did not drain"))
(.then
(fn []
(ptk/emit! store (dwtxt/finalize-text-reflow))
(done))))))))
(t/deftest test-fixed-text-resize-bridges-dom-measurement
(t/async done
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)
{:renderer :svg})]
(set! st/state store)
(set! st/stream (ptk/input-stream store))
(ptk/emit! store (dwtxt/initialize-text-reflow))
(let [^js ctx (api/create-context zero-id)
^js text (.createText ctx "Resize me")
id (obj/get text "$id")
task (wrf/start! :text-measure [id])]
(wrf/finish! task)
(-> (.waitForLayoutUpdate text 100)
(.then
(fn []
;; Finish the grow-type update before resizing.
(set! (.-growType text) "fixed")
(let [task (wrf/start! :text-measure [id])]
(wrf/finish! task))
(.waitForLayoutUpdate text 100)))
(.then
(fn []
(.resize text 240 80)
;; Turn only this short wait into a boolean.
(-> (.waitForLayoutUpdate text 20)
(.then (fn [] false))
(.catch (fn [_] true)))))
(.then
(fn [timed-out?]
(t/is timed-out?
"fixed text resize stayed bridged to DOM measurement")
(let [task (wrf/start! :text-measure [id])]
(wrf/finish! task))
(.waitForLayoutUpdate text 100)))
(.then
(fn []
(t/is true "the resize bridge drained after measurement")
;; Match the DOM renderer's 0.001 geometry tolerance.
(.resize text 240.0005 80)
(.waitForLayoutUpdate text 100)))
(.then
(fn []
(t/is true "a sub-tolerance resize opened no DOM bridge")
(ptk/emit! store (dwtxt/finalize-text-reflow))
(done)))
(.catch
(fn [cause]
(ptk/emit! store (dwtxt/finalize-text-reflow))
(t/is false (str "unexpected resize bridge rejection: " cause))
(done))))))))
(t/deftest test-wait-for-layout-update-pending (t/deftest test-wait-for-layout-update-pending
;; While a shape is pending the context promise stays unresolved; it resolves ;; While a shape is pending the context promise stays unresolved; it resolves
;; once that shape is marked done. ;; once that shape is marked done.
@ -584,6 +807,54 @@
20)) 20))
20)))))) 20))))))
(t/deftest test-wait-for-layout-update-ancestor
;; A shape wait also covers layout on its parents.
(t/async done
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1))]
(set! st/state store)
(set! st/stream (ptk/input-stream store))
(let [^js ctx (api/create-context zero-id)
^js board (.createBoard ctx)
^js rect (.createRectangle ctx)]
(.appendChild board rect)
(let [board-id (obj/get board "$id")
task (wrf/start! :layout [board-id])
resolved (atom false)]
(-> (.waitForLayoutUpdate rect)
(.then (fn [] (reset! resolved true)))
(.catch (fn [err]
(t/is false (str "unexpected rejection: " err)))))
(js/setTimeout
(fn []
(t/is (false? @resolved) "child wait must block on a pending ancestor")
(wrf/finish! task)
(js/setTimeout
(fn []
(t/is (true? @resolved) "resolves once the ancestor drains")
(done))
20))
20))))))
(t/deftest test-shape-wait-observes-file-sync
(t/async done
(let [file (cthf/sample-file :file1 :page-label :page1)
store (ths/setup-store file)
_ (set! st/state store)
_ (set! st/stream (ptk/input-stream store))
^js ctx (api/create-context zero-id)
^js shape (.createRectangle ctx)
task (wrf/start! :sync-file [(:id file)])]
(-> (.waitForLayoutUpdate shape 20)
(.then #(t/is false "shape wait ignored its pending file sync"))
(.catch
(fn []
(t/is true "shape wait remained pending for its file sync")
(wrf/finish! task)
(.waitForLayoutUpdate shape 100)))
(.then #(t/is true "shape wait drained after the file sync"))
(.catch #(t/is false "shape wait did not drain its file sync"))
(.then (fn [] (done)))))))
(t/deftest test-wait-for-layout-update-invalid-timeout (t/deftest test-wait-for-layout-update-invalid-timeout
;; A non-numeric or non-positive timeout is an invalid argument. The method ;; A non-numeric or non-positive timeout is an invalid argument. The method
;; always hands back a promise and rejects it, whatever the plugin's ;; always hands back a promise and rejects it, whatever the plugin's
@ -603,6 +874,7 @@
(rejected? (.waitForLayoutUpdate ctx -5)) (rejected? (.waitForLayoutUpdate ctx -5))
(rejected? (.waitForLayoutUpdate ctx js/NaN)) (rejected? (.waitForLayoutUpdate ctx js/NaN))
(rejected? (.waitForLayoutUpdate ctx js/Infinity)) (rejected? (.waitForLayoutUpdate ctx js/Infinity))
(rejected? (.waitForLayoutUpdate ctx 2147483648))
(rejected? (.waitForLayoutUpdate shape "soon"))]) (rejected? (.waitForLayoutUpdate shape "soon"))])
(.then (fn [results] (.then (fn [results]
(t/is (every? true? (array-seq results)) (t/is (every? true? (array-seq results))
@ -661,7 +933,7 @@
resolved (atom false)] resolved (atom false)]
(ptk/emit! store (dwsh/update-shapes-buffer-start)) (ptk/emit! store (dwsh/update-shapes-buffer-start))
(ptk/emit! store (dwwt/resize-wasm-text-all [id])) (ptk/emit! store (dwwt/resize-wasm-text-all [id]))
(-> (wrf/wait-for-layout-update [id] nil) (-> (pwrf/wait-for-layout-update [id] nil)
(.then (fn [] (reset! resolved true))) (.then (fn [] (reset! resolved true)))
(.catch (fn [err] (.catch (fn [err]
(t/is false (str "unexpected rejection: " err))))) (t/is false (str "unexpected rejection: " err)))))

View File

@ -1,6 +1,13 @@
import { expect, expectReject } from '../framework/expect'; import { expect, expectReject } from '../framework/expect';
import { describe, test } from '../framework/registry'; import { describe, test } from '../framework/registry';
import type { Board, Font, Group, Shape, Text } from '@penpot/plugin-types'; import type {
Board,
Font,
Group,
Penpot,
Shape,
Text,
} from '@penpot/plugin-types';
import type { TestContext } from '../framework/types'; import type { TestContext } from '../framework/types';
// waitForLayoutUpdate (context-level and per-shape). // waitForLayoutUpdate (context-level and per-shape).
@ -43,8 +50,17 @@ function byX(rects: [Shape, Shape]): { left: Shape; right: Shape } {
return a.x <= b.x ? { left: a, right: b } : { left: b, right: a }; return a.x <= b.x ? { left: a, right: b } : { left: b, right: a };
} }
/** Font ids already handed out by `unloadedFont`. */ /** Tracks fonts used by this test run. */
const claimedFonts = new Set<string>(); const claimedFontsByRun = new WeakMap<Penpot, Set<string>>();
function claimedFonts(ctx: TestContext): Set<string> {
let claimed = claimedFontsByRun.get(ctx.penpot);
if (!claimed) {
claimed = new Set<string>();
claimedFontsByRun.set(ctx.penpot, claimed);
}
return claimed;
}
/** /**
* Picks an unclaimed font differing from the text's current one, so assigning * Picks an unclaimed font differing from the text's current one, so assigning
@ -53,11 +69,12 @@ const claimedFonts = new Set<string>();
*/ */
function unloadedFont(ctx: TestContext, t: Text): Font { function unloadedFont(ctx: TestContext, t: Text): Font {
const all = ctx.penpot.fonts.all; const all = ctx.penpot.fonts.all;
const claimed = claimedFonts(ctx);
for (let i = all.length - 1; i >= 0; i--) { for (let i = all.length - 1; i >= 0; i--) {
const f = all[i]; const f = all[i];
if (f.fontId === t.fontId || f.variants.length === 0) continue; if (f.fontId === t.fontId || f.variants.length === 0) continue;
if (claimedFonts.has(f.fontId)) continue; if (claimed.has(f.fontId)) continue;
claimedFonts.add(f.fontId); claimed.add(f.fontId);
return f; return f;
} }
throw new Error('no alternative font available'); throw new Error('no alternative font available');
@ -351,6 +368,89 @@ describe('WaitForLayoutUpdate', () => {
}); });
}); });
describe('Components', () => {
test('wait covers propagation from a component main to its copy', async (ctx) => {
const source = ctx.penpot.createRectangle();
ctx.board.appendChild(source);
const component = ctx.penpot.library.local.createComponent([source]);
const main = component.mainInstance() as Board;
const copy = component.instance() as Board;
ctx.board.appendChild(copy);
await ctx.penpot.waitForLayoutUpdate();
const mainChild = main.children[0];
const copyChild = copy.children[0];
mainChild.opacity = 0.37;
await ctx.penpot.waitForLayoutUpdate();
expect(copyChild.opacity).toBeCloseTo(0.37);
});
test('shape wait covers propagation from a component main to its copy', async (ctx) => {
const source = ctx.penpot.createRectangle();
ctx.board.appendChild(source);
const component = ctx.penpot.library.local.createComponent([source]);
const main = component.mainInstance() as Board;
const copy = component.instance() as Board;
ctx.board.appendChild(copy);
await ctx.penpot.waitForLayoutUpdate();
const mainChild = main.children[0];
const copyChild = copy.children[0];
mainChild.opacity = 0.63;
await copyChild.waitForLayoutUpdate();
expect(copyChild.opacity).toBeCloseTo(0.63);
});
test('wait covers layout triggered by component propagation', async (ctx) => {
const host = flexBoard(ctx);
const flex = host.addFlexLayout();
flex.dir = 'row';
flex.columnGap = 10;
const first = ctx.penpot.createRectangle();
first.resize(50, 50);
flex.appendChild(first);
const second = ctx.penpot.createRectangle();
second.resize(50, 50);
flex.appendChild(second);
const component = ctx.penpot.library.local.createComponent([host]);
const main = component.mainInstance() as Board;
const copy = component.instance() as Board;
ctx.board.appendChild(copy);
await ctx.penpot.waitForLayoutUpdate();
const { left: mainLeft } = byX([main.children[0], main.children[1]]);
mainLeft.resize(120, 50);
await ctx.penpot.waitForLayoutUpdate();
const { left: copyLeft, right: copyRight } = byX([
copy.children[0],
copy.children[1],
]);
expect(copyLeft.width).toBeCloseTo(120, 0);
expect(copyRight.x - copyLeft.x).toBeCloseTo(130, 0);
});
});
describe('Library assets', () => {
test('wait covers propagation from a local color to a referenced shape', async (ctx) => {
const color = ctx.penpot.library.local.createColor();
color.color = '#112233';
color.opacity = 1;
const rect = ctx.penpot.createRectangle();
rect.fills = [color.asFill()];
ctx.board.appendChild(rect);
await ctx.penpot.waitForLayoutUpdate();
color.color = '#aabbcc';
await ctx.penpot.waitForLayoutUpdate();
expect(rect.fills[0]?.fillColor).toBe('#aabbcc');
});
});
// A font applied to a group reaches its text descendants, so the work is // A font applied to a group reaches its text descendants, so the work is
// pending on the children and never on the group itself. Skipped under a // pending on the children and never on the group itself. Skipped under a
// mocked backend for the same reason as the Text group: no fonts are served, // mocked backend for the same reason as the Text group: no fonts are served,

View File

@ -60,15 +60,41 @@ body {
background-color: var(--background-secondary); background-color: var(--background-secondary);
} }
.group-summary { .group-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-8, 8px); gap: var(--spacing-8, 8px);
padding: var(--spacing-8, 8px); padding: var(--spacing-8, 8px);
}
/* Makes the full header row toggle the group. */
.group-toggle {
display: flex;
flex: 1;
align-items: center;
gap: var(--spacing-8, 8px);
min-width: 0;
margin: 0;
padding: 0;
border: none;
background: none;
color: inherit;
font: inherit;
text-align: start;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
} }
.group-chevron {
flex: 0 0 auto;
color: var(--foreground-secondary);
transition: transform 0.15s ease;
}
.group-toggle[aria-expanded='true'] .group-chevron {
transform: rotate(90deg);
}
.group-name { .group-name {
color: var(--foreground-primary); color: var(--foreground-primary);
} }
@ -144,6 +170,11 @@ body {
padding: 0; padding: 0;
} }
/* Keeps hidden test lists out of the layout. */
.test-list[hidden] {
display: none;
}
.test-row { .test-row {
display: grid; display: grid;
grid-template-columns: 1fr auto auto; grid-template-columns: 1fr auto auto;

View File

@ -128,6 +128,13 @@ function reloadIcon(): SVGSVGElement {
return svgIcon(['M13 8a5 5 0 1 1-1.46-3.54', 'M13 2.5v3h-3'], false); return svgIcon(['M13 8a5 5 0 1 1-1.46-3.54', 'M13 2.5v3h-3'], false);
} }
/** Shows whether a group is expanded. */
function chevronIcon(): SVGSVGElement {
const icon = svgIcon(['M6 3.5 10.5 8 6 12.5'], false);
icon.classList.add('group-chevron');
return icon;
}
function render() { function render() {
root.replaceChildren( root.replaceChildren(
renderHeader(), renderHeader(),
@ -273,9 +280,12 @@ function renderRow(test: TestMeta): HTMLElement {
return row; return row;
} }
function renderGroupSummary( /** Builds a group header with separate select, toggle, and run controls. */
function renderGroupHeader(
name: string, name: string,
groupTestList: TestMeta[], groupTestList: TestMeta[],
panelId: string,
expanded: boolean,
): HTMLElement { ): HTMLElement {
const statuses = groupTestList.map( const statuses = groupTestList.map(
(t) => results.get(t.id)?.status ?? 'pending', (t) => results.get(t.id)?.status ?? 'pending',
@ -291,12 +301,12 @@ function renderGroupSummary(
const groupCheckbox = el('input', { const groupCheckbox = el('input', {
type: 'checkbox', type: 'checkbox',
className: 'checkbox-input', className: 'checkbox-input',
title: `Select every test in "${name}"`,
ariaLabel: `Select every test in "${name}"`,
checked: selectedCount === total && total > 0, checked: selectedCount === total && total > 0,
disabled: running, disabled: running,
}); });
groupCheckbox.indeterminate = selectedCount > 0 && selectedCount < total; groupCheckbox.indeterminate = selectedCount > 0 && selectedCount < total;
// Keep the checkbox from toggling the <details> when clicked.
groupCheckbox.addEventListener('click', (e) => e.stopPropagation());
groupCheckbox.addEventListener('change', () => { groupCheckbox.addEventListener('change', () => {
if (groupCheckbox.checked) ids.forEach((id) => selected.add(id)); if (groupCheckbox.checked) ids.forEach((id) => selected.add(id));
else ids.forEach((id) => selected.delete(id)); else ids.forEach((id) => selected.delete(id));
@ -305,17 +315,14 @@ function renderGroupSummary(
const runButton = el('button', { const runButton = el('button', {
className: 'icon-button run-group', className: 'icon-button run-group',
type: 'button',
title: `Run "${name}"`, title: `Run "${name}"`,
ariaLabel: `Run "${name}"`, ariaLabel: `Run "${name}"`,
disabled: running, disabled: running,
}); });
runButton.dataset.appearance = 'secondary'; runButton.dataset.appearance = 'secondary';
runButton.append(playIcon()); runButton.append(playIcon());
runButton.addEventListener('click', (e) => { runButton.addEventListener('click', () => run(ids));
e.preventDefault();
e.stopPropagation();
run(ids);
});
const counts = el('span', { className: 'group-counts' }, [ const counts = el('span', { className: 'group-counts' }, [
el('span', { className: 'count-pass', textContent: `${passed}` }), el('span', { className: 'count-pass', textContent: `${passed}` }),
@ -327,14 +334,26 @@ function renderGroupSummary(
}), }),
]); ]);
return el('summary', { className: 'group-summary' }, [ const toggle = el('button', { className: 'group-toggle', type: 'button' }, [
groupCheckbox, chevronIcon(),
el('span', { el('span', {
className: `status-dot dot-${aggregate}`, className: `status-dot dot-${aggregate}`,
title: statusLabel(aggregate), title: statusLabel(aggregate),
}), }),
el('span', { className: 'group-name', textContent: name }), el('span', { className: 'group-name', textContent: name }),
counts, counts,
]);
toggle.setAttribute('aria-expanded', String(expanded));
toggle.setAttribute('aria-controls', panelId);
toggle.addEventListener('click', () => {
if (expanded) expandedGroups.delete(name);
else expandedGroups.add(name);
render();
});
return el('div', { className: 'group-header' }, [
groupCheckbox,
toggle,
runButton, runButton,
]); ]);
} }
@ -342,25 +361,24 @@ function renderGroupSummary(
function renderList(): HTMLElement { function renderList(): HTMLElement {
const container = el('div', { className: 'groups' }); const container = el('div', { className: 'groups' });
for (const group of groupTests()) { groupTests().forEach((group, index) => {
const details = el('details', { className: 'group' });
// Groups are collapsed by default; remember the ones the user expands. // Groups are collapsed by default; remember the ones the user expands.
details.open = expandedGroups.has(group.name); const expanded = expandedGroups.has(group.name);
details.addEventListener('toggle', () => { const panelId = `group-panel-${index}`;
if (details.open) expandedGroups.add(group.name);
else expandedGroups.delete(group.name);
});
details.append(renderGroupSummary(group.name, group.tests)); const list = el('ul', { className: 'test-list', id: panelId });
list.hidden = !expanded;
const list = el('ul', { className: 'test-list' });
for (const test of group.tests) { for (const test of group.tests) {
list.append(renderRow(test)); list.append(renderRow(test));
} }
details.append(list);
container.append(details); container.append(
} el('div', { className: 'group' }, [
renderGroupHeader(group.name, group.tests, panelId, expanded),
list,
]),
);
});
return container; return container;
} }

View File

@ -1353,12 +1353,13 @@ export interface Context {
/** /**
* This method returns a promise that will be resolved when all the * This method returns a promise that will be resolved when all the
* pending layout updates have finished. If no layout work is pending * pending layout updates have finished and the components have synchronized.
* the promise resolves immediately. * If no layout work is pending the promise resolves immediately.
* @param timeout Maximum time to wait, in milliseconds. If the timeout * @param timeout Maximum time to wait, in milliseconds. If the timeout
* elapses before the layout settles, the promise is rejected. Defaults to * elapses before the layout settles, the promise is rejected. Defaults to
* 30000; the promise never waits indefinitely. * 30000; the promise never waits indefinitely.
* @return The promise to be resolved when the layout is updated * @return The promise to be resolved when the layout is updated. It is
* rejected with an Error, both on timeout and on an invalid timeout value.
*/ */
waitForLayoutUpdate(timeout?: number): Promise<void>; waitForLayoutUpdate(timeout?: number): Promise<void>;
} }
@ -4109,13 +4110,14 @@ export interface ShapeBase extends PluginData {
remove(): void; remove(): void;
/** /**
* This method returns a promise that will be resolved when the pending * This method returns a promise that will be resolved when all the
* layout updates for this shape and its children have finished. If no layout * pending layout updates have finished and the components have synchronized.
* work is pending for them the promise resolves immediately. * If no layout work is pending the promise resolves immediately.
* @param timeout Maximum time to wait, in milliseconds. If the timeout * @param timeout Maximum time to wait, in milliseconds. If the timeout
* elapses before the shape's layout settles, the promise is rejected. * elapses before the shape's layout settles, the promise is rejected.
* Defaults to 30000; the promise never waits indefinitely. * Defaults to 30000; the promise never waits indefinitely.
* @return The promise to be resolved when the shape's layout is updated * @return The promise to be resolved when the shape's layout is updated. It
* is rejected with an Error, both on timeout and on an invalid timeout value.
*/ */
waitForLayoutUpdate(timeout?: number): Promise<void>; waitForLayoutUpdate(timeout?: number): Promise<void>;
} }