mirror of
https://github.com/penpot/penpot.git
synced 2026-07-29 01:16:14 +00:00
✨ Add waitForLayoutUpdate plugin method (#9898)
* ✨ Add waitForLayoutUpdate plugin method * ✨ Refactor internal wait for tasks
This commit is contained in:
parent
0999bc31b2
commit
cb21f6401a
@ -60,6 +60,7 @@
|
||||
[app.main.data.workspace.selection :as dws]
|
||||
[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.thumbnails :as dwth]
|
||||
[app.main.data.workspace.transforms :as dwt]
|
||||
[app.main.data.workspace.undo :as dwu]
|
||||
@ -402,6 +403,7 @@
|
||||
(rx/of (dpj/initialize-project (:project-id file))
|
||||
(dwn/initialize team-id file-id)
|
||||
(dwsl/initialize-shape-layout)
|
||||
(dwtxt/initialize-text-reflow)
|
||||
(fetch-libraries file-id features)
|
||||
(-> (workspace-initialized file-id)
|
||||
(with-meta {:team-id team-id
|
||||
@ -553,6 +555,7 @@
|
||||
(rx/of (dwn/finalize file-id)
|
||||
(dpj/finalize-project project-id)
|
||||
(dwsl/finalize-shape-layout)
|
||||
(dwtxt/finalize-text-reflow)
|
||||
(dwcl/stop-picker)
|
||||
(dwc/set-workspace-visited)
|
||||
(modal/hide)
|
||||
|
||||
@ -770,7 +770,7 @@
|
||||
subtree-ids-by-id]
|
||||
:or {ignore-constraints false ignore-snap-pixel false snap-ignore-axis nil undo-transation? true}
|
||||
:as params}]
|
||||
(ptk/reify ::apply-wasm-modifiesr
|
||||
(ptk/reify ::apply-wasm-modifiers
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [translation?
|
||||
|
||||
194
frontend/src/app/main/data/workspace/reflow.cljs
Normal file
194
frontend/src/app/main/data/workspace/reflow.cljs
Normal file
@ -0,0 +1,194 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.main.data.workspace.reflow
|
||||
"Tracks the shape ids that have layout/reflow work in flight, broken down by
|
||||
the kind of work so we can tell which type of reflow is blocking each shape.
|
||||
|
||||
Pending work is stored as `{shape-id -> {kind -> #{task-id}}}`. 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.
|
||||
|
||||
Observable producers should use `with-pending`; imperative renderer work
|
||||
should use `run-pending!`. Direct task lifecycle calls are reserved for Potok
|
||||
pipelines whose operation has to cross event or batching boundaries.
|
||||
|
||||
Kinds correspond to the pipelines that schedule the work:
|
||||
:layout flex/grid layout reflow (shape-layout)
|
||||
:text-resize text geometry resize (wasm-text, texts)
|
||||
:text-measure DOM text measurement (texts)
|
||||
:text-bridge change awaiting its pipeline (texts)
|
||||
:font font change measurement (texts)"
|
||||
(:require
|
||||
[beicon.v2.core :as rx]
|
||||
[promesa.core :as p]))
|
||||
|
||||
;; Feeder subject receiving task lifecycle messages, scanned into the
|
||||
;; `pending-shapes` map.
|
||||
(defonce ^:private reflow-input (rx/subject))
|
||||
|
||||
(defonce ^:private workspace-generation (atom 0))
|
||||
(defonce ^:private next-task-id (atom 0))
|
||||
|
||||
(defn- add-task
|
||||
[acc {:keys [id kind ids]}]
|
||||
(reduce (fn [m shape-id]
|
||||
(update-in m [shape-id kind] (fnil conj #{}) id))
|
||||
acc
|
||||
ids))
|
||||
|
||||
(defn- remove-task
|
||||
[acc {:keys [id kind ids]}]
|
||||
(let [task-id id]
|
||||
(reduce (fn [m shape-id]
|
||||
(let [tasks (disj (get-in m [shape-id kind] #{}) task-id)
|
||||
kinds (if (seq tasks)
|
||||
(assoc (get m shape-id) kind tasks)
|
||||
(dissoc (get m shape-id) kind))]
|
||||
(if (seq kinds)
|
||||
(assoc m shape-id kinds)
|
||||
(dissoc m shape-id))))
|
||||
acc
|
||||
ids)))
|
||||
|
||||
(defn- reducer
|
||||
[acc {:keys [op task ids]}]
|
||||
(case op
|
||||
:add (add-task acc task)
|
||||
:remove (remove-task acc task)
|
||||
: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))
|
||||
|
||||
(defn task
|
||||
"Creates an opaque task token without opening it."
|
||||
[kind ids]
|
||||
{:id (swap! next-task-id inc)
|
||||
:generation @workspace-generation
|
||||
:kind kind
|
||||
:ids (into #{} ids)})
|
||||
|
||||
(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}))
|
||||
task)
|
||||
([kind ids]
|
||||
(start! (task kind ids))))
|
||||
|
||||
(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})))
|
||||
|
||||
(defn reset-pending!
|
||||
"Starts a new workspace generation and forgets every task from the old one."
|
||||
[]
|
||||
(swap! workspace-generation inc)
|
||||
(rx/push! reflow-input {:op :reset}))
|
||||
|
||||
(defn cancel-shapes!
|
||||
"Forgets pending work attached to shapes that no longer exist."
|
||||
[ids]
|
||||
(when (seq ids)
|
||||
(rx/push! reflow-input {:op :cancel :ids ids})))
|
||||
|
||||
(defn with-pending
|
||||
"Runs observable `ob` as tracked layout work. Prefer this entry point for
|
||||
observable producers: it owns task activation and finalization, including
|
||||
errors and unsubscription. An `ob` that is built but never subscribed marks
|
||||
nothing."
|
||||
[kind ids ob]
|
||||
(->> (rx/of ::subscribe)
|
||||
(rx/mapcat (fn [_]
|
||||
(let [task (start! kind ids)]
|
||||
(rx/finalize #(finish! task) ob))))))
|
||||
|
||||
(defn run-pending!
|
||||
"Runs zero-argument promise operation `f` as tracked layout work. The task is
|
||||
opened before `f` runs and is finalized when its returned promise settles.
|
||||
Synchronous failures also finalize the task before being rethrown.
|
||||
|
||||
Prefer this entry point for imperative or renderer-driven producers."
|
||||
[kind ids f]
|
||||
(let [task (start! kind ids)]
|
||||
(try
|
||||
(p/finally (f) #(finish! task))
|
||||
(catch :default cause
|
||||
(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))
|
||||
|
||||
(any-pending? [pending]
|
||||
(some (partial id-pending? pending) ids))]
|
||||
(->> pending-shapes
|
||||
(rx/filter any-pending?)
|
||||
(rx/take 1))))
|
||||
|
||||
;; 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)
|
||||
|
||||
(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`.
|
||||
|
||||
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)))))))
|
||||
@ -26,6 +26,7 @@
|
||||
[app.main.data.workspace.colors :as cl]
|
||||
[app.main.data.workspace.grid-layout.editor :as dwge]
|
||||
[app.main.data.workspace.modifiers :as dwm]
|
||||
[app.main.data.workspace.reflow :as wrf]
|
||||
[app.main.data.workspace.selection :as dwse]
|
||||
[app.main.data.workspace.shapes :as dwsh]
|
||||
[app.main.data.workspace.undo :as dwu]
|
||||
@ -98,26 +99,50 @@
|
||||
;; Never call this directly but through the data-event `:layout/update`
|
||||
;; Otherwise a lot of cycle dependencies could be generated
|
||||
(defn- update-layout-positions
|
||||
[{:keys [page-id ids undo-group]}]
|
||||
[{:keys [page-id ids undo-group reflow-tasks]}]
|
||||
(ptk/reify ::update-layout-positions
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(watch [_ state stream]
|
||||
(let [page-id (or page-id (:current-page-id state))
|
||||
objects (dsh/lookup-page-objects state page-id)
|
||||
ids (->> ids (remove uuid/zero?) (filter #(contains? objects %)))]
|
||||
(if (d/not-empty? ids)
|
||||
(let [modif-tree (dwm/create-modif-tree ids (ctm/reflow-modifiers))]
|
||||
(if (features/active-feature? state "render-wasm/v1")
|
||||
(rx/of (dwm/apply-wasm-modifiers modif-tree
|
||||
ids (->> ids (remove uuid/zero?) (filter #(contains? objects %)))
|
||||
|
||||
update-positions-stream
|
||||
(if (d/not-empty? ids)
|
||||
(let [modif-tree (dwm/create-modif-tree ids (ctm/reflow-modifiers))]
|
||||
(if (features/active-feature? state "render-wasm/v1")
|
||||
(rx/of (dwm/apply-wasm-modifiers modif-tree
|
||||
:stack-undo? true
|
||||
:undo-group undo-group
|
||||
:ignore-touched true))
|
||||
(rx/of (dwm/apply-modifiers {:page-id page-id
|
||||
:modifiers modif-tree
|
||||
:stack-undo? true
|
||||
:undo-group undo-group
|
||||
:ignore-touched true))
|
||||
(rx/of (dwm/apply-modifiers {:page-id page-id
|
||||
:modifiers modif-tree
|
||||
:stack-undo? true
|
||||
:ignore-touched true
|
||||
:undo-group undo-group}))))
|
||||
(rx/empty))))))
|
||||
:ignore-touched true
|
||||
:undo-group undo-group}))))
|
||||
(rx/empty))
|
||||
|
||||
;; The apply events above are dispatched and applied synchronously,
|
||||
;; except while a shape-update buffer is open (token propagation),
|
||||
;; where the commit lands on `update-shapes-buffer-commit`.
|
||||
drain-stream
|
||||
(if (and (::dwsh/update-shapes-buffer state)
|
||||
(d/not-empty? ids))
|
||||
(->> 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))
|
||||
;; 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)))))))
|
||||
|
||||
(defn- without-root-board
|
||||
[ids]
|
||||
(into [] (remove uuid/zero?) ids))
|
||||
|
||||
(defn initialize-shape-layout
|
||||
[]
|
||||
@ -126,21 +151,27 @@
|
||||
(watch [_ _ stream]
|
||||
(let [stopper (rx/filter (ptk/type? ::finalize-shape-layout) stream)]
|
||||
(->> stream
|
||||
;; FIXME: we don't need use types for simple signaling,
|
||||
;; we can just use a keyword for it
|
||||
(rx/filter (ptk/type? :layout/update))
|
||||
(rx/map deref)
|
||||
;; We buffer the updates to the layout so if there are many changes at the same time
|
||||
;; they are process together. It will get a better performance.
|
||||
;; Keeps each update's page and the shapes that can be laid out;
|
||||
;; the root lays out nothing, so an update with only it is dropped.
|
||||
(rx/map (fn [event]
|
||||
(-> (deref event)
|
||||
(update :ids without-root-board))))
|
||||
(rx/filter #(d/not-empty? (:ids %)))
|
||||
(rx/map #(assoc % ::reflow-task (wrf/start! :layout (:ids %))))
|
||||
(rx/buffer-time 100)
|
||||
(rx/filter #(d/not-empty? %))
|
||||
(rx/mapcat
|
||||
(fn [data]
|
||||
(->> (group-by :page-id data)
|
||||
(map (fn [[page-id items]]
|
||||
(let [ids (reduce #(into %1 (:ids %2)) #{} items)]
|
||||
(update-layout-positions {:page-id page-id :ids ids})))))))
|
||||
(rx/take-until stopper))))))
|
||||
(fn [updates]
|
||||
(->> (group-by :page-id updates)
|
||||
(map (fn [[page-id updates]]
|
||||
(let [ids (into #{} (mapcat :ids) updates)]
|
||||
(update-layout-positions {:page-id page-id
|
||||
:ids ids
|
||||
:reflow-tasks (mapv ::reflow-task updates)})))))))
|
||||
(rx/take-until stopper)
|
||||
;; On workspace teardown clear everything still pending.
|
||||
(rx/finalize wrf/reset-pending!))))))
|
||||
|
||||
(defn finalize-shape-layout
|
||||
[]
|
||||
|
||||
@ -23,8 +23,10 @@
|
||||
[app.main.data.helpers :as dsh]
|
||||
[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.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]))
|
||||
|
||||
@ -32,6 +34,24 @@
|
||||
;; `(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)
|
||||
@ -177,25 +197,48 @@
|
||||
objects (dsh/lookup-page-objects state page-id)
|
||||
ids (into [] (filter some?) ids)
|
||||
|
||||
xf-update-layout
|
||||
;; 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))
|
||||
(filter #(some update-layout-attr? (pcb/changed-attrs % objects update-fn {:attrs attrs :with-objects? with-objects?})))
|
||||
(map :id))
|
||||
(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 layout attr is present
|
||||
;; filters its result to that set, so if no reflow attr is present
|
||||
;; the check is always empty.
|
||||
update-layout-ids
|
||||
reflow-changes
|
||||
(when-not (or translation?
|
||||
(not update-layout?)
|
||||
(and (some? attrs)
|
||||
(not (some update-layout-attr? attrs))))
|
||||
(->> (into [] xf-update-layout ids)
|
||||
(not-empty)))
|
||||
(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)
|
||||
@ -218,6 +261,13 @@
|
||||
(add-undo-group changes state)]
|
||||
|
||||
(rx/concat
|
||||
;; 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}))
|
||||
(rx/empty))
|
||||
|
||||
(if (seq (:redo-changes changes))
|
||||
(let [changes (cond-> changes reg-objects? (pcb/resize-parents ids))]
|
||||
(rx/of (dch/commit-changes changes)))
|
||||
@ -269,6 +319,10 @@
|
||||
|
||||
(rx/concat
|
||||
(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)
|
||||
(ptk/data-event :text/reflow {:ids [(:id shape)] :page-id page-id}))
|
||||
(dch/commit-changes changes)
|
||||
(when-not no-update-layout?
|
||||
(ptk/data-event :layout/update {:ids [(:parent-id shape)]}))
|
||||
@ -327,6 +381,7 @@
|
||||
fdata (dsh/lookup-file-data state file-id)
|
||||
page (dsh/get-page fdata page-id)
|
||||
objects (:objects page)
|
||||
deleted-ids (into #{} (mapcat #(cfh/get-children-ids-with-self objects %)) ids)
|
||||
|
||||
undo-id (or (:undo-id options) (js/Symbol))
|
||||
[all-parents changes]
|
||||
@ -336,6 +391,7 @@
|
||||
:undo-group (:undo-group options)
|
||||
:undo-id undo-id}))]
|
||||
|
||||
(wrf/cancel-shapes! deleted-ids)
|
||||
(rx/of (dwu/start-undo-transaction undo-id)
|
||||
(dc/detach-comment-thread ids)
|
||||
(dch/commit-changes changes)
|
||||
|
||||
@ -27,6 +27,7 @@
|
||||
[app.main.data.workspace.common :as dwc]
|
||||
[app.main.data.workspace.libraries :as dwl]
|
||||
[app.main.data.workspace.modifiers :as dwm]
|
||||
[app.main.data.workspace.reflow :as wrf]
|
||||
[app.main.data.workspace.selection :as dws]
|
||||
[app.main.data.workspace.shapes :as dwsh]
|
||||
[app.main.data.workspace.transforms :as dwt]
|
||||
@ -37,6 +38,7 @@
|
||||
[app.main.router :as rt]
|
||||
[app.main.store :as st]
|
||||
[app.render-wasm.api :as wasm.api]
|
||||
[app.render-wasm.api.fonts :as wasm.fonts]
|
||||
[app.render-wasm.text-editor :as wasm.text-editor]
|
||||
[app.util.text-editor :as ted]
|
||||
[app.util.text.content :as tc]
|
||||
@ -60,6 +62,87 @@
|
||||
(declare v2-update-text-editor-styles)
|
||||
(declare v2-sync-wasm-text-layout)
|
||||
|
||||
(defn- bridge-to-measurement
|
||||
"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)))
|
||||
|
||||
(defn initialize-text-reflow
|
||||
"Tracks the texts the DOM pipeline still has to re-measure, so a reflow wait
|
||||
covers the measurement that resizes them."
|
||||
[]
|
||||
(ptk/reify ::initialize-text-reflow
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ stream]
|
||||
(let [stopper (rx/filter (ptk/type? ::finalize-text-reflow) stream)]
|
||||
(->> stream
|
||||
(rx/filter (ptk/type? :text/reflow))
|
||||
(rx/map deref)
|
||||
(rx/mapcat (fn [{:keys [ids]}] (bridge-to-measurement ids)))
|
||||
(rx/take-until stopper))))))
|
||||
|
||||
(defn finalize-text-reflow
|
||||
[]
|
||||
(ptk/data-event ::finalize-text-reflow))
|
||||
|
||||
(defn- resolve-text-ids
|
||||
"Returns the text shapes an attribute change on `id` applies to: `id` itself
|
||||
when it is a text, its text descendants when it is a group. These are the
|
||||
shapes that get re-measured, and so the ones the reflow waits track."
|
||||
[objects id]
|
||||
(let [shape (get objects id)]
|
||||
(cond
|
||||
(cfh/text-shape? shape)
|
||||
[id]
|
||||
|
||||
(cfh/group-shape? shape)
|
||||
(into [] (filter #(cfh/text-shape? (get objects %))) (cfh/get-children-ids objects id))
|
||||
|
||||
:else
|
||||
[])))
|
||||
|
||||
(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]
|
||||
(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)))))
|
||||
|
||||
(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]
|
||||
(if (or (nil? font-id) (empty? ids))
|
||||
(rx/empty)
|
||||
(->> (rx/of ::load-font)
|
||||
;; `ensure-loaded!` is intentionally invoked after `with-pending`
|
||||
;; subscribes, so even an immediately settled promise cannot create a
|
||||
;; gap before the task is visible to waiters.
|
||||
(rx/mapcat (fn [_]
|
||||
(rx/from (fonts/ensure-loaded! font-id font-variant-id))))
|
||||
(rx/ignore)
|
||||
(wrf/with-pending :font ids))))
|
||||
|
||||
;; -- Content helpers
|
||||
|
||||
(defn ensure-valid-text-content
|
||||
@ -425,12 +508,20 @@
|
||||
(update-text-range-attrs start end attrs)))
|
||||
|
||||
shape-ids (cond (cfh/text-shape? shape) [id]
|
||||
(cfh/group-shape? shape) (cfh/get-children-ids objects id))]
|
||||
(cfh/group-shape? shape) (cfh/get-children-ids objects id))
|
||||
text-ids (resolve-text-ids objects id)]
|
||||
|
||||
(rx/concat
|
||||
(rx/of (dwsh/update-shapes shape-ids update-fn))
|
||||
(if (features/active-feature? state "render-wasm/v1")
|
||||
(rx/of (dwwt/resize-wasm-text-debounce id))
|
||||
(cond
|
||||
(features/active-feature? state "render-wasm/v1")
|
||||
(->> (rx/from text-ids)
|
||||
(rx/map dwwt/resize-wasm-text-debounce))
|
||||
|
||||
(contains? attrs :font-id)
|
||||
(await-html-font (:font-id attrs) (:font-variant-id attrs) text-ids)
|
||||
|
||||
:else
|
||||
(rx/empty)))))))
|
||||
|
||||
(defn update-root-attrs
|
||||
@ -664,17 +755,20 @@
|
||||
(defn resize-text
|
||||
[id new-width new-height]
|
||||
|
||||
(let [cur-event (js/Symbol)]
|
||||
(let [cur-event (js/Symbol)
|
||||
reflow-task (wrf/task :text-resize [id])]
|
||||
(ptk/reify ::resize-text
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(-> state
|
||||
(update ::resize-text-debounce-props (fnil assoc {}) id [new-width new-height])
|
||||
(update ::resize-text-reflow-tasks (fnil conj []) reflow-task)
|
||||
(cond-> (nil? (::resize-text-debounce-event state))
|
||||
(assoc ::resize-text-debounce-event cur-event))))
|
||||
|
||||
ptk/WatchEvent
|
||||
(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)))]
|
||||
(rx/concat
|
||||
@ -686,7 +780,12 @@
|
||||
(rx/map #(commit-resize-text))
|
||||
(rx/take-until stopper))
|
||||
(rx/of (resize-text id new-width new-height)))
|
||||
(rx/of #(dissoc % ::resize-text-debounce-props ::resize-text-debounce-event))))
|
||||
(rx/of (fn [state]
|
||||
(run! wrf/finish! (::resize-text-reflow-tasks state))
|
||||
(dissoc state
|
||||
::resize-text-debounce-props
|
||||
::resize-text-reflow-tasks
|
||||
::resize-text-debounce-event)))))
|
||||
(rx/empty))))))
|
||||
|
||||
(defn save-font
|
||||
@ -834,19 +933,14 @@
|
||||
(rx/of (update-position-data id position-data))))
|
||||
(rx/empty))))))
|
||||
|
||||
(defn font-loaded-event?
|
||||
[font-id]
|
||||
(fn [event]
|
||||
(and
|
||||
(= :font-loaded (ptk/type event))
|
||||
(= (:font-id (deref event)) font-id))))
|
||||
|
||||
(defn update-attrs
|
||||
[id attrs]
|
||||
(ptk/reify ::update-attrs
|
||||
ptk/WatchEvent
|
||||
(watch [_ state stream]
|
||||
(let [text-editor-instance (:workspace-editor state)]
|
||||
(let [text-editor-instance (:workspace-editor state)
|
||||
objects (dsh/lookup-page-objects state)
|
||||
text-ids (resolve-text-ids objects id)]
|
||||
(if (and (features/active-feature? state "text-editor/v2")
|
||||
(some? text-editor-instance))
|
||||
(rx/empty)
|
||||
@ -870,7 +964,7 @@
|
||||
(not (features/active-feature? state "text-editor-wasm/v1")))
|
||||
(rx/of (v2-update-text-editor-styles id attrs)))
|
||||
|
||||
(when (features/active-feature? state "render-wasm/v1")
|
||||
(if (features/active-feature? state "render-wasm/v1")
|
||||
(rx/concat
|
||||
;; Apply style to selected spans and sync content
|
||||
(let [has-selection? (wasm.api/text-editor-has-selection?)]
|
||||
@ -882,14 +976,24 @@
|
||||
(rx/of (v2-update-text-shape-content
|
||||
(:shape-id result) (:content result)
|
||||
:update-name? true))))))))
|
||||
;; Resize (with delay for font-id changes)
|
||||
(if (contains? attrs :font-id)
|
||||
(->> stream
|
||||
(rx/filter (font-loaded-event? (:font-id attrs)))
|
||||
(rx/take 1)
|
||||
(rx/observe-on :async)
|
||||
(rx/map #(dwwt/resize-wasm-text id)))
|
||||
(rx/of (dwwt/resize-wasm-text id)))))))))
|
||||
;; Resize (with delay for font-id changes). Only auto-height and
|
||||
;; auto-width shapes have geometry to recompute.
|
||||
(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)
|
||||
;; No font change: measurable right away.
|
||||
(->> (rx/from auto-ids)
|
||||
(rx/map dwwt/resize-wasm-text)))))
|
||||
|
||||
;; The legacy renderer re-measures these in the DOM on its own,
|
||||
;; but font loading starts before that render commits.
|
||||
(if (contains? attrs :font-id)
|
||||
(await-html-font
|
||||
(:font-id attrs)
|
||||
(:font-variant-id attrs)
|
||||
text-ids)
|
||||
(rx/empty)))))))
|
||||
|
||||
ptk/EffectEvent
|
||||
(effect [_ state _]
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
[app.common.types.modifiers :as ctm]
|
||||
[app.main.data.helpers :as dsh]
|
||||
[app.main.data.workspace.modifiers :as dwm]
|
||||
[app.main.data.workspace.reflow :as wrf]
|
||||
[app.main.data.workspace.shapes :as dwsh]
|
||||
[app.main.data.workspace.undo :as dwu]
|
||||
[app.render-wasm.api :as wasm.api]
|
||||
@ -83,12 +84,14 @@
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [objects (dsh/lookup-page-objects state)
|
||||
shape (get objects id)]
|
||||
(if (and (some? shape)
|
||||
(cfh/text-shape? shape)
|
||||
(not= :fixed (:grow-type shape)))
|
||||
(rx/of (dwm/apply-wasm-modifiers (resize-wasm-text-modifiers shape)))
|
||||
(rx/empty))))))
|
||||
shape (get objects id)
|
||||
resize-stream
|
||||
(if (and (some? shape)
|
||||
(cfh/text-shape? shape)
|
||||
(not= :fixed (:grow-type shape)))
|
||||
(rx/of (dwm/apply-wasm-modifiers (resize-wasm-text-modifiers shape)))
|
||||
(rx/empty))]
|
||||
(wrf/with-pending :text-resize [id] resize-stream)))))
|
||||
|
||||
(defn resize-wasm-text-debounce-commit
|
||||
([]
|
||||
@ -141,17 +144,20 @@
|
||||
([id]
|
||||
(resize-wasm-text-debounce-inner id nil))
|
||||
([id {:keys [undo-group undo-id]}]
|
||||
(let [cur-event (js/Symbol)]
|
||||
(let [cur-event (js/Symbol)
|
||||
reflow-task (wrf/task :text-resize [id])]
|
||||
(ptk/reify ::resize-wasm-text-debounce-inner
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(-> state
|
||||
(update ::resize-wasm-text-debounce-ids (fnil conj []) id)
|
||||
(update ::resize-wasm-text-reflow-tasks (fnil conj []) reflow-task)
|
||||
(cond-> (nil? (::resize-wasm-text-debounce-event state))
|
||||
(assoc ::resize-wasm-text-debounce-event cur-event))))
|
||||
|
||||
ptk/WatchEvent
|
||||
(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)))]
|
||||
(rx/concat
|
||||
@ -168,9 +174,16 @@
|
||||
(rx/of (with-meta
|
||||
(resize-wasm-text-debounce-inner id)
|
||||
{:undo-group undo-group :undo-id undo-id})))
|
||||
(rx/of #(dissoc %
|
||||
::resize-wasm-text-debounce-ids
|
||||
::resize-wasm-text-debounce-event))))
|
||||
;; Cleanup, reached both after the commit and when the stopper
|
||||
;; cancels the debounce, so the batch always drains and stays
|
||||
;; 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))
|
||||
(dissoc state
|
||||
::resize-wasm-text-debounce-ids
|
||||
::resize-wasm-text-reflow-tasks
|
||||
::resize-wasm-text-debounce-event)))))
|
||||
(rx/empty)))))))
|
||||
|
||||
(defn resize-wasm-text-debounce
|
||||
@ -190,18 +203,24 @@
|
||||
(every?
|
||||
(fn [font]
|
||||
(let [font-data (wasm.fonts/make-font-data font)]
|
||||
(wasm.fonts/font-stored? font-data (:emoji? font-data))))))]
|
||||
(wasm.fonts/font-stored? font-data (:emoji? font-data))))))
|
||||
|
||||
(if fonts-loaded?
|
||||
(let [pass-opts (when (or (some? undo-group) (some? undo-id))
|
||||
(cond-> {}
|
||||
(some? undo-group) (assoc :undo-group undo-group)
|
||||
(some? undo-id) (assoc :undo-id undo-id)))]
|
||||
(rx/of (resize-wasm-text-debounce-inner id pass-opts)))
|
||||
resize-wasm-stream
|
||||
(if fonts-loaded?
|
||||
(let [pass-opts (when (or (some? undo-group) (some? undo-id))
|
||||
(cond-> {}
|
||||
(some? undo-group) (assoc :undo-group undo-group)
|
||||
(some? undo-id) (assoc :undo-id undo-id)))]
|
||||
(rx/of (resize-wasm-text-debounce-inner id pass-opts)))
|
||||
|
||||
;; Fonts not loaded; retry after 20 msecs
|
||||
(->> (rx/of (resize-wasm-text-debounce id opts))
|
||||
(rx/delay 20))))))))
|
||||
;; Fonts not loaded; retry after 20 msecs
|
||||
(->> (rx/of (resize-wasm-text-debounce id opts))
|
||||
(rx/delay 20)))]
|
||||
|
||||
;; Holds the shape pending across the font-retry loop: the retried
|
||||
;; event opens its task before this one drains, so the task set never
|
||||
;; becomes empty in between.
|
||||
(wrf/with-pending :text-resize [id] resize-wasm-stream))))))
|
||||
|
||||
(defn resize-wasm-text-all
|
||||
"Resize all text shapes (auto-width/auto-height) from a collection of ids."
|
||||
@ -214,9 +233,12 @@
|
||||
(rx/map resize-wasm-text-debounce))]
|
||||
(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
|
||||
(->> stream
|
||||
(rx/filter (ptk/type? ::dwsh/update-shapes-buffer-commit))
|
||||
(rx/take 1)
|
||||
(rx/mapcat (constantly resize-stream)))
|
||||
;; 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))))
|
||||
resize-stream)))))
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
[app.common.types.modifiers :as ctm]
|
||||
[app.common.types.text :as txt]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.workspace.reflow :as wrf]
|
||||
[app.main.data.workspace.texts :as dwt]
|
||||
[app.main.fonts :as fonts]
|
||||
[app.main.refs :as refs]
|
||||
@ -94,7 +95,10 @@
|
||||
(not migrate))
|
||||
(st/emit! (dwt/resize-text id width height)))))
|
||||
|
||||
(st/emit! (dwt/clean-text-modifier id))))))
|
||||
(st/emit! (dwt/clean-text-modifier id))))
|
||||
;; Swallowed so a text whose position data cannot be computed still
|
||||
;; settles and still reports its measurement as finished.
|
||||
(p/catch (fn [_] nil))))
|
||||
|
||||
(defn- update-text-modifier
|
||||
[{:keys [grow-type id] :as shape} node]
|
||||
@ -183,11 +187,17 @@
|
||||
(mf/use-fn
|
||||
(fn [shape node]
|
||||
;; Unique to indentify the pending state
|
||||
(let [uid (uuid/next)]
|
||||
(swap! pending-update* assoc uid (:id shape))
|
||||
(p/then
|
||||
(update-text-shape shape node)
|
||||
#(swap! pending-update* dissoc uid)))))]
|
||||
(let [uid (uuid/next)
|
||||
id (:id shape)]
|
||||
(swap! pending-update* assoc uid id)
|
||||
;; Callback refs run at the DOM commit boundary. Track the exact
|
||||
;; measurement promise from that acknowledgement; any resize it
|
||||
;; schedules owns the following task in the chain.
|
||||
(wrf/run-pending!
|
||||
:text-measure
|
||||
[id]
|
||||
#(-> (update-text-shape shape node)
|
||||
(p/finally (fn [] (swap! pending-update* dissoc uid))))))))]
|
||||
|
||||
[:.text-changes-renderer
|
||||
(for [{:keys [id] :as shape} changed-texts]
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
[app.main.data.workspace.groups :as dwg]
|
||||
[app.main.data.workspace.media :as dwm]
|
||||
[app.main.data.workspace.pages :as dwpg]
|
||||
[app.main.data.workspace.reflow :as wrf]
|
||||
[app.main.data.workspace.selection :as dws]
|
||||
[app.main.data.workspace.variants :as dwv]
|
||||
[app.main.data.workspace.wasm-text :as dwwt]
|
||||
@ -729,4 +730,14 @@
|
||||
(se/add-event plugin-id)))
|
||||
(shape/shape-proxy plugin-id variant-id))
|
||||
|
||||
(u/not-valid plugin-id :shapes "One of the components is not on the same page or is already a variant")))))))
|
||||
(u/not-valid plugin-id :shapes "One of the components is not on the same page or is already a variant")))))
|
||||
|
||||
:waitForLayoutUpdate
|
||||
(fn [timeout]
|
||||
;; 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)))))))
|
||||
|
||||
@ -42,6 +42,7 @@
|
||||
[app.main.data.workspace.guides :as dwgu]
|
||||
[app.main.data.workspace.interactions :as dwi]
|
||||
[app.main.data.workspace.libraries :as dwl]
|
||||
[app.main.data.workspace.reflow :as wrf]
|
||||
[app.main.data.workspace.selection :as dws]
|
||||
[app.main.data.workspace.shape-layout :as dwsl]
|
||||
[app.main.data.workspace.shapes :as dwsh]
|
||||
@ -1053,6 +1054,18 @@
|
||||
:else
|
||||
(st/emit! (dwsh/delete-shapes #{id}))))
|
||||
|
||||
: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)))))
|
||||
|
||||
;; Plugin data
|
||||
:getPluginData
|
||||
(fn [key]
|
||||
|
||||
@ -291,6 +291,14 @@
|
||||
(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)]
|
||||
|
||||
@ -18,16 +18,23 @@
|
||||
[app.render-wasm.helpers :as h]
|
||||
[app.render-wasm.wasm :as wasm]
|
||||
[app.util.http :as http]
|
||||
[app.util.timers :as tm]
|
||||
[beicon.v2.core :as rx]
|
||||
[cuerdas.core :as str]
|
||||
[goog.object :as gobj]
|
||||
[lambdaisland.uri :as u]
|
||||
[okulary.core :as l]
|
||||
[potok.v2.core :as ptk]))
|
||||
[okulary.core :as l]))
|
||||
|
||||
(def ^:private fonts
|
||||
;; Custom fonts uploaded to the current team, keyed by id (`fonts` is taken by
|
||||
;; the `app.main.fonts` alias).
|
||||
(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.
|
||||
(defonce font-stored-stream (rx/subject))
|
||||
|
||||
(def ^:private default-font-size 14)
|
||||
(def ^:private default-line-height 1.2)
|
||||
(def ^:private default-letter-spacing 0.0)
|
||||
@ -103,7 +110,7 @@
|
||||
(= (str (:font-weight font)) (str font-weight))
|
||||
(= (:font-style font) font-style)
|
||||
font))
|
||||
(seq @fonts))]
|
||||
(seq @custom-fonts))]
|
||||
(when matching-font
|
||||
(:ttf-file-id matching-font)))
|
||||
:builtin
|
||||
@ -141,7 +148,6 @@
|
||||
mem (js/Uint8Array. (.-buffer heap) ptr size)]
|
||||
|
||||
(.set mem (js/Uint8Array. font-array-buffer))
|
||||
(st/emit! (ptk/data-event :font-loaded {:font-id (:font-id font-data)}))
|
||||
(h/call wasm/internal-module "_store_font"
|
||||
(aget font-id-buffer 0)
|
||||
(aget font-id-buffer 1)
|
||||
@ -151,6 +157,8 @@
|
||||
(:style font-data)
|
||||
emoji?
|
||||
fallback?)
|
||||
;; Reported after the store call: subscribers react by measuring text.
|
||||
(rx/push! font-stored-stream (:font-id font-data))
|
||||
true)))
|
||||
|
||||
;; Tracks fonts currently being fetched: {url -> fallback?}
|
||||
@ -224,7 +232,9 @@
|
||||
font-data (assoc font-data :family-id-buffer id-buffer)
|
||||
font-stored? (font-stored? font-data emoji?)]
|
||||
(if font-stored?
|
||||
(st/async-emit! (ptk/data-event :font-loaded {:font-id (:font-id font-data)}))
|
||||
;; 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?)))))
|
||||
|
||||
(defn serialize-font-style
|
||||
|
||||
159
frontend/test/frontend_tests/data/workspace_reflow_test.cljs
Normal file
159
frontend/test/frontend_tests/data/workspace_reflow_test.cljs
Normal file
@ -0,0 +1,159 @@
|
||||
;; 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 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."
|
||||
(: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.texts :as dwtxt]
|
||||
[cljs.test :as t :include-macros true]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
(t/use-fixtures :each {:before wrf/reset-pending!
|
||||
:after wrf/reset-pending!})
|
||||
|
||||
;; Starts the `:layout/update` pipeline on a throwaway store; its empty state
|
||||
;; resolves every buffered update to no shapes and applies no modifiers.
|
||||
(defn- start-pipeline!
|
||||
[]
|
||||
(doto (ptk/store {:state {} :on-error #(js/console.error %)})
|
||||
(ptk/emit! (dwsl/initialize-shape-layout))))
|
||||
|
||||
(defn- stop-pipeline!
|
||||
[store]
|
||||
(ptk/emit! store (dwsl/finalize-shape-layout)))
|
||||
|
||||
(defn- start-text-pipeline!
|
||||
[]
|
||||
(doto (ptk/store {:state {} :on-error #(js/console.error %)})
|
||||
(ptk/emit! (dwtxt/initialize-text-reflow))))
|
||||
|
||||
(defn- stop-text-pipeline!
|
||||
[store]
|
||||
(ptk/emit! store (dwtxt/finalize-text-reflow)))
|
||||
|
||||
(t/deftest root-only-layout-update-is-not-pending-work
|
||||
;; The root lays out nothing, so an update with only its id holds no wait.
|
||||
(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)
|
||||
(.then #(t/is true "resolved with no pending work"))
|
||||
(.catch #(t/is false "a root-only update was marked as pending work"))
|
||||
(.then (fn []
|
||||
(stop-pipeline! store)
|
||||
(done)))))))
|
||||
|
||||
(t/deftest stale-task-cannot-finish-current-work
|
||||
;; Delayed callbacks from a finalized workspace carry the old generation and
|
||||
;; must not drain a new workspace task for the same shape id.
|
||||
(t/async done
|
||||
(let [id (uuid/next)
|
||||
stale-task (wrf/start! :text-measure [id])
|
||||
_ (wrf/reset-pending!)
|
||||
current-task (wrf/start! :text-measure [id])]
|
||||
(wrf/finish! stale-task)
|
||||
(-> (wrf/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)))
|
||||
(.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 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.
|
||||
(t/async done
|
||||
(let [id (uuid/next)
|
||||
resolve* (atom nil)
|
||||
started? (atom false)]
|
||||
(wrf/run-pending!
|
||||
:text-measure
|
||||
[id]
|
||||
(fn []
|
||||
(reset! started? true)
|
||||
(js/Promise. (fn [resolve _] (reset! resolve* resolve)))))
|
||||
(-> (wrf/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)))
|
||||
(.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)))))))
|
||||
|
||||
(t/deftest pending-promise-finishes-after-a-synchronous-error
|
||||
(let [id (uuid/next)]
|
||||
(try
|
||||
(wrf/run-pending! :text-measure [id] #(throw (js/Error. "boom")))
|
||||
(catch :default _))
|
||||
(t/async done
|
||||
(-> (wrf/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)))))))
|
||||
|
||||
(t/deftest deleted-shapes-cancel-only-their-work
|
||||
(t/async done
|
||||
(let [id-a (uuid/next)
|
||||
id-b (uuid/next)
|
||||
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)
|
||||
(.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 #(t/is false "cancelling one shape drained its sibling"))
|
||||
(.catch #(t/is true "sibling work stayed pending"))
|
||||
(.then (fn []
|
||||
(wrf/finish! task-a)
|
||||
(wrf/finish! task-b)
|
||||
(done)))))))
|
||||
|
||||
(t/deftest text-bridge-waits-for-each-shape
|
||||
;; Starting measurement for one id must not release sibling bridge tasks.
|
||||
(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-a (wrf/start! :text-measure [id-a])]
|
||||
(wrf/finish! task-a))
|
||||
(-> (wrf/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)))
|
||||
(.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 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)
|
||||
(.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 #(t/is true "resolved once the update was processed"))
|
||||
(.catch #(t/is false "the pipeline never drained its mark"))
|
||||
(.then (fn []
|
||||
(stop-pipeline! store)
|
||||
(done)))))))
|
||||
@ -175,12 +175,26 @@
|
||||
"Stores the original WASM function values so they can be restored."
|
||||
(atom {}))
|
||||
|
||||
;; Nesting depth of setup/teardown pairs. Only the outermost setup saves the
|
||||
;; originals and installs the mocks, and only the outermost teardown restores
|
||||
;; them, so a `with-wasm-mocks*` inside a fixture that already installed the
|
||||
;; mocks does not capture mocks as "originals" and leak them.
|
||||
(def ^:private install-depth (atom 0))
|
||||
|
||||
(declare install-wasm-mocks!)
|
||||
|
||||
(defn setup-wasm-mocks!
|
||||
"Install WASM mocks via `set!` that persist across async boundaries.
|
||||
Also resets call counts. Call `teardown-wasm-mocks!` to restore."
|
||||
Also resets call counts. Call `teardown-wasm-mocks!` to restore.
|
||||
Nested calls are no-ops apart from resetting call counts."
|
||||
[]
|
||||
;; Reset call tracking
|
||||
(reset-call-counts!)
|
||||
(when (= 1 (swap! install-depth inc))
|
||||
(install-wasm-mocks!)))
|
||||
|
||||
(defn- install-wasm-mocks!
|
||||
[]
|
||||
;; Save originals
|
||||
(reset! originals
|
||||
{:initialized? wasm.api/initialized?
|
||||
@ -221,12 +235,16 @@
|
||||
(set! wasm.fonts/make-font-data mock-make-font-data)
|
||||
(set! wasm.fonts/get-content-fonts mock-get-content-fonts))
|
||||
|
||||
(declare restore-wasm-mocks!)
|
||||
|
||||
(defn teardown-wasm-mocks!
|
||||
"Restore the original WASM functions saved by `setup-wasm-mocks!`.
|
||||
No-op when there is nothing to restore (`originals` empty): restoring from an
|
||||
empty snapshot would `set!` every WASM function to nil, breaking any later
|
||||
code that calls them (e.g. a leaked debounced event firing during a
|
||||
subsequent test namespace)."
|
||||
Nested calls are no-ops; only the outermost teardown restores."
|
||||
[]
|
||||
(when (zero? (swap! install-depth #(max 0 (dec %))))
|
||||
(restore-wasm-mocks!)))
|
||||
|
||||
(defn- restore-wasm-mocks!
|
||||
[]
|
||||
(when-let [orig (not-empty @originals)]
|
||||
(set! wasm.api/initialized? (:initialized? orig))
|
||||
|
||||
@ -9,9 +9,14 @@
|
||||
[app.common.math :as m]
|
||||
[app.common.test-helpers.files :as cthf]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.workspace.reflow :as wrf]
|
||||
[app.main.data.workspace.shapes :as dwsh]
|
||||
[app.main.data.workspace.wasm-text :as dwwt]
|
||||
[app.main.store :as st]
|
||||
[app.plugins.api :as api]
|
||||
[app.plugins.shape :as shape]
|
||||
[app.util.object :as obj]
|
||||
[beicon.v2.core :as rx]
|
||||
[cljs.test :as t :include-macros true]
|
||||
[frontend-tests.helpers.state :as ths]
|
||||
[frontend-tests.helpers.wasm :as thw]
|
||||
@ -404,3 +409,270 @@
|
||||
(let [shadows (.-shadows shape)]
|
||||
(t/is (array? shadows))
|
||||
(t/is (= 0 (.-length shadows)))))))))
|
||||
|
||||
;; ---- waitForLayoutUpdate tests ------------------------------------------
|
||||
;;
|
||||
;; `waitForLayoutUpdate` resolves once the shapes with reflow work in flight
|
||||
;; have drained from the `app.main.data.workspace.reflow` pending task map.
|
||||
;; The tests drive that map directly with `start!` / `finish!` instead
|
||||
;; of replaying internal pipeline events. A minimal store is still installed so
|
||||
;; `create-context` / `shape-proxy` can resolve the global st/state.
|
||||
|
||||
(def ^:private original-st-state st/state)
|
||||
(def ^:private original-st-stream st/stream)
|
||||
(def ^:private zero-id "00000000-0000-0000-0000-000000000000")
|
||||
|
||||
(t/use-fixtures :each
|
||||
{:before (fn []
|
||||
;; Keep the WASM mocks installed across the async test bodies:
|
||||
;; debounced text-resize timers leaked by earlier test namespaces
|
||||
;; can fire inside this namespace's async waits, and in Node they
|
||||
;; would otherwise reach the real (unavailable) WASM API.
|
||||
(thw/setup-wasm-mocks!)
|
||||
(wrf/reset-pending!))
|
||||
:after (fn []
|
||||
(thw/teardown-wasm-mocks!)
|
||||
(wrf/reset-pending!)
|
||||
(set! st/state original-st-state)
|
||||
(set! st/stream original-st-stream))})
|
||||
|
||||
(defn- make-test-store
|
||||
"Creates a minimal potok store with an empty state map and installs it as the
|
||||
global st/state and st/stream for the duration of the calling test."
|
||||
[]
|
||||
(let [test-store (ptk/store {:state {} :on-error (fn [e] (js/console.error e))})]
|
||||
(set! st/state test-store)
|
||||
(set! st/stream (ptk/input-stream test-store))
|
||||
test-store))
|
||||
|
||||
(t/deftest test-wait-for-layout-update-no-pending
|
||||
;; When nothing is pending the promise resolves immediately via the fast path
|
||||
;; (the behavior-subject replays the empty map on subscribe).
|
||||
(t/async done
|
||||
(make-test-store)
|
||||
(let [^js ctx (api/create-context zero-id)]
|
||||
(-> (.waitForLayoutUpdate ctx)
|
||||
(.then (fn []
|
||||
(t/is true "resolved with no pending work")
|
||||
(done)))
|
||||
(.catch (fn [err]
|
||||
(t/is false (str "unexpected rejection: " err))
|
||||
(done)))))))
|
||||
|
||||
(t/deftest test-wait-for-layout-update-pending
|
||||
;; While a shape is pending the context promise stays unresolved; it resolves
|
||||
;; once that shape is marked done.
|
||||
(t/async done
|
||||
(make-test-store)
|
||||
(wrf/start! :layout [(uuid/next)])
|
||||
(let [^js ctx (api/create-context zero-id)
|
||||
resolved (atom false)]
|
||||
(-> (.waitForLayoutUpdate ctx)
|
||||
(.then (fn [] (reset! resolved true)))
|
||||
(.catch (fn [err]
|
||||
(t/is false (str "unexpected rejection: " err)))))
|
||||
(js/setTimeout
|
||||
(fn []
|
||||
(t/is (false? @resolved) "must not resolve while a shape is pending")
|
||||
;; Draining everything resolves the context wait.
|
||||
(wrf/reset-pending!)
|
||||
(js/setTimeout
|
||||
(fn []
|
||||
(t/is (true? @resolved) "resolves once nothing is pending")
|
||||
(done))
|
||||
20))
|
||||
20))))
|
||||
|
||||
(t/deftest test-wait-for-layout-update-per-shape
|
||||
;; `shape.waitForLayoutUpdate()` only waits for that shape: draining another
|
||||
;; shape must not resolve it; draining its own id does.
|
||||
(t/async done
|
||||
(make-test-store)
|
||||
(let [id-a (uuid/next)
|
||||
id-b (uuid/next)
|
||||
task-a (wrf/start! :layout [id-a])
|
||||
task-b (wrf/start! :layout [id-b])
|
||||
^js shape-a (shape/shape-proxy zero-id uuid/zero uuid/zero id-a)
|
||||
resolved (atom false)]
|
||||
(-> (.waitForLayoutUpdate shape-a)
|
||||
(.then (fn [] (reset! resolved true)))
|
||||
(.catch (fn [err]
|
||||
(t/is false (str "unexpected rejection: " err)))))
|
||||
;; Draining the other shape leaves A pending.
|
||||
(wrf/finish! task-b)
|
||||
(js/setTimeout
|
||||
(fn []
|
||||
(t/is (false? @resolved) "shape A wait must stay pending while A is pending")
|
||||
(wrf/finish! task-a)
|
||||
(js/setTimeout
|
||||
(fn []
|
||||
(t/is (true? @resolved) "resolves once shape A drains")
|
||||
(done))
|
||||
20))
|
||||
20))))
|
||||
|
||||
(t/deftest test-wait-for-layout-update-overlapping-tasks
|
||||
;; Two overlapping operations on the same shape: finishing one task must not
|
||||
;; resolve the wait while the second operation is still in flight.
|
||||
(t/async done
|
||||
(make-test-store)
|
||||
(let [id (uuid/next)
|
||||
task-a (wrf/start! :layout [id])
|
||||
task-b (wrf/start! :layout [id])
|
||||
^js shape (shape/shape-proxy zero-id uuid/zero uuid/zero id)
|
||||
resolved (atom false)]
|
||||
(-> (.waitForLayoutUpdate shape)
|
||||
(.then (fn [] (reset! resolved true)))
|
||||
(.catch (fn [err]
|
||||
(t/is false (str "unexpected rejection: " err)))))
|
||||
;; First operation finishes; second is still pending.
|
||||
(wrf/finish! task-a)
|
||||
(js/setTimeout
|
||||
(fn []
|
||||
(t/is (false? @resolved) "must not resolve while a second op is in flight")
|
||||
(wrf/finish! task-b)
|
||||
(js/setTimeout
|
||||
(fn []
|
||||
(t/is (true? @resolved) "resolves once both operations drain")
|
||||
(done))
|
||||
20))
|
||||
20))))
|
||||
|
||||
(t/deftest test-wait-for-layout-update-timeout
|
||||
;; When the optional timeout fires before the shape drains the promise should
|
||||
;; reject with an Error whose message mentions "timeout".
|
||||
(t/async done
|
||||
(make-test-store)
|
||||
(wrf/start! :layout [(uuid/next)])
|
||||
(let [^js ctx (api/create-context zero-id)]
|
||||
(-> (.waitForLayoutUpdate ctx 20)
|
||||
(.then (fn []
|
||||
(t/is false "expected rejection but promise resolved")
|
||||
(done)))
|
||||
(.catch (fn [^js err]
|
||||
(t/is (instance? js/Error err) "rejection value should be an Error")
|
||||
(t/is (some? (re-find #"timeout" (.-message err))) "error message should mention timeout")
|
||||
(done)))))))
|
||||
|
||||
(t/deftest test-wait-for-layout-update-subtree
|
||||
;; A shape wait covers its whole subtree: reflow work is often marked on the
|
||||
;; children rather than on the shape the caller holds (a group is re-measured
|
||||
;; through its texts), so a pending child must block the parent.
|
||||
(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 [child-id (obj/get rect "$id")
|
||||
task (wrf/start! :layout [child-id])
|
||||
resolved (atom false)]
|
||||
(-> (.waitForLayoutUpdate board)
|
||||
(.then (fn [] (reset! resolved true)))
|
||||
(.catch (fn [err]
|
||||
(t/is false (str "unexpected rejection: " err)))))
|
||||
(js/setTimeout
|
||||
(fn []
|
||||
(t/is (false? @resolved) "parent wait must block on a pending child")
|
||||
(wrf/finish! task)
|
||||
(js/setTimeout
|
||||
(fn []
|
||||
(t/is (true? @resolved) "resolves once the child drains")
|
||||
(done))
|
||||
20))
|
||||
20))))))
|
||||
|
||||
(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
|
||||
;; throwValidationErrors flag says.
|
||||
(t/async done
|
||||
(make-test-store)
|
||||
(let [^js ctx (api/create-context zero-id)
|
||||
^js shape (shape/shape-proxy zero-id uuid/zero uuid/zero (uuid/next))
|
||||
rejected? (fn [^js promise]
|
||||
(t/is (instance? js/Promise promise) "must return a promise")
|
||||
(-> promise
|
||||
(.then (fn [] false))
|
||||
(.catch (fn [] true))))]
|
||||
(-> (js/Promise.all
|
||||
#js [(rejected? (.waitForLayoutUpdate ctx "soon"))
|
||||
(rejected? (.waitForLayoutUpdate ctx 0))
|
||||
(rejected? (.waitForLayoutUpdate ctx -5))
|
||||
(rejected? (.waitForLayoutUpdate ctx js/NaN))
|
||||
(rejected? (.waitForLayoutUpdate ctx js/Infinity))
|
||||
(rejected? (.waitForLayoutUpdate shape "soon"))])
|
||||
(.then (fn [results]
|
||||
(t/is (every? true? (array-seq results))
|
||||
"every invalid timeout must reject")
|
||||
(done)))))))
|
||||
|
||||
(t/deftest test-with-pending-marks-on-subscribe
|
||||
;; `with-pending` marks on subscription, not while the observable is being
|
||||
;; built, so an observable that is never subscribed leaves nothing pending.
|
||||
(t/async done
|
||||
(make-test-store)
|
||||
(let [id (uuid/next)
|
||||
;; Built but never subscribed.
|
||||
_ (wrf/with-pending :layout [id] (rx/of :ignored))
|
||||
^js ctx (api/create-context zero-id)]
|
||||
(-> (.waitForLayoutUpdate ctx 50)
|
||||
(.then (fn []
|
||||
(t/is true "an unsubscribed with-pending must not mark anything")
|
||||
(done)))
|
||||
(.catch (fn [err]
|
||||
(t/is false (str "unexpected rejection: " err))
|
||||
(done)))))))
|
||||
|
||||
(t/deftest test-with-pending-drains-on-terminate
|
||||
;; Once subscribed, the shape is pending for the lifetime of the wrapped
|
||||
;; observable and drains when it completes.
|
||||
(t/async done
|
||||
(make-test-store)
|
||||
(let [id (uuid/next)
|
||||
subject (rx/subject)
|
||||
^js shape (shape/shape-proxy zero-id uuid/zero uuid/zero id)
|
||||
resolved (atom false)]
|
||||
(rx/sub! (wrf/with-pending :layout [id] subject) (fn [_] nil))
|
||||
(-> (.waitForLayoutUpdate shape)
|
||||
(.then (fn [] (reset! resolved true)))
|
||||
(.catch (fn [err]
|
||||
(t/is false (str "unexpected rejection: " err)))))
|
||||
(js/setTimeout
|
||||
(fn []
|
||||
(t/is (false? @resolved) "must stay pending while the wrapped stream is alive")
|
||||
(rx/end! subject)
|
||||
(js/setTimeout
|
||||
(fn []
|
||||
(t/is (true? @resolved) "resolves once the wrapped stream completes")
|
||||
(done))
|
||||
20))
|
||||
20))))
|
||||
|
||||
(t/deftest test-resize-wasm-text-all-pending-across-buffer-commit
|
||||
;; While a shape-update buffer is open (token propagation) `resize-wasm-text-all`
|
||||
;; holds the resizes back until the commit lands, and the shapes stay pending
|
||||
;; for that whole wait.
|
||||
(t/async done
|
||||
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1))
|
||||
id (uuid/next)
|
||||
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)
|
||||
(.then (fn [] (reset! resolved true)))
|
||||
(.catch (fn [err]
|
||||
(t/is false (str "unexpected rejection: " err)))))
|
||||
(js/setTimeout
|
||||
(fn []
|
||||
(t/is (false? @resolved) "must stay pending while the buffer has not committed")
|
||||
(ptk/emit! store (dwsh/update-shapes-buffer-commit))
|
||||
;; Covers the commit plus the per-shape debounce the resizes go through.
|
||||
(js/setTimeout
|
||||
(fn []
|
||||
(t/is (true? @resolved) "resolves once the commit releases the resizes")
|
||||
(done))
|
||||
300))
|
||||
50))))
|
||||
|
||||
@ -262,7 +262,7 @@
|
||||
(fn [id theme]
|
||||
(swap! captured conj {:id id :theme theme})
|
||||
:update-token-theme)
|
||||
st/emit! identity]
|
||||
st/emit! mock/noop]
|
||||
(.addSet theme set)
|
||||
(.removeSet theme set)
|
||||
(t/is (= [theme-id theme-id] (mapv :id @captured)))
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
[frontend-tests.data.workspace-interactions-test]
|
||||
[frontend-tests.data.workspace-mcp-test]
|
||||
[frontend-tests.data.workspace-media-test]
|
||||
[frontend-tests.data.workspace-reflow-test]
|
||||
[frontend-tests.data.workspace-shortcuts-test]
|
||||
[frontend-tests.data.workspace-texts-test]
|
||||
[frontend-tests.data.workspace-thumbnails-test]
|
||||
@ -104,6 +105,7 @@
|
||||
'frontend-tests.data.workspace-interactions-test
|
||||
'frontend-tests.data.workspace-mcp-test
|
||||
'frontend-tests.data.workspace-media-test
|
||||
'frontend-tests.data.workspace-reflow-test
|
||||
'frontend-tests.data.workspace-shortcuts-test
|
||||
'frontend-tests.data.workspace-texts-test
|
||||
'frontend-tests.data.workspace-thumbnails-test
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
### 🚀 Features
|
||||
|
||||
- **plugin-types:** Added `paddingType` (`'simple' | 'multiple'`) to flex and grid layouts and `marginType` (`'simple' | 'multiple'`) to layout children, exposing whether the four padding/margin sides are mirrored or honoured independently.
|
||||
- **plugin-types**: Added `waitForLayoutUpdate` to wait until pending layout updates have finished. It rejects when the optional timeout elapses, defaulting to 30 seconds so a wait never hangs.
|
||||
- **plugin-types**: Added `waitForLayoutUpdate` to the `Shape` interface to wait until the pending layout updates of a shape and its children have finished
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
|
||||
@ -32,6 +32,10 @@ From `plugins/`:
|
||||
- Dev server: `pnpm run start:plugin:api-test-suite` (serves on port 4202).
|
||||
- In Penpot: open the Plugin Manager (Ctrl+Alt+P) and install
|
||||
`http://localhost:4202/manifest.json`.
|
||||
- Alternatively, a one-shot `pnpm --filter plugin-api-test-suite run build` output
|
||||
is served by the devenv alongside the other bundled plugins, at
|
||||
`https://localhost:3449/plugins/plugin-api-test-suite/manifest.json` (no hot
|
||||
reload of the UI there, but the **Reload** button still picks up rebuilt tests).
|
||||
- **Hot-reloading tests:** after editing a `*.test.ts`, click **Reload** in the
|
||||
plugin UI. It fetches the freshly built test bundle and swaps in your changes —
|
||||
no need to close/reopen the plugin. (The dev server rebuilds the bundle on save.)
|
||||
|
||||
@ -17,6 +17,7 @@ import type { CoverageReport, TestResult } from '../src/framework/types';
|
||||
// Optional env: PENPOT_BASE_URL (default https://localhost:3449).
|
||||
// Optional env: TEST_FILTER — run only tests whose group or name contains
|
||||
// the given substring (case-insensitive).
|
||||
// Optional env: RENDER_WASM — force the workspace renderer (`true`/`false`).
|
||||
//
|
||||
// - MOCKED (`MOCK_BACKEND=1`): serves the prebuilt frontend bundle via the e2e
|
||||
// static server and intercepts every backend RPC with Playwright `page.route`,
|
||||
@ -351,6 +352,12 @@ async function main() {
|
||||
fileUrl = getFileUrl(file);
|
||||
}
|
||||
|
||||
const renderWasm = process.env['RENDER_WASM'];
|
||||
if (renderWasm === 'true' || renderWasm === 'false') {
|
||||
const separator = fileUrl.includes('?') ? '&' : '?';
|
||||
fileUrl += `${separator}wasm=${renderWasm}`;
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({
|
||||
args: ['--ignore-certificate-errors'],
|
||||
});
|
||||
|
||||
@ -114,7 +114,8 @@
|
||||
"uploadMediaData",
|
||||
"uploadMediaUrl",
|
||||
"version",
|
||||
"viewport"
|
||||
"viewport",
|
||||
"waitForLayoutUpdate"
|
||||
],
|
||||
"ContextGeometryUtils": ["center"],
|
||||
"ContextTypesUtils": [
|
||||
@ -454,6 +455,7 @@
|
||||
"switchVariant",
|
||||
"tokens",
|
||||
"visible",
|
||||
"waitForLayoutUpdate",
|
||||
"width",
|
||||
"x",
|
||||
"y"
|
||||
@ -1213,6 +1215,12 @@
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"waitForLayoutUpdate": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"getPluginData": {
|
||||
"decl": "PluginData",
|
||||
"kind": "method",
|
||||
@ -1713,6 +1721,12 @@
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"waitForLayoutUpdate": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"getPluginData": {
|
||||
"decl": "PluginData",
|
||||
"kind": "method",
|
||||
@ -2312,6 +2326,12 @@
|
||||
"kind": "method",
|
||||
"type": "VariantContainer",
|
||||
"array": false
|
||||
},
|
||||
"waitForLayoutUpdate": {
|
||||
"decl": "Context",
|
||||
"kind": "method",
|
||||
"type": null,
|
||||
"array": false
|
||||
}
|
||||
},
|
||||
"ContextGeometryUtils": {
|
||||
@ -2845,6 +2865,12 @@
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"waitForLayoutUpdate": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"getPluginData": {
|
||||
"decl": "PluginData",
|
||||
"kind": "method",
|
||||
@ -4087,6 +4113,12 @@
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"waitForLayoutUpdate": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"getPluginData": {
|
||||
"decl": "PluginData",
|
||||
"kind": "method",
|
||||
@ -4671,6 +4703,12 @@
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"waitForLayoutUpdate": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"getPluginData": {
|
||||
"decl": "PluginData",
|
||||
"kind": "method",
|
||||
@ -6299,6 +6337,12 @@
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"waitForLayoutUpdate": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"getPluginData": {
|
||||
"decl": "PluginData",
|
||||
"kind": "method",
|
||||
@ -6620,6 +6664,12 @@
|
||||
"kind": "method",
|
||||
"type": "VariantContainer",
|
||||
"array": false
|
||||
},
|
||||
"waitForLayoutUpdate": {
|
||||
"decl": "Context",
|
||||
"kind": "method",
|
||||
"type": null,
|
||||
"array": false
|
||||
}
|
||||
},
|
||||
"PluginData": {
|
||||
@ -7129,6 +7179,12 @@
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"waitForLayoutUpdate": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"getPluginData": {
|
||||
"decl": "PluginData",
|
||||
"kind": "method",
|
||||
@ -7657,6 +7713,12 @@
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"waitForLayoutUpdate": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"getPluginData": {
|
||||
"decl": "PluginData",
|
||||
"kind": "method",
|
||||
@ -8221,6 +8283,12 @@
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"waitForLayoutUpdate": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"getPluginData": {
|
||||
"decl": "PluginData",
|
||||
"kind": "method",
|
||||
@ -8787,6 +8855,12 @@
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"waitForLayoutUpdate": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"getPluginData": {
|
||||
"decl": "PluginData",
|
||||
"kind": "method",
|
||||
@ -11085,6 +11159,12 @@
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"waitForLayoutUpdate": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"getPluginData": {
|
||||
"decl": "PluginData",
|
||||
"kind": "method",
|
||||
|
||||
613
plugins/apps/plugin-api-test-suite/src/tests/wait-layout.test.ts
Normal file
613
plugins/apps/plugin-api-test-suite/src/tests/wait-layout.test.ts
Normal file
@ -0,0 +1,613 @@
|
||||
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 { TestContext } from '../framework/types';
|
||||
|
||||
// waitForLayoutUpdate (context-level and per-shape).
|
||||
// The promise must resolve only once the pending reflow work has been applied,
|
||||
// so every test asserts the *observable geometry* after awaiting: if the
|
||||
// promise settles too early the read-back is stale and the test goes red.
|
||||
// Reflow work is scheduled by three pipelines: flex/grid layout updates
|
||||
// (buffered ~100ms), wasm text resizes (debounced), and font-change
|
||||
// re-measurement. The probes below cover each one, including cascaded reflows
|
||||
// (a child resize that must also re-hug its ancestors) and waits racing the
|
||||
// font-loading retry loop.
|
||||
|
||||
function flexBoard(ctx: TestContext): Board {
|
||||
const b = ctx.penpot.createBoard();
|
||||
ctx.board.appendChild(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
/** Creates a horizontal flex board with two 50x50 rects and a 10px gap. */
|
||||
function flexRow(ctx: TestContext): {
|
||||
board: Board;
|
||||
rects: [Shape, Shape];
|
||||
} {
|
||||
const b = flexBoard(ctx);
|
||||
const flex = b.addFlexLayout();
|
||||
flex.dir = 'row';
|
||||
flex.columnGap = 10;
|
||||
const r1 = ctx.penpot.createRectangle();
|
||||
r1.resize(50, 50);
|
||||
flex.appendChild(r1);
|
||||
const r2 = ctx.penpot.createRectangle();
|
||||
r2.resize(50, 50);
|
||||
flex.appendChild(r2);
|
||||
return { board: b, rects: [r1, r2] };
|
||||
}
|
||||
|
||||
/** Orders the two rects of a flex row by their current x position. */
|
||||
function byX(rects: [Shape, Shape]): { left: Shape; right: Shape } {
|
||||
const [a, b] = rects;
|
||||
return a.x <= b.x ? { left: a, right: b } : { left: b, right: a };
|
||||
}
|
||||
|
||||
/** Font ids already handed out by `unloadedFont`. */
|
||||
const claimedFonts = new Set<string>();
|
||||
|
||||
/**
|
||||
* Picks an unclaimed font differing from the text's current one, so assigning
|
||||
* it triggers a real (not-yet-loaded) fetch. A font stays cached for the whole
|
||||
* run, so reusing one would drain the pending mark near-instantly.
|
||||
*/
|
||||
function unloadedFont(ctx: TestContext, t: Text): Font {
|
||||
const all = ctx.penpot.fonts.all;
|
||||
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);
|
||||
return f;
|
||||
}
|
||||
throw new Error('no alternative font available');
|
||||
}
|
||||
|
||||
/** Picks any font other than the text's current one; may already be loaded. */
|
||||
function otherFont(ctx: TestContext, t: Text): Font {
|
||||
const font = ctx.penpot.fonts.all.find(
|
||||
(f) => f.fontId !== t.fontId && f.variants.length > 0,
|
||||
);
|
||||
if (!font) throw new Error('no alternative font available');
|
||||
return font;
|
||||
}
|
||||
|
||||
function autoWidthText(ctx: TestContext, value: string): Text {
|
||||
const t = ctx.penpot.createText(value);
|
||||
if (!t) throw new Error('createText returned null');
|
||||
ctx.board.appendChild(t);
|
||||
t.growType = 'auto-width';
|
||||
t.fontSize = '36';
|
||||
return t;
|
||||
}
|
||||
|
||||
describe('WaitForLayoutUpdate', () => {
|
||||
describe('Basics', () => {
|
||||
test('context method returns a promise that resolves', async (ctx) => {
|
||||
const p = ctx.penpot.waitForLayoutUpdate();
|
||||
expect(typeof p.then).toBe('function');
|
||||
const value = await p;
|
||||
expect(value).toBeUndefined();
|
||||
});
|
||||
|
||||
// For the next two, resolving (not rejecting, not hitting the runner
|
||||
// timeout) is itself the behaviour under test, so they have no expects.
|
||||
|
||||
test('a timeout wait resolves via the fast path when nothing is pending', async (ctx) => {
|
||||
// On an already drained pending map the settle signal replays
|
||||
// synchronously, so it beats the deadline and even a 1ms timeout
|
||||
// resolves. Draining is what has to be retried: a pipeline can schedule
|
||||
// fresh work in the instant between the drain and the timed call, and
|
||||
// that is a pending map, not a broken fast path.
|
||||
let resolved = false;
|
||||
for (let i = 0; i < 5 && !resolved; i++) {
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
resolved = await ctx.penpot
|
||||
.waitForLayoutUpdate(1)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
|
||||
test('shape method resolves for a shape with no pending work', async (ctx) => {
|
||||
const r = ctx.penpot.createRectangle();
|
||||
ctx.board.appendChild(r);
|
||||
await r.waitForLayoutUpdate();
|
||||
});
|
||||
|
||||
test('consecutive waits each track their own pending work', async (ctx) => {
|
||||
// Each call opens a fresh subscription to the pending map, so a wait must
|
||||
// settle on the work scheduled right before it and never carry state from
|
||||
// an earlier, already-resolved wait. Two successive resizes to different
|
||||
// sizes prove it: the second wait has to reflect the second reflow.
|
||||
const { rects } = flexRow(ctx);
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
|
||||
const { left, right } = byX(rects);
|
||||
left.resize(120, 50);
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
expect(right.x - left.x).toBeCloseTo(130, 0);
|
||||
|
||||
left.resize(70, 50);
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
expect(right.x - left.x).toBeCloseTo(80, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Flex reflow', () => {
|
||||
test('sibling is repositioned after awaiting a child resize', async (ctx) => {
|
||||
const { rects } = flexRow(ctx);
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
|
||||
const { left, right } = byX(rects);
|
||||
left.resize(120, 50);
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
expect(right.x - left.x).toBeCloseTo(130, 0);
|
||||
});
|
||||
|
||||
test('board-level wait settles the reflow of its layout', async (ctx) => {
|
||||
const { board: b, rects } = flexRow(ctx);
|
||||
await b.waitForLayoutUpdate();
|
||||
|
||||
const { left, right } = byX(rects);
|
||||
left.resize(120, 50);
|
||||
// The layout pipeline marks the laid-out board (the parent) as pending,
|
||||
// so the board proxy is the shape-level handle for this reflow.
|
||||
await b.waitForLayoutUpdate();
|
||||
expect(right.x - left.x).toBeCloseTo(130, 0);
|
||||
});
|
||||
|
||||
test('back-to-back resizes settle to the last value', async (ctx) => {
|
||||
// Overlapping operations on the same shapes: the wait must not resolve
|
||||
// after the first one drains while the second is still in flight.
|
||||
const { rects } = flexRow(ctx);
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
|
||||
const { left, right } = byX(rects);
|
||||
left.resize(80, 50);
|
||||
left.resize(120, 50);
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
expect(right.x - left.x).toBeCloseTo(130, 0);
|
||||
});
|
||||
|
||||
test('grid layout reflow settles child positions', async (ctx) => {
|
||||
const b = flexBoard(ctx);
|
||||
const grid = b.addGridLayout();
|
||||
grid.addRow('fixed', 60);
|
||||
grid.addColumn('fixed', 60);
|
||||
grid.addColumn('fixed', 60);
|
||||
const r1 = ctx.penpot.createRectangle();
|
||||
r1.resize(50, 50);
|
||||
grid.appendChild(r1, 1, 1);
|
||||
const r2 = ctx.penpot.createRectangle();
|
||||
r2.resize(50, 50);
|
||||
grid.appendChild(r2, 1, 2);
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
// Cells are 60px wide, so the second child sits one track further right.
|
||||
expect(Math.abs(r2.x - r1.x)).toBeCloseTo(60, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hug sizing', () => {
|
||||
test('hug board fits its content after awaiting the initial layout', async (ctx) => {
|
||||
const { board: b } = flexRow(ctx);
|
||||
const flex = b.flex;
|
||||
if (!flex) throw new Error('expected a flex layout on the board');
|
||||
flex.horizontalSizing = 'auto';
|
||||
flex.verticalSizing = 'auto';
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
// 50 + 10 gap + 50, no padding.
|
||||
expect(b.width).toBeCloseTo(110, 0);
|
||||
});
|
||||
|
||||
test('hug board grows after awaiting a child resize', async (ctx) => {
|
||||
const { board: b, rects } = flexRow(ctx);
|
||||
const flex = b.flex;
|
||||
if (!flex) throw new Error('expected a flex layout on the board');
|
||||
flex.horizontalSizing = 'auto';
|
||||
flex.verticalSizing = 'auto';
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
|
||||
const { left } = byX(rects);
|
||||
left.resize(120, 50);
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
expect(b.width).toBeCloseTo(180, 0);
|
||||
});
|
||||
|
||||
test('nested hug boards both grow after awaiting a child resize', async (ctx) => {
|
||||
// Cascade probe: the child resize reflows the inner board, whose new
|
||||
// size must then reflow the outer board. The wait has to cover the whole
|
||||
// chain, not just the first reflow round.
|
||||
const outer = flexBoard(ctx);
|
||||
const outerFlex = outer.addFlexLayout();
|
||||
outerFlex.dir = 'row';
|
||||
outerFlex.horizontalSizing = 'auto';
|
||||
outerFlex.verticalSizing = 'auto';
|
||||
|
||||
const inner = ctx.penpot.createBoard();
|
||||
outerFlex.appendChild(inner);
|
||||
const innerFlex = inner.addFlexLayout();
|
||||
innerFlex.dir = 'row';
|
||||
innerFlex.horizontalSizing = 'auto';
|
||||
innerFlex.verticalSizing = 'auto';
|
||||
|
||||
const rect = ctx.penpot.createRectangle();
|
||||
rect.resize(50, 50);
|
||||
innerFlex.appendChild(rect);
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
expect(inner.width).toBeCloseTo(50, 0);
|
||||
expect(outer.width).toBeCloseTo(50, 0);
|
||||
|
||||
rect.resize(200, 50);
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
expect(inner.width).toBeCloseTo(200, 0);
|
||||
expect(outer.width).toBeCloseTo(200, 0);
|
||||
});
|
||||
|
||||
test.skipIfMocked(
|
||||
'hug board hugs an auto-width text after a characters change',
|
||||
async (ctx) => {
|
||||
// Cascade probe across pipelines: the text resize is scheduled by the
|
||||
// text pipeline and only then does the board reflow to hug it. Skipped
|
||||
// under a mocked backend: hugging a text depends on real glyph
|
||||
// measurement (no fonts are served), so the text width never grows.
|
||||
const b = flexBoard(ctx);
|
||||
const flex = b.addFlexLayout();
|
||||
flex.horizontalSizing = 'auto';
|
||||
flex.verticalSizing = 'auto';
|
||||
const t = ctx.penpot.createText('hi');
|
||||
if (!t) throw new Error('createText returned null');
|
||||
flex.appendChild(t);
|
||||
t.growType = 'auto-width';
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
const w0 = b.width;
|
||||
|
||||
t.characters = 'a much much much longer text content than before';
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
expect(t.width).toBeGreaterThan(w0);
|
||||
expect(b.width).toBeCloseTo(t.width, 0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Skipped under a mocked backend: every case here asserts that a text is
|
||||
// re-measured (width changes) after a mutation, which depends on real glyph
|
||||
// measurement against fonts the backend serves. Mocked mode serves no fonts,
|
||||
// so the measured width stays put and the assertions can't hold.
|
||||
describe.skipIfMocked('Text', () => {
|
||||
test('auto-width text grows after awaiting a fontSize bump', async (ctx) => {
|
||||
const t = autoWidthText(ctx, 'The quick brown fox');
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
const w0 = t.width;
|
||||
|
||||
t.fontSize = '72';
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
expect(t.width).toBeGreaterThan(w0);
|
||||
});
|
||||
|
||||
test('auto-width text grows after awaiting a characters change', async (ctx) => {
|
||||
const t = autoWidthText(ctx, 'short');
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
const w0 = t.width;
|
||||
|
||||
t.characters = 'short plus a considerably longer tail 0123456789';
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
expect(t.width).toBeGreaterThan(w0);
|
||||
});
|
||||
|
||||
test('shape-level wait covers the text own resize', async (ctx) => {
|
||||
const t = autoWidthText(ctx, 'The quick brown fox');
|
||||
await t.waitForLayoutUpdate();
|
||||
const w0 = t.width;
|
||||
|
||||
t.fontSize = '72';
|
||||
await t.waitForLayoutUpdate();
|
||||
expect(t.width).toBeGreaterThan(w0);
|
||||
});
|
||||
|
||||
test('auto-width text is re-measured after awaiting a font change', async (ctx) => {
|
||||
// Probe for the font-loading window: right after a font change the new
|
||||
// font is typically not loaded yet, and the wait must cover the load +
|
||||
// re-measure, not resolve while the loader is still retrying.
|
||||
const t = autoWidthText(
|
||||
ctx,
|
||||
'The quick brown fox jumps over the lazy dog 0123456789',
|
||||
);
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
const w0 = t.width;
|
||||
|
||||
const font = otherFont(ctx, t);
|
||||
t.fontId = font.fontId;
|
||||
t.fontFamily = font.fontFamily;
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
// A different family practically never yields the same measured width
|
||||
// for a 54-char string at 36px.
|
||||
expect(Math.abs(t.width - w0)).toBeGreaterThan(0.5);
|
||||
|
||||
// The wait covers the *final* geometry: a second wait must find the
|
||||
// text where the first one left it.
|
||||
const settled = t.width;
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
expect(t.width).toBeCloseTo(settled, 0);
|
||||
});
|
||||
|
||||
test('range font change is re-measured after awaiting', async (ctx) => {
|
||||
// Same probe through the text-range pipeline instead of the shape-level
|
||||
// font setter.
|
||||
const t = autoWidthText(
|
||||
ctx,
|
||||
'The quick brown fox jumps over the lazy dog 0123456789',
|
||||
);
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
const w0 = t.width;
|
||||
|
||||
const font = otherFont(ctx, t);
|
||||
const range = t.getRange(0, t.characters.length);
|
||||
range.fontFamily = font.fontFamily;
|
||||
range.fontId = font.fontId;
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
expect(Math.abs(t.width - w0)).toBeGreaterThan(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
// 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,
|
||||
// so nothing is ever re-measured.
|
||||
describe.skipIfMocked('Groups', () => {
|
||||
/** Groups two auto-width texts and returns the group with its children. */
|
||||
function textGroup(ctx: TestContext): {
|
||||
group: Group;
|
||||
texts: [Text, Text];
|
||||
} {
|
||||
const a = autoWidthText(ctx, 'The quick brown fox');
|
||||
const b = autoWidthText(ctx, 'jumps over the lazy dog');
|
||||
const group = ctx.penpot.group([a, b]);
|
||||
if (!group) throw new Error('group returned null');
|
||||
return { group, texts: [a, b] };
|
||||
}
|
||||
|
||||
/** Groups an auto-width text with a fixed-size one. */
|
||||
function mixedGrowTypeGroup(ctx: TestContext): {
|
||||
group: Group;
|
||||
auto: Text;
|
||||
fixed: Text;
|
||||
} {
|
||||
const auto = autoWidthText(ctx, 'The quick brown fox');
|
||||
const fixed = autoWidthText(ctx, 'jumps over the lazy dog');
|
||||
fixed.resize(400, 80);
|
||||
fixed.growType = 'fixed';
|
||||
const group = ctx.penpot.group([auto, fixed]);
|
||||
if (!group) throw new Error('group returned null');
|
||||
return { group, auto, fixed };
|
||||
}
|
||||
|
||||
/**
|
||||
* The typed signature of `applyToText` takes a `Text`, but the setter also
|
||||
* accepts a container and forwards the change to its text descendants, as
|
||||
* it does for a group selected in the UI.
|
||||
*/
|
||||
function applyFontToGroup(font: Font, group: Group): void {
|
||||
font.applyToText(group as unknown as Text);
|
||||
}
|
||||
|
||||
test('context wait covers a font applied to a group', async (ctx) => {
|
||||
const { group, texts } = textGroup(ctx);
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
const [w0, w1] = [texts[0].width, texts[1].width];
|
||||
|
||||
const font = otherFont(ctx, texts[0]);
|
||||
applyFontToGroup(font, group);
|
||||
const started = Date.now();
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
|
||||
expect(Math.abs(texts[0].width - w0)).toBeGreaterThan(0.5);
|
||||
expect(Math.abs(texts[1].width - w1)).toBeGreaterThan(0.5);
|
||||
// The wait tracks the text children. Tracking the group id would only
|
||||
// unblock on the internal stuck-timeout, orders of magnitude slower.
|
||||
expect(Date.now() - started).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
test('group wait covers the reflow of its text children', async (ctx) => {
|
||||
const { group, texts } = textGroup(ctx);
|
||||
await group.waitForLayoutUpdate();
|
||||
const w0 = texts[0].width;
|
||||
|
||||
const font = otherFont(ctx, texts[0]);
|
||||
applyFontToGroup(font, group);
|
||||
const started = Date.now();
|
||||
await group.waitForLayoutUpdate();
|
||||
|
||||
expect(Math.abs(texts[0].width - w0)).toBeGreaterThan(0.5);
|
||||
expect(Date.now() - started).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
test('a font on a mixed group re-measures only the auto-width child', async (ctx) => {
|
||||
// A group can hold both grow types, and only the auto ones have geometry
|
||||
// to recompute. A fixed text keeps its box whatever the new glyphs
|
||||
// measure, so the wait settles on the auto child alone.
|
||||
const { group, auto, fixed } = mixedGrowTypeGroup(ctx);
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
const [autoW0, fixedW0] = [auto.width, fixed.width];
|
||||
|
||||
const font = otherFont(ctx, auto);
|
||||
applyFontToGroup(font, group);
|
||||
const started = Date.now();
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
|
||||
expect(Math.abs(auto.width - autoW0)).toBeGreaterThan(0.5);
|
||||
expect(fixed.width).toBeCloseTo(fixedW0, 0);
|
||||
expect(Date.now() - started).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
test('board wait covers a pending descendant', async (ctx) => {
|
||||
// The shape-level wait spans the subtree, so a board settles only once
|
||||
// the text nested inside it has been re-measured too.
|
||||
const b = flexBoard(ctx);
|
||||
const t = ctx.penpot.createText('The quick brown fox');
|
||||
if (!t) throw new Error('createText returned null');
|
||||
b.appendChild(t);
|
||||
t.growType = 'auto-width';
|
||||
t.fontSize = '36';
|
||||
await b.waitForLayoutUpdate();
|
||||
const w0 = t.width;
|
||||
|
||||
const font = otherFont(ctx, t);
|
||||
t.fontId = font.fontId;
|
||||
t.fontFamily = font.fontFamily;
|
||||
await b.waitForLayoutUpdate();
|
||||
expect(Math.abs(t.width - w0)).toBeGreaterThan(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Arguments', () => {
|
||||
test('rejects a non-numeric timeout', async (ctx) => {
|
||||
await expectReject(() =>
|
||||
ctx.penpot.waitForLayoutUpdate('soon' as unknown as number),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a non-positive timeout', async (ctx) => {
|
||||
// 0 would mean "reject immediately"; omitting the argument is how a
|
||||
// caller asks for the default.
|
||||
await expectReject(() => ctx.penpot.waitForLayoutUpdate(0));
|
||||
await expectReject(() => ctx.penpot.waitForLayoutUpdate(-1));
|
||||
});
|
||||
|
||||
test('shape method rejects an invalid timeout too', async (ctx) => {
|
||||
const r = ctx.penpot.createRectangle();
|
||||
ctx.board.appendChild(r);
|
||||
await expectReject(() =>
|
||||
r.waitForLayoutUpdate('soon' as unknown as number),
|
||||
);
|
||||
});
|
||||
|
||||
test('an invalid timeout comes back as a rejected promise', async (ctx) => {
|
||||
// `expectReject` accepts a synchronous throw as readily as a rejection,
|
||||
// so a thunk cannot tell the two apart. The signature is `Promise<void>`:
|
||||
// the rejection has to travel on a promise, so call it bare and check
|
||||
// what came back before awaiting it.
|
||||
const r = ctx.penpot.createRectangle();
|
||||
ctx.board.appendChild(r);
|
||||
|
||||
const fromContext = ctx.penpot.waitForLayoutUpdate(-1);
|
||||
expect(fromContext instanceof Promise).toBe(true);
|
||||
await expectReject(fromContext);
|
||||
|
||||
const fromShape = r.waitForLayoutUpdate(0);
|
||||
expect(fromShape instanceof Promise).toBe(true);
|
||||
await expectReject(fromShape);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Timeout', () => {
|
||||
// Pure geometry mutations (resize & co.) are settled synchronously by the
|
||||
// renderer, so right after them nothing is pending and a short-timeout
|
||||
// wait legitimately resolves. The deterministic pending source is a font
|
||||
// change: it marks the text pending until the font is fetched and the
|
||||
// shape re-measured, which can never complete within 1ms. These tests hit
|
||||
// the real backend for the font asset, hence skipIfMocked.
|
||||
|
||||
test.skipIfMocked(
|
||||
'rejects with a timeout error while a font is loading',
|
||||
async (ctx) => {
|
||||
const t = autoWidthText(ctx, 'The quick brown fox');
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
|
||||
// A fresh font change leaves work pending, so a 1ms deadline loses and
|
||||
// the wait rejects. Retried because that work can already have drained
|
||||
// by the time the deadline is set.
|
||||
let rejected = false;
|
||||
for (let i = 0; i < 3 && !rejected; i++) {
|
||||
const font = unloadedFont(ctx, t);
|
||||
t.fontId = font.fontId;
|
||||
t.fontFamily = font.fontFamily;
|
||||
rejected = await ctx.penpot
|
||||
.waitForLayoutUpdate(1)
|
||||
.then(() => false)
|
||||
.catch((e: Error) => /timeout/i.test(e.message));
|
||||
}
|
||||
expect(rejected).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
test.skipIfMocked(
|
||||
'shape-level wait also rejects on timeout',
|
||||
async (ctx) => {
|
||||
const t = autoWidthText(ctx, 'The quick brown fox');
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
|
||||
const font = unloadedFont(ctx, t);
|
||||
t.fontId = font.fontId;
|
||||
t.fontFamily = font.fontFamily;
|
||||
await expectReject(t.waitForLayoutUpdate(1), /timeout/i);
|
||||
},
|
||||
);
|
||||
|
||||
test('resolves normally when work settles before the timeout', async (ctx) => {
|
||||
const { rects } = flexRow(ctx);
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
|
||||
const { left, right } = byX(rects);
|
||||
left.resize(120, 50);
|
||||
await ctx.penpot.waitForLayoutUpdate(5000);
|
||||
expect(right.x - left.x).toBeCloseTo(130, 0);
|
||||
});
|
||||
|
||||
test.skipIfMocked(
|
||||
'a rejected wait leaves later waits working',
|
||||
async (ctx) => {
|
||||
const t = autoWidthText(ctx, 'The quick brown fox');
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
const w0 = t.width;
|
||||
const loaded = { fontId: t.fontId, fontFamily: t.fontFamily };
|
||||
|
||||
// A fresh font change leaves work pending, so a 1ms deadline loses and
|
||||
// the wait rejects. Retried because that work can already have drained
|
||||
// by the time the deadline is set.
|
||||
let rejected = false;
|
||||
for (let i = 0; i < 3 && !rejected; i++) {
|
||||
const font = unloadedFont(ctx, t);
|
||||
t.fontId = font.fontId;
|
||||
t.fontFamily = font.fontFamily;
|
||||
rejected = await ctx.penpot
|
||||
.waitForLayoutUpdate(1)
|
||||
.then(() => false)
|
||||
.catch((e: Error) => /timeout/i.test(e.message));
|
||||
}
|
||||
expect(rejected).toBe(true);
|
||||
|
||||
// A later wait still tracks work and still reports settled geometry.
|
||||
// Measured on the loaded font the text started with: one inside its
|
||||
// block period renders no box, so that text can never grow.
|
||||
t.fontId = loaded.fontId;
|
||||
t.fontFamily = loaded.fontFamily;
|
||||
await ctx.penpot.waitForLayoutUpdate(8000);
|
||||
t.characters =
|
||||
'The quick brown fox jumps over the lazy dog 0123456789 and then some';
|
||||
await ctx.penpot.waitForLayoutUpdate(8000);
|
||||
expect(t.width).toBeGreaterThan(w0);
|
||||
},
|
||||
);
|
||||
|
||||
test.skipIfMocked(
|
||||
'per-shape wait is not blocked by another shape pending work',
|
||||
async (ctx) => {
|
||||
const t = autoWidthText(ctx, 'The quick brown fox');
|
||||
const bystander = ctx.penpot.createRectangle();
|
||||
ctx.board.appendChild(bystander);
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
|
||||
const font = unloadedFont(ctx, t);
|
||||
t.fontId = font.fontId;
|
||||
t.fontFamily = font.fontFamily;
|
||||
// The text has pending font work but the bystander does not; its wait
|
||||
// must resolve on the fast path instead of waiting for the font.
|
||||
const started = Date.now();
|
||||
await bystander.waitForLayoutUpdate();
|
||||
expect(Date.now() - started).toBeLessThan(90);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -3,6 +3,10 @@ import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
// Emit relative asset URLs in index.html so the built plugin works when served
|
||||
// from a subdirectory (Penpot serves the bundled plugins under `/plugins/...`).
|
||||
// Vite resolves `./` to `/` for the dev server, so `pnpm run dev` is unaffected.
|
||||
base: './',
|
||||
server: {
|
||||
port: 4202,
|
||||
host: '0.0.0.0',
|
||||
|
||||
22
plugins/libs/plugin-types/index.d.ts
vendored
22
plugins/libs/plugin-types/index.d.ts
vendored
@ -1350,6 +1350,17 @@ export interface Context {
|
||||
* @return The variant container created
|
||||
*/
|
||||
createVariantFromComponents(shapes: Board[]): VariantContainer;
|
||||
|
||||
/**
|
||||
* This method returns a promise that will be resolved when all the
|
||||
* pending layout updates have finished. If no layout work is pending
|
||||
* the promise resolves immediately.
|
||||
* @param timeout Maximum time to wait, in milliseconds. If the timeout
|
||||
* elapses before the layout settles, the promise is rejected. Defaults to
|
||||
* 30000; the promise never waits indefinitely.
|
||||
* @return The promise to be resolved when the layout is updated
|
||||
*/
|
||||
waitForLayoutUpdate(timeout?: number): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -4096,6 +4107,17 @@ export interface ShapeBase extends PluginData {
|
||||
* Removes the shape from its parent.
|
||||
*/
|
||||
remove(): void;
|
||||
|
||||
/**
|
||||
* This method returns a promise that will be resolved when the pending
|
||||
* layout updates for this shape and its children have finished. If no layout
|
||||
* work is pending for them 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
|
||||
*/
|
||||
waitForLayoutUpdate(timeout?: number): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -388,6 +388,11 @@ export function createApi(
|
||||
checkPermission('content:write');
|
||||
return plugin.context.createVariantFromComponents(shapes);
|
||||
},
|
||||
|
||||
waitForLayoutUpdate(timeout?: number): Promise<void> {
|
||||
checkPermission('content:read');
|
||||
return plugin.context.waitForLayoutUpdate(timeout);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user