diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index d39d839ad7..fd2250c379 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -40,6 +40,7 @@ [app.main.data.workspace.groups :as dwg] [app.main.data.workspace.notifications :as-alias dwn] [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.shapes :as dwsh] [app.main.data.workspace.specialized-panel :as dwsp] @@ -1129,6 +1130,16 @@ (def valid-asset-types #{: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 [updating?] (ptk/reify ::set-updating-library @@ -1138,6 +1149,32 @@ (assoc state :updating-library true) (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 "Synchronize the given file from the given library. Walk through all shapes in all pages in the file that use some color, typography or @@ -1196,35 +1233,20 @@ updated-frames (->> changes :redo-changes (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 (:redo-changes changes) ldata)) (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 (->> 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"))))))))) + ;; Keep the sync pending until its layout work starts. + (wrf/with-pending :sync-file pending-ids frontend-sync) (when (not= file-id library-id) ;; When we have just updated the library file, give some time for the @@ -1400,66 +1422,88 @@ (rx/buffer 2 1) (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 (rx/filter dch/commit?) (rx/map deref) (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)) - check-changes + get-component-events (fn [[event old-data]] - (cond - (nil? old-data) - (rx/empty) + (let [{:keys [file-id changes save-undo? undo-group]} event + changed-components + (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) - (rx/empty) + 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)))) - :else - (let [{:keys [file-id changes save-undo? undo-group]} event + :else + ;; Undos only bump :modified-at. + (->> (rx/from changed-components) + (rx/map touch-component))))) - changed-components - (when (or (nil? file-id) (= file-id (:id old-data))) - (->> 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 + component-events-s + (->> commits-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)) notifier-s - (->> changes-s + (->> component-events-s (rx/debounce 5000) (rx/tap #(log/trc :hint "buffer initialized")))] (when (or (contains? cf/flags :component-thumbnails) (features/active-feature? state "render-wasm/v1")) (->> (rx/merge - changes-s + component-events-s ;; WASM only: render the thumbnail on every component ;; change so single edits (fill, etc.) update instantly. ;; Non-WASM persists on every render, so it stays on the ;; debounced path below to avoid per-edit backend posts. (if (features/active-feature? state "render-wasm/v1") - (->> changes-s + (->> component-events-s (rx/filter (ptk/type? ::component-changed)) (rx/map deref) (rx/map render-component-thumbnail-event)) @@ -1467,7 +1511,7 @@ ;; Persist to the server in batches, 5s after the user ;; goes idle. - (->> changes-s + (->> component-events-s (rx/filter (ptk/type? ::component-changed)) (rx/map deref) (rx/buffer-until notifier-s) @@ -1476,7 +1520,7 @@ (update-component-thumbnail component-id file-id)))) ;; Undo/redo emit touch-component instead. - (->> changes-s + (->> component-events-s (rx/filter (ptk/type? ::touch-component)) (rx/map deref) (rx/map render-component-thumbnail-event))) @@ -1631,5 +1675,3 @@ (rx/mapcat (fn [_] (rp/cmd! :get-file-libraries {:file-id file-id}))) (rx/map (partial cleanup-unlinked-libraries file-id)))))) - - diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs index fde7e22d6f..8931690c2b 100644 --- a/frontend/src/app/main/data/workspace/mcp.cljs +++ b/frontend/src/app/main/data/workspace/mcp.cljs @@ -14,6 +14,7 @@ [app.main.broadcast :as mbc] [app.main.data.plugins :as dp] [app.main.data.profile :as du] + [app.main.data.workspace :as-alias dw] [app.main.store :as st] [app.plugins.register :as preg] [app.util.timers :as ts] @@ -132,7 +133,7 @@ (assoc :host (str (u/join cf/public-uri "plugins/mcp/")))) 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)) extension #js {:getToken (constantly token) @@ -202,7 +203,7 @@ ptk/WatchEvent (watch [_ state stream] (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)) session-id (get state :session-id) diff --git a/frontend/src/app/main/data/workspace/reflow.cljs b/frontend/src/app/main/data/workspace/reflow.cljs index 3932aa7ca7..1fc4842672 100644 --- a/frontend/src/app/main/data/workspace/reflow.cljs +++ b/frontend/src/app/main/data/workspace/reflow.cljs @@ -5,11 +5,13 @@ ;; Copyright (c) KALEIDOS INC (ns app.main.data.workspace.reflow - "Tracks the shape ids that have layout/reflow work in flight, broken down by - the kind of work so we can tell which type of reflow is blocking each shape. + "Tracks the ids that have layout/reflow work in flight, broken down by the + kind of work so we can tell which type of reflow is blocking each id. - Pending work is stored as `{shape-id -> {kind -> #{task-id}}}`. Every producer - opens an exact task with `start!` and closes that same task with `finish!`. + Pending work is stored as `{id -> {kind -> #{task-id}}}`, where ids are page + 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 finalized workspace cannot drain work opened after the workspace reloads. @@ -21,8 +23,10 @@ :layout flex/grid layout reflow (shape-layout) :text-resize text geometry resize (wasm-text, texts) :text-measure DOM text measurement (texts) + :text-position DOM text fragment geometry (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 [beicon.v2.core :as rx] [promesa.core :as p])) @@ -55,22 +59,35 @@ acc ids))) +;; Single-task operations are wrapped as batches before reaching the reducer. (defn- reducer - [acc {:keys [op task ids]}] + [acc {:keys [op tasks ids]}] (case op - :add (add-task acc task) - :remove (remove-task acc task) + :add (reduce add-task acc tasks) + :remove (reduce remove-task acc tasks) :cancel (apply dissoc acc ids) :reset {} acc)) -;; Behaviour subject holding `{shape-id -> {kind -> #{task-id}}}`. -;; It replays its current value synchronously to new subscribers, which gives -;; `wait-for-layout-update` a free fast-path when there is nothing pending. -(defonce ^:private pending-shapes - (let [sub (rx/behavior-subject {})] - (rx/sub! (->> reflow-input (rx/scan reducer {})) sub) - sub)) +;; Holds pending tasks and replays them to new waiters. +;; Reloads rebuild the scan with the latest reducer. +(def ^:private pending-shapes (rx/behavior-subject {})) + +(defonce ^:private pending-subscription (atom nil)) + +(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 "Creates an opaque task token without opening it." @@ -80,24 +97,42 @@ :kind kind :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! "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] - (when (and (seq (:ids task)) - (= (:generation task) @workspace-generation)) - (rx/push! reflow-input {:op :add :task task})) + (push-tasks! :add [task]) 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! "Closes `task` if it belongs to the active workspace generation. Repeated or stale completion is a no-op." - [{:keys [generation ids] :as task}] - (when (and (seq ids) - (= generation @workspace-generation)) - (rx/push! reflow-input {:op :remove :task task}))) + [task] + (finish-tasks! [task])) (defn reset-pending! "Starts a new workspace generation and forgets every task from the old one." @@ -136,59 +171,77 @@ (finish! task) (throw cause))))) -(defn pending-signal - "Emits once any of `kinds` is pending for any of `ids`, then completes. - Emits right away when that work is already in flight." - [ids kinds] - (letfn [(id-pending? [pending id] - (some (partial contains? (get pending id)) kinds)) +(defn bridge-pending + "Keeps each id pending until matching work starts." + [ids target-kinds bridge-kind] + (let [ids (into #{} ids)] + (if (empty? ids) + (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] - (some (partial id-pending? pending) ids))] - (->> pending-shapes - (rx/filter any-pending?) - (rx/take 1)))) + remaining + (atom ids) -;; 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) + release! + (fn [released] + (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 - "Returns a JS Promise that resolves when every id in `shape-ids` has drained - from the pending map. A nil `shape-ids` waits for every pending shape; an - empty one has nothing to wait for and resolves right away. The promise is - rejected when `timeout` (ms) elapses first; a nil `timeout` uses - `default-timeout`. + matching-task-ids + (fn [tasks] + (into #{} + (comp + (filter #(contains? target-kinds (:kind %))) + (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 on the shape (a board laying out its children) or on its descendants (a group whose texts are re-measured)." - ([timeout] - (wait-for-layout-update nil timeout)) - ([shape-ids timeout] - (js/Promise. - (fn [resolve reject] - (let [timeout (or timeout default-timeout) - - 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))))))) + [ids] + (let [done? (if (some? ids) + (fn [pending] (not-any? #(contains? pending %) ids)) + empty?)] + (->> pending-shapes + (rx/filter done?) + (rx/take 1)))) diff --git a/frontend/src/app/main/data/workspace/reflow/signals.cljs b/frontend/src/app/main/data/workspace/reflow/signals.cljs new file mode 100644 index 0000000000..d2c145676e --- /dev/null +++ b/frontend/src/app/main/data/workspace/reflow/signals.cljs @@ -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)) diff --git a/frontend/src/app/main/data/workspace/selection.cljs b/frontend/src/app/main/data/workspace/selection.cljs index 36039f230b..838d6cc02e 100644 --- a/frontend/src/app/main/data/workspace/selection.cljs +++ b/frontend/src/app/main/data/workspace/selection.cljs @@ -29,6 +29,7 @@ [app.main.data.workspace.undo :as dwu] [app.main.data.workspace.viewport-wasm :as dwvw] [app.main.data.workspace.zoom :as dwz] + [app.main.features :as features] [app.main.refs :as refs] [app.main.router :as rt] [app.main.streams :as ms] @@ -452,6 +453,16 @@ (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 [ids & {:keys [move-delta? alt-duplication? change-selection? return-ref] :or {move-delta? false alt-duplication? false change-selection? true return-ref nil}}] @@ -493,6 +504,9 @@ (map #(get-in % [:obj :id])) (into (d/ordered-set))) + new-dom-text-ids + (get-new-dom-text-ids state changes) + id-duplicated (first new-ids) frames (into #{} @@ -531,6 +545,11 @@ ;; Warning: This order is important for the focus mode. (->> (rx/of (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) (when change-selection? (select-shapes new-ids)) diff --git a/frontend/src/app/main/data/workspace/shape_layout.cljs b/frontend/src/app/main/data/workspace/shape_layout.cljs index fade07bd2d..d4c09ec75e 100644 --- a/frontend/src/app/main/data/workspace/shape_layout.cljs +++ b/frontend/src/app/main/data/workspace/shape_layout.cljs @@ -23,6 +23,7 @@ [app.main.data.changes :as dch] [app.main.data.event :as ev] [app.main.data.helpers :as dsh] + [app.main.data.workspace :as-alias dw] [app.main.data.workspace.colors :as cl] [app.main.data.workspace.grid-layout.editor :as dwge] [app.main.data.workspace.modifiers :as dwm] @@ -131,14 +132,14 @@ (->> stream (rx/filter (ptk/type? ::dwsh/update-shapes-buffer-commit)) (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 (rx/ignore)) (rx/empty))] (cond->> (rx/concat update-positions-stream drain-stream) (d/not-empty? reflow-tasks) - (rx/finalize #(run! wrf/finish! reflow-tasks))))))) + (rx/finalize #(wrf/finish-tasks! reflow-tasks))))))) (defn- without-root-board [ids] diff --git a/frontend/src/app/main/data/workspace/shapes.cljs b/frontend/src/app/main/data/workspace/shapes.cljs index 0b3cfb3f94..3530e35f3b 100644 --- a/frontend/src/app/main/data/workspace/shapes.cljs +++ b/frontend/src/app/main/data/workspace/shapes.cljs @@ -24,34 +24,12 @@ [app.main.data.workspace.collapse :as dwco] [app.main.data.workspace.edition :as dwe] [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.undo :as dwu] - [app.main.features :as features] [beicon.v2.core :as rx] [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 [changes state] (let [undo (:workspace-undo state) @@ -82,15 +60,35 @@ (update [_ state] (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 [] (ptk/reify ::update-shapes-buffer-commit ptk/WatchEvent (watch [_ state _] - (->> (get state ::update-shapes-buffer-changes) - (vals) - (map dch/commit-changes) - (rx/from))))) + (let [text-reflow-events + (->> (get state ::update-shapes-buffer-text-candidates) + (keep (fn [[page-id ids]] + (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" ;; update-shapes-buffer will return the objeccts inside the current changes @@ -111,7 +109,8 @@ (update-shapes-buffer ids update-fn nil)) ([ids update-fn {: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 save-undo? true stack-undo? false @@ -126,9 +125,14 @@ (assoc state ::update-shapes-buffer-event cur-event) (let [page-id (or page-id (get state :current-page-id)) - objects (dsh/lookup-page-objects state page-id)] - (-> state + objects (lookup-changed-objects state page-id) + text-ids + (into #{} + (filter #(cfh/text-shape? objects %)) + ids) + state (update-in + state [::update-shapes-buffer-changes page-id] (fn [changes] (-> (or changes @@ -148,7 +152,15 @@ :ignore-touched ignore-touched :with-objects? with-objects?}) (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 (watch [_ state stream] @@ -165,6 +177,7 @@ (rx/of #(dissoc % ::update-shapes-buffer-changes + ::update-shapes-buffer-text-candidates ::update-shapes-buffer-event)))) (rx/empty))))))) @@ -174,14 +187,12 @@ ([ids update-fn {:as props :keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id - ignore-touched undo-group with-objects? changed-sub-attr translation? - update-layout?] + ignore-touched undo-group with-objects? changed-sub-attr translation?] :or {reg-objects? false save-undo? true stack-undo? false ignore-touched false - with-objects? false - update-layout? true}}] + with-objects? false}}] (assert (every? uuid? ids) "expect a coll of uuid for `ids`") (assert (fn? update-fn) "the `update-fn` should be a valid function") @@ -197,49 +208,6 @@ objects (dsh/lookup-page-objects state page-id) 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 (-> (pcb/empty-changes it page-id) (pcb/set-save-undo? save-undo?) @@ -257,6 +225,12 @@ (pcb/set-undo-group undo-group)) (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 (add-undo-group changes state)] @@ -264,8 +238,8 @@ ;; Announces the texts still to be re-measured, so a reflow wait ;; covers the render that measures them. Goes before the commit, ;; which is what triggers that render. - (if text-reflow-ids - (rx/of (ptk/data-event :text/reflow {:ids text-reflow-ids :page-id page-id})) + (if text-ids + (rx/of (ptk/data-event :text/reflow {:ids text-ids :page-id page-id})) (rx/empty)) (if (seq (:redo-changes changes)) @@ -274,8 +248,8 @@ (rx/empty)) ;; Update layouts for properties marked - (if update-layout-ids - (rx/of (ptk/data-event :layout/update {:ids update-layout-ids})) + (if layout-ids + (rx/of (ptk/data-event :layout/update {:ids layout-ids})) (rx/empty))))))))) (defn add-shape @@ -321,7 +295,7 @@ (rx/of (dwu/start-undo-transaction undo-id) ;; A new text has no geometry until the pipeline measures it, ;; 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})) (dch/commit-changes changes) (when-not no-update-layout? diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 2a905523b6..3b6856b10f 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -24,9 +24,11 @@ [app.main.data.changes :as dch] [app.main.data.event :as ev] [app.main.data.helpers :as dsh] + [app.main.data.workspace :as-alias dw] [app.main.data.workspace.common :as dwc] [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.modifiers :as dwm] + [app.main.data.workspace.pages :as-alias dwpg] [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.shapes :as dwsh] @@ -66,15 +68,19 @@ "Marks `ids` pending until the text pipeline marks its own work: `:text-measure` in the DOM renderer, `:text-resize` in wasm. Emits nothing." [ids] - (->> (rx/from ids) - ;; Each id owns its bridge. Starting work for one text must not release - ;; siblings that the renderer has not picked up yet. - (rx/mapcat - (fn [id] - (->> (wrf/pending-signal [id] #{:text-measure :text-resize}) - (rx/ignore) - (wrf/with-pending :text-bridge [id])))) - (rx/ignore))) + (wrf/bridge-pending ids #{:text-measure :text-resize} :text-bridge)) + +(defn- page-finalize? + [event] + (= ::dwpg/finalize-page (ptk/type event))) + +(defn- text-work-stopper + [stream] + (rx/filter + (fn [event] + (or (= ::dw/finalize-workspace (ptk/type event)) + (page-finalize? event))) + stream)) (defn initialize-text-reflow "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/WatchEvent (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 (rx/filter (ptk/type? :text/reflow)) (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)))))) (defn finalize-text-reflow @@ -110,28 +120,51 @@ :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 - "Marks `ids` as pending font work and dispatches their wasm resize once wasm - can measure with `font-id`, draining the marks afterwards. The fetch of that - 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] + "Waits for missing font faces, then resizes `ids`." + [stream ids] (if (empty? ids) (rx/empty) - (let [stopper (rx/filter (ptk/type? :app.main.data.workspace/finalize) stream)] - (->> wasm.fonts/font-stored-stream - (rx/filter #(= % font-id)) - (rx/take 1) - (rx/take-until stopper) - (rx/observe-on :async) - (rx/mapcat (fn [_] (rx/from (mapv dwwt/resize-wasm-text ids)))) - (wrf/with-pending :font ids))))) + (->> (rx/of ::await-fonts) + (rx/mapcat + (fn [_] + (await-font-faces stream (pending-font-faces ids) ids)))))) (defn- await-html-font "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 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)) (rx/empty) (->> (rx/of ::load-font) @@ -140,6 +173,7 @@ ;; gap before the task is visible to waiters. (rx/mapcat (fn [_] (rx/from (fonts/ensure-loaded! font-id font-variant-id)))) + (rx/take-until (text-work-stopper stream)) (rx/ignore) (wrf/with-pending :font ids)))) @@ -525,7 +559,7 @@ [id start end attrs] (ptk/reify ::update-text-range ptk/WatchEvent - (watch [_ state _] + (watch [_ state stream] (let [objects (dsh/lookup-page-objects state) shape (get objects id) @@ -547,7 +581,7 @@ (rx/map dwwt/resize-wasm-text-debounce)) (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 (rx/empty))))))) @@ -798,7 +832,7 @@ (watch [_ state stream] (wrf/start! reflow-task) (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/merge (->> stream @@ -809,7 +843,7 @@ (rx/take-until stopper)) (rx/of (resize-text id new-width new-height))) (rx/of (fn [state] - (run! wrf/finish! (::resize-text-reflow-tasks state)) + (wrf/finish-tasks! (::resize-text-reflow-tasks state)) (dissoc state ::resize-text-debounce-props ::resize-text-reflow-tasks @@ -877,7 +911,7 @@ ptk/WatchEvent (watch [_ state stream] (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/merge (->> stream @@ -925,40 +959,49 @@ ptk/WatchEvent (watch [_ state _] (let [position-data (::update-position-data state)] - (rx/concat - (rx/of (dwsh/update-shapes - (keys position-data) - (fn [shape] - (-> shape - (assoc :position-data (get position-data (:id shape))))) - {:stack-undo? true :reg-objects? false})) - (rx/of (fn [state] - (dissoc state ::update-position-data-debounce ::update-position-data)))))))) + (rx/of (dwsh/update-shapes + (keys position-data) + (fn [shape] + (-> shape + (assoc :position-data (get position-data (:id shape))))) + {:stack-undo? true :reg-objects? false})))))) (defn update-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/UpdateEvent (update [_ state] (let [state (assoc-in state [:workspace-text-modifier id :position-data] position-data)] - (if (nil? (::update-position-data-debounce state)) - (assoc state ::update-position-data-debounce cur-event) - (assoc-in state [::update-position-data id] position-data)))) + (-> state + (update ::update-position-data-reflow-tasks (fnil conj []) reflow-task) + (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 (watch [_ state stream] + (wrf/start! reflow-task) (if (= (::update-position-data-debounce state) cur-event) - (let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))] - (rx/merge - (->> stream - (rx/filter (ptk/type? ::update-position-data)) - (rx/debounce 50) - (rx/take 1) - (rx/map #(commit-position-data)) - (rx/take-until stopper)) - (rx/of (update-position-data id position-data)))) + (let [stopper (text-work-stopper stream)] + (rx/concat + (rx/merge + (->> stream + (rx/filter (ptk/type? ::update-position-data)) + (rx/debounce 50) + (rx/take 1) + (rx/map #(commit-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)))))) (defn update-attrs @@ -1009,7 +1052,7 @@ (let [auto-ids (into [] (remove #(= :fixed (:grow-type (get objects %)))) text-ids)] (if (contains? attrs :font-id) ;; 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. (->> (rx/from auto-ids) (rx/map dwwt/resize-wasm-text))))) @@ -1018,6 +1061,7 @@ ;; but font loading starts before that render commits. (if (contains? attrs :font-id) (await-html-font + stream (:font-id attrs) (:font-variant-id attrs) text-ids) diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs index eba1fdb8f6..bb98793dc3 100644 --- a/frontend/src/app/main/data/workspace/wasm_text.cljs +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -16,6 +16,7 @@ [app.common.geom.point :as gpt] [app.common.types.modifiers :as ctm] [app.main.data.helpers :as dsh] + [app.main.data.workspace :as-alias dw] [app.main.data.workspace.modifiers :as dwm] [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.shapes :as dwsh] @@ -159,7 +160,7 @@ (watch [_ state stream] (wrf/start! reflow-task) (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/merge (->> stream @@ -179,7 +180,7 @@ ;; pending until the resize is applied. All exact tasks in the ;; batch are retained in state and finished by the cleanup. (rx/of (fn [state] - (run! wrf/finish! (::resize-wasm-text-reflow-tasks state)) + (wrf/finish-tasks! (::resize-wasm-text-reflow-tasks state)) (dissoc state ::resize-wasm-text-debounce-ids ::resize-wasm-text-reflow-tasks @@ -198,15 +199,15 @@ content (dm/get-in objects [id :content]) fonts (wasm.fonts/get-content-fonts content) - fonts-loaded? + fonts-ready? (->> fonts (every? (fn [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 - (if fonts-loaded? + (if fonts-ready? (let [pass-opts (when (or (some? undo-group) (some? undo-id)) (cond-> {} (some? undo-group) (assoc :undo-group undo-group) @@ -232,15 +233,32 @@ (watch [_ state stream] (let [resize-stream (->> (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 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, ;; since the per-shape debounce only marks them once dispatched. (wrf/with-pending :text-resize ids - (->> stream - (rx/filter (ptk/type? ::dwsh/update-shapes-buffer-commit)) - (rx/take 1) - (rx/mapcat (constantly resize-stream)))) + (->> buffer-finished-stream + (rx/mapcat + (fn [reason] + (if (= reason :finalize) + (rx/empty) + resize-stream))))) resize-stream)))))) diff --git a/frontend/src/app/main/fonts.cljs b/frontend/src/app/main/fonts.cljs index 677f8aa1fb..15fa05534b 100644 --- a/frontend/src/app/main/fonts.cljs +++ b/frontend/src/app/main/fonts.cljs @@ -18,6 +18,7 @@ [app.util.globals :as globals] [app.util.http :as http] [app.util.object :as obj] + [app.util.timers :as tm] [beicon.v2.core :as rx] [cuerdas.core :as str] [okulary.core :as l] @@ -243,8 +244,10 @@ (defmulti ^:private load-font :backend) (defmethod load-font :default - [{:keys [backend] :as font}] - (log/wrn :msg "no implementation found for" :backend backend)) + [{:keys [backend ::on-failed] :as font}] + (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 [{:keys [id ::on-loaded] :as font}] @@ -269,23 +272,30 @@ (let [base (u/join cf/public-uri "internal/gfonts/font")] (str/replace css "https://fonts.gstatic.com/s" (dm/str base)))) -(defn- fetch-gfont-css +(defn- request-gfont-css [url] (->> (http/send! {:method :get :uri url :mode :cors :response-type :text}) - (rx/map :body) - (rx/catch (fn [err] - (log/wrn :hint "cannot find the font" :cause err) + (rx/map :body))) + +(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))))) (defmethod load-font :google - [{:keys [id ::on-loaded] :as font}] + [{:keys [id ::on-loaded ::on-failed] :as font}] (when (globals/browser?) (log/dbg :hint "load-font" :font-id id :backend "google") (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/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))) ;; --- LOADER: CUSTOM @@ -358,15 +368,30 @@ ;; First caller, we create the promise and then wait :else - (let [on-load (fn [resolve] - (swap! loaded conj font-id) - (swap! loading dissoc font-id) - (resolve font-id)) + (let [settle! (fn [resolve loaded?] + ;; Defer cleanup until a synchronous load is cached. + (tm/schedule + #(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 (fn [resolve _] (-> font (assoc ::on-loaded (partial on-load resolve)) + (assoc ::on-failed (partial on-failed resolve)) (load-font)))) ;; We need to wait for the font to be loaded (p/then (partial p/delay 120)))] diff --git a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs index 77d659c17b..634392e97a 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs @@ -12,6 +12,7 @@ [app.common.geom.point :as gpt] [app.common.geom.shapes :as gsh] [app.common.geom.shapes.text :as gsht] + [app.common.logging :as log] [app.common.math :as mth] [app.common.types.modifiers :as ctm] [app.common.types.text :as txt] @@ -96,9 +97,12 @@ (st/emit! (dwt/resize-text id width height))))) (st/emit! (dwt/clean-text-modifier id)))) - ;; Swallowed so a text whose position data cannot be computed still - ;; settles and still reports its measurement as finished. - (p/catch (fn [_] nil)))) + ;; Always clear the task and log measurement errors. + (p/catch (fn [cause] + (log/error :hint "Could not measure text shape" + :shape-id id + :cause cause) + nil)))) (defn- update-text-modifier [{:keys [grow-type id] :as shape} node] diff --git a/frontend/src/app/plugins/api.cljs b/frontend/src/app/plugins/api.cljs index bea36ad027..6582b76e62 100644 --- a/frontend/src/app/plugins/api.cljs +++ b/frontend/src/app/plugins/api.cljs @@ -28,7 +28,6 @@ [app.main.data.workspace.groups :as dwg] [app.main.data.workspace.media :as dwm] [app.main.data.workspace.pages :as dwpg] - [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.variants :as dwv] [app.main.data.workspace.wasm-text :as dwwt] @@ -47,6 +46,7 @@ [app.plugins.local-storage :as local-storage] [app.plugins.page :as page] [app.plugins.parser :as parser] + [app.plugins.reflow :as wrfp] [app.plugins.shape :as shape] [app.plugins.system-events :as se] [app.plugins.user :as user] @@ -416,7 +416,10 @@ (cb/with-objects (:objects page)) (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)) (when (features/active-feature? @st/state "render-wasm/v1") @@ -734,10 +737,5 @@ :waitForLayoutUpdate (fn [timeout] - ;; Always a promise, so a bad argument travels as a rejection. - (if (u/valid-timeout? 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))))))) + ;; Resolves once every shape with reflow work in flight has settled. + (wrfp/wait-for-layout-update timeout)))) diff --git a/frontend/src/app/plugins/reflow.cljs b/frontend/src/app/plugins/reflow.cljs new file mode 100644 index 0000000000..306fff667b --- /dev/null +++ b/frontend/src/app/plugins/reflow.cljs @@ -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))))))) diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index 88177b39ab..205f7d0d97 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -43,7 +43,6 @@ [app.main.data.workspace.guides :as dwgu] [app.main.data.workspace.interactions :as dwi] [app.main.data.workspace.libraries :as dwl] - [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.shape-layout :as dwsl] [app.main.data.workspace.shapes :as dwsh] @@ -58,6 +57,7 @@ [app.plugins.format :as format] [app.plugins.grid :as grid] [app.plugins.parser :as parser] + [app.plugins.reflow :as wrfp] [app.plugins.register :as r] [app.plugins.ruler-guides :as rg] [app.plugins.shadows :as shadows] @@ -1057,15 +1057,11 @@ :waitForLayoutUpdate (fn [timeout] - ;; Always a promise, so a bad argument travels as a rejection. - (if (u/valid-timeout? timeout) - ;; Resolves once the reflow work of this shape's subtree has - ;; settled: it can be marked on the shape or on its descendants. - (let [objects (u/locate-objects file-id page-id)] - (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))))) + ;; Wait for layout work that can affect this shape. + (let [objects (u/locate-objects file-id page-id)] + (wrfp/wait-for-layout-update + (wrfp/shape-wait-ids objects file-id id) + timeout))) ;; Plugin data :getPluginData diff --git a/frontend/src/app/plugins/text.cljs b/frontend/src/app/plugins/text.cljs index 3692ae1a59..f8e32458de 100644 --- a/frontend/src/app/plugins/text.cljs +++ b/frontend/src/app/plugins/text.cljs @@ -499,10 +499,10 @@ (u/not-valid plugin-id :growType "Cannot modify a page that is not currently active") :else - (st/emit! - (dwsh/update-shapes [id] #(assoc % :grow-type value)) - (when (features/active-feature? @st/state "render-wasm/v1") - (st/emit! (dwwt/resize-wasm-text-debounce id)))))))} + (do + (st/emit! (dwsh/update-shapes [id] #(assoc % :grow-type value))) + (when (features/active-feature? @st/state "render-wasm/v1") + (st/emit! (dwwt/resize-wasm-text-debounce id)))))))} {:name "fontId" :get #(-> % u/proxy->shape text-props :font-id format/format-mixed) diff --git a/frontend/src/app/plugins/utils.cljs b/frontend/src/app/plugins/utils.cljs index 21afd5cdde..49622d9710 100644 --- a/frontend/src/app/plugins/utils.cljs +++ b/frontend/src/app/plugins/utils.cljs @@ -291,14 +291,6 @@ (throw-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 [reject code value] (let [msg (dm/str "[PENPOT PLUGIN] Value not valid: " value ". Code: " code)] diff --git a/frontend/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs index c3d5a32a35..eef60480fe 100644 --- a/frontend/src/app/render_wasm/api/fonts.cljs +++ b/frontend/src/app/render_wasm/api/fonts.cljs @@ -30,11 +30,30 @@ (def ^:private custom-fonts (l/derived :fonts st/state)) -;; Emits the font-id of every font whose glyphs wasm can already shape and -;; measure with. The browser-side loading of `app.main.fonts` is a separate -;; signal: it only says the DOM can render the font. +;; Emits every font face that WASM can measure. (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-line-height 1.2) (def ^:private default-letter-spacing 0.0) @@ -157,39 +176,91 @@ (:style font-data) emoji? fallback?) + (clear-font-storage-failure! font-data) ;; 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))) -;; Tracks fonts currently being fetched: {url -> fallback?} -;; 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. +;; Tracks every font face waiting on each shared request. (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 [font-data font-url emoji? fallback?] - (if (contains? @fetching font-url) - (do (when fallback? (swap! fetching assoc font-url true)) - nil) + (cond + (nil? font-url) + ;; Fail missing font assets without sharing a nil request. (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 :callback (fn [] - (->> (http/send! {:method :get - :uri font-url - :response-type :buffer}) - (rx/map (fn [{:keys [body]}] - (let [fallback? (get @fetching font-url fallback?)] - (swap! fetching dissoc font-url) - (store-font-buffer font-data body emoji? fallback?)))) - (rx/catch (fn [cause] - (swap! fetching dissoc font-url) - (log/error :hint "Could not fetch font" - :font-url font-url - :cause cause) - (rx/empty)))))}))) + (try + (->> (http/send! {:method :get + :uri font-url + :response-type :buffer}) + (rx/map + (fn [{:keys [body]}] + (let [requests (take-font-fetches! font-url)] + (mapv (partial store-font-fetch! body) requests)))) + (rx/catch + (fn [cause] + (fail-font-fetches! font-url cause) + (rx/empty)))) + (catch :default cause + (fail-font-fetches! font-url cause) + (rx/empty))))}))) (defn- google-font-ttf-url [font-id font-variant-id font-weight font-style] @@ -220,9 +291,15 @@ (:style font-data) 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 [font-data asset-id emoji? fallback?] - (when asset-id + (if asset-id (let [uri (font-id->ttf-url (:font-id font-data) asset-id (:font-variant-id font-data) @@ -234,8 +311,16 @@ (if font-stored? ;; Deferred so consumers, which subscribe after dispatching the sync ;; that lands here, are listening when an already-stored font reports. - (tm/schedule #(rx/push! font-stored-stream (:font-id font-data))) - (fetch-font font-data uri emoji? fallback?))))) + (do + (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 [font-style] diff --git a/frontend/test/frontend_tests/data/workspace_reflow_test.cljs b/frontend/test/frontend_tests/data/workspace_reflow_test.cljs index a1a50bf985..349494c5da 100644 --- a/frontend/test/frontend_tests/data/workspace_reflow_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_reflow_test.cljs @@ -6,12 +6,22 @@ (ns frontend-tests.data.workspace-reflow-test "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 [app.common.uuid :as uuid] [app.main.data.workspace.reflow :as wrf] [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.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] [potok.v2.core :as ptk])) @@ -43,7 +53,7 @@ (t/async done (let [store (start-pipeline!)] (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")) (.catch #(t/is false "a root-only update was marked as pending work")) (.then (fn [] @@ -59,16 +69,41 @@ _ (wrf/reset-pending!) current-task (wrf/start! :text-measure [id])] (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")) (.catch #(t/is true "current work stayed pending")) (.then (fn [] (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")) (.catch #(t/is false "the current task did not drain")) (.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 ;; Imperative render work is pending from before its thunk starts until the ;; exact promise returned by that thunk settles; no timer is involved. @@ -82,12 +117,12 @@ (fn [] (reset! started? true) (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")) (.catch #(t/is @started? "the task was opened before running the operation")) (.then (fn [] (@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")) (.catch #(t/is false "the settled render operation stayed pending")) (.then (fn [] (done))))))) @@ -98,7 +133,7 @@ (wrf/run-pending! :text-measure [id] #(throw (js/Error. "boom"))) (catch :default _)) (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")) (.catch #(t/is false "a synchronous failure leaked pending work")) (.then (fn [] (done))))))) @@ -110,10 +145,10 @@ task-a (wrf/start! :text-bridge [id-a]) task-b (wrf/start! :text-bridge [id-b])] (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")) (.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")) (.catch #(t/is true "sibling work stayed pending")) (.then (fn [] @@ -130,28 +165,281 @@ (ptk/emit! store (ptk/data-event :text/reflow {:ids [id-a id-b]})) (let [task-a (wrf/start! :text-measure [id-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")) (.catch #(t/is true "the sibling bridge stayed pending")) (.then (fn [] (let [task-b (wrf/start! :text-measure [id-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")) (.catch #(t/is false "the sibling never drained")) (.then (fn [] (stop-text-pipeline! store) (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 ;; A shape id is marked on arrival and drained when the update is processed. (t/async done (let [store (start-pipeline!)] (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")) (.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")) (.catch #(t/is false "the pipeline never drained its mark")) (.then (fn [] diff --git a/frontend/test/frontend_tests/plugins/context_shapes_test.cljs b/frontend/test/frontend_tests/plugins/context_shapes_test.cljs index c417b43dab..e6c5d2d629 100644 --- a/frontend/test/frontend_tests/plugins/context_shapes_test.cljs +++ b/frontend/test/frontend_tests/plugins/context_shapes_test.cljs @@ -11,9 +11,11 @@ [app.common.uuid :as uuid] [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.shapes :as dwsh] + [app.main.data.workspace.texts :as dwtxt] [app.main.data.workspace.wasm-text :as dwwt] [app.main.store :as st] [app.plugins.api :as api] + [app.plugins.reflow :as pwrf] [app.plugins.shape :as shape] [app.util.object :as obj] [beicon.v2.core :as rx] @@ -445,6 +447,24 @@ (set! st/stream (ptk/input-stream 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 ;; When nothing is pending the promise resolves immediately via the fast path ;; (the behavior-subject replays the empty map on subscribe). @@ -459,6 +479,209 @@ (t/is false (str "unexpected rejection: " err)) (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 ;; While a shape is pending the context promise stays unresolved; it resolves ;; once that shape is marked done. @@ -584,6 +807,54 @@ 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 ;; 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 @@ -603,6 +874,7 @@ (rejected? (.waitForLayoutUpdate ctx -5)) (rejected? (.waitForLayoutUpdate ctx js/NaN)) (rejected? (.waitForLayoutUpdate ctx js/Infinity)) + (rejected? (.waitForLayoutUpdate ctx 2147483648)) (rejected? (.waitForLayoutUpdate shape "soon"))]) (.then (fn [results] (t/is (every? true? (array-seq results)) @@ -661,7 +933,7 @@ resolved (atom false)] (ptk/emit! store (dwsh/update-shapes-buffer-start)) (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))) (.catch (fn [err] (t/is false (str "unexpected rejection: " err))))) diff --git a/plugins/apps/plugin-api-test-suite/src/tests/wait-layout.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/wait-layout.test.ts index 79c0fb330d..c2b67a4d5b 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/wait-layout.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/wait-layout.test.ts @@ -1,6 +1,13 @@ import { expect, expectReject } from '../framework/expect'; 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'; // 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 }; } -/** Font ids already handed out by `unloadedFont`. */ -const claimedFonts = new Set(); +/** Tracks fonts used by this test run. */ +const claimedFontsByRun = new WeakMap>(); + +function claimedFonts(ctx: TestContext): Set { + let claimed = claimedFontsByRun.get(ctx.penpot); + if (!claimed) { + claimed = new Set(); + claimedFontsByRun.set(ctx.penpot, claimed); + } + return claimed; +} /** * Picks an unclaimed font differing from the text's current one, so assigning @@ -53,11 +69,12 @@ const claimedFonts = new Set(); */ function unloadedFont(ctx: TestContext, t: Text): Font { const all = ctx.penpot.fonts.all; + const claimed = claimedFonts(ctx); for (let i = all.length - 1; i >= 0; i--) { const f = all[i]; if (f.fontId === t.fontId || f.variants.length === 0) continue; - if (claimedFonts.has(f.fontId)) continue; - claimedFonts.add(f.fontId); + if (claimed.has(f.fontId)) continue; + claimed.add(f.fontId); return f; } 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 // 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, diff --git a/plugins/apps/plugin-api-test-suite/src/ui.css b/plugins/apps/plugin-api-test-suite/src/ui.css index 104ac64c90..6e9913f1e4 100644 --- a/plugins/apps/plugin-api-test-suite/src/ui.css +++ b/plugins/apps/plugin-api-test-suite/src/ui.css @@ -60,15 +60,41 @@ body { background-color: var(--background-secondary); } -.group-summary { +.group-header { display: flex; align-items: center; gap: 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; 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 { color: var(--foreground-primary); } @@ -144,6 +170,11 @@ body { padding: 0; } +/* Keeps hidden test lists out of the layout. */ +.test-list[hidden] { + display: none; +} + .test-row { display: grid; grid-template-columns: 1fr auto auto; diff --git a/plugins/apps/plugin-api-test-suite/src/ui.ts b/plugins/apps/plugin-api-test-suite/src/ui.ts index 4aacfd1a8e..fe5fc8a7cf 100644 --- a/plugins/apps/plugin-api-test-suite/src/ui.ts +++ b/plugins/apps/plugin-api-test-suite/src/ui.ts @@ -128,6 +128,13 @@ function reloadIcon(): SVGSVGElement { 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() { root.replaceChildren( renderHeader(), @@ -273,9 +280,12 @@ function renderRow(test: TestMeta): HTMLElement { return row; } -function renderGroupSummary( +/** Builds a group header with separate select, toggle, and run controls. */ +function renderGroupHeader( name: string, groupTestList: TestMeta[], + panelId: string, + expanded: boolean, ): HTMLElement { const statuses = groupTestList.map( (t) => results.get(t.id)?.status ?? 'pending', @@ -291,12 +301,12 @@ function renderGroupSummary( const groupCheckbox = el('input', { type: 'checkbox', className: 'checkbox-input', + title: `Select every test in "${name}"`, + ariaLabel: `Select every test in "${name}"`, checked: selectedCount === total && total > 0, disabled: running, }); groupCheckbox.indeterminate = selectedCount > 0 && selectedCount < total; - // Keep the checkbox from toggling the
when clicked. - groupCheckbox.addEventListener('click', (e) => e.stopPropagation()); groupCheckbox.addEventListener('change', () => { if (groupCheckbox.checked) ids.forEach((id) => selected.add(id)); else ids.forEach((id) => selected.delete(id)); @@ -305,17 +315,14 @@ function renderGroupSummary( const runButton = el('button', { className: 'icon-button run-group', + type: 'button', title: `Run "${name}"`, ariaLabel: `Run "${name}"`, disabled: running, }); runButton.dataset.appearance = 'secondary'; runButton.append(playIcon()); - runButton.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - run(ids); - }); + runButton.addEventListener('click', () => run(ids)); const counts = el('span', { className: 'group-counts' }, [ el('span', { className: 'count-pass', textContent: `${passed}` }), @@ -327,14 +334,26 @@ function renderGroupSummary( }), ]); - return el('summary', { className: 'group-summary' }, [ - groupCheckbox, + const toggle = el('button', { className: 'group-toggle', type: 'button' }, [ + chevronIcon(), el('span', { className: `status-dot dot-${aggregate}`, title: statusLabel(aggregate), }), el('span', { className: 'group-name', textContent: name }), 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, ]); } @@ -342,25 +361,24 @@ function renderGroupSummary( function renderList(): HTMLElement { const container = el('div', { className: 'groups' }); - for (const group of groupTests()) { - const details = el('details', { className: 'group' }); + groupTests().forEach((group, index) => { // Groups are collapsed by default; remember the ones the user expands. - details.open = expandedGroups.has(group.name); - details.addEventListener('toggle', () => { - if (details.open) expandedGroups.add(group.name); - else expandedGroups.delete(group.name); - }); + const expanded = expandedGroups.has(group.name); + const panelId = `group-panel-${index}`; - details.append(renderGroupSummary(group.name, group.tests)); - - const list = el('ul', { className: 'test-list' }); + const list = el('ul', { className: 'test-list', id: panelId }); + list.hidden = !expanded; for (const test of group.tests) { 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; } diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index 483cfecca8..abb4e981c1 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -1353,12 +1353,13 @@ export interface Context { /** * This method returns a promise that will be resolved when all the - * pending layout updates have finished. If no layout work is pending - * the promise resolves immediately. + * pending layout updates have finished and the components have synchronized. + * If no layout work is pending the promise resolves immediately. * @param timeout Maximum time to wait, in milliseconds. If the timeout * elapses before the layout settles, the promise is rejected. Defaults to * 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; } @@ -4109,13 +4110,14 @@ export interface ShapeBase extends PluginData { remove(): void; /** - * This method returns a promise that will be resolved when the pending - * layout updates for this shape and its children have finished. If no layout - * work is pending for them the promise resolves immediately. + * This method returns a promise that will be resolved when all the + * pending layout updates have finished and the components have synchronized. + * If no layout work is pending the promise resolves immediately. * @param timeout Maximum time to wait, in milliseconds. If the timeout * elapses before the shape's layout settles, the promise is rejected. * 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; }