mirror of
https://github.com/penpot/penpot.git
synced 2026-08-05 12:29:00 +00:00
✨ Add component synchronization to waitForLayoutUpdate
This commit is contained in:
parent
83a3d099f6
commit
3405aede14
@ -40,6 +40,7 @@
|
|||||||
[app.main.data.workspace.groups :as dwg]
|
[app.main.data.workspace.groups :as dwg]
|
||||||
[app.main.data.workspace.notifications :as-alias dwn]
|
[app.main.data.workspace.notifications :as-alias dwn]
|
||||||
[app.main.data.workspace.pages :as-alias dwpg]
|
[app.main.data.workspace.pages :as-alias dwpg]
|
||||||
|
[app.main.data.workspace.reflow :as wrf]
|
||||||
[app.main.data.workspace.selection :as dws]
|
[app.main.data.workspace.selection :as dws]
|
||||||
[app.main.data.workspace.shapes :as dwsh]
|
[app.main.data.workspace.shapes :as dwsh]
|
||||||
[app.main.data.workspace.specialized-panel :as dwsp]
|
[app.main.data.workspace.specialized-panel :as dwsp]
|
||||||
@ -1129,6 +1130,16 @@
|
|||||||
(def valid-asset-types
|
(def valid-asset-types
|
||||||
#{:colors :components :typographies})
|
#{:colors :components :typographies})
|
||||||
|
|
||||||
|
(defn- sync-file-pending-ids
|
||||||
|
[file-id changes]
|
||||||
|
;; Track the file and every changed page object.
|
||||||
|
(into #{file-id}
|
||||||
|
(comp
|
||||||
|
(filter :page-id)
|
||||||
|
(keep :id)
|
||||||
|
(remove uuid/zero?))
|
||||||
|
(:redo-changes changes)))
|
||||||
|
|
||||||
(defn set-updating-library
|
(defn set-updating-library
|
||||||
[updating?]
|
[updating?]
|
||||||
(ptk/reify ::set-updating-library
|
(ptk/reify ::set-updating-library
|
||||||
@ -1138,6 +1149,32 @@
|
|||||||
(assoc state :updating-library true)
|
(assoc state :updating-library true)
|
||||||
(dissoc state :updating-library)))))
|
(dissoc state :updating-library)))))
|
||||||
|
|
||||||
|
(defn- sync-file-frontend-events
|
||||||
|
[file-id changes updated-frames undo-group]
|
||||||
|
(rx/concat
|
||||||
|
(rx/of (set-updating-library false)
|
||||||
|
(ntf/hide {:tag :sync-dialog}))
|
||||||
|
(when (seq (:redo-changes changes))
|
||||||
|
(rx/of (dch/commit-changes changes)))
|
||||||
|
(when-not (empty? updated-frames)
|
||||||
|
(let [frames-by-page (group-by :page-id updated-frames)]
|
||||||
|
(rx/merge
|
||||||
|
;; Emit one layout/update event for each page.
|
||||||
|
(->> frames-by-page
|
||||||
|
(map (fn [[page-id frames]]
|
||||||
|
(ptk/data-event :layout/update
|
||||||
|
{:page-id page-id
|
||||||
|
:ids (map :id frames)
|
||||||
|
:undo-group undo-group})))
|
||||||
|
(rx/from))
|
||||||
|
(->> (rx/from updated-frames)
|
||||||
|
(rx/mapcat
|
||||||
|
(fn [shape]
|
||||||
|
(rx/of
|
||||||
|
(dwt/clear-thumbnail file-id (:page-id shape) (:id shape) "frame")
|
||||||
|
(when-not (= (:frame-id shape) uuid/zero)
|
||||||
|
(dwt/clear-thumbnail file-id (:page-id shape) (:frame-id shape) "frame")))))))))))
|
||||||
|
|
||||||
(defn sync-file
|
(defn sync-file
|
||||||
"Synchronize the given file from the given library. Walk through all
|
"Synchronize the given file from the given library. Walk through all
|
||||||
shapes in all pages in the file that use some color, typography or
|
shapes in all pages in the file that use some color, typography or
|
||||||
@ -1196,35 +1233,20 @@
|
|||||||
updated-frames (->> changes
|
updated-frames (->> changes
|
||||||
:redo-changes
|
:redo-changes
|
||||||
(mapcat find-frames)
|
(mapcat find-frames)
|
||||||
distinct)]
|
distinct)
|
||||||
|
|
||||||
|
pending-ids (sync-file-pending-ids file-id changes)
|
||||||
|
|
||||||
|
frontend-sync
|
||||||
|
(sync-file-frontend-events
|
||||||
|
file-id changes updated-frames undo-group)]
|
||||||
|
|
||||||
(log/debug :msg "SYNC-FILE finished" :js/rchanges (log-changes
|
(log/debug :msg "SYNC-FILE finished" :js/rchanges (log-changes
|
||||||
(:redo-changes changes)
|
(:redo-changes changes)
|
||||||
ldata))
|
ldata))
|
||||||
(rx/concat
|
(rx/concat
|
||||||
(rx/of (set-updating-library false)
|
;; Keep the sync pending until its layout work starts.
|
||||||
(ntf/hide {:tag :sync-dialog}))
|
(wrf/with-pending :sync-file pending-ids frontend-sync)
|
||||||
(when (seq (:redo-changes changes))
|
|
||||||
(rx/of (dch/commit-changes changes)))
|
|
||||||
(when-not (empty? updated-frames)
|
|
||||||
(let [frames-by-page (->> updated-frames
|
|
||||||
(group-by :page-id))]
|
|
||||||
(rx/merge
|
|
||||||
;; Emit one layout/update event for each page
|
|
||||||
(rx/from
|
|
||||||
(map (fn [[page-id frames]]
|
|
||||||
(ptk/data-event :layout/update
|
|
||||||
{:page-id page-id
|
|
||||||
:ids (map :id frames)
|
|
||||||
:undo-group undo-group}))
|
|
||||||
frames-by-page))
|
|
||||||
(->> (rx/from updated-frames)
|
|
||||||
(rx/mapcat
|
|
||||||
(fn [shape]
|
|
||||||
(rx/of
|
|
||||||
(dwt/clear-thumbnail file-id (:page-id shape) (:id shape) "frame")
|
|
||||||
(when-not (= (:frame-id shape) uuid/zero)
|
|
||||||
(dwt/clear-thumbnail file-id (:page-id shape) (:frame-id shape) "frame")))))))))
|
|
||||||
|
|
||||||
(when (not= file-id library-id)
|
(when (not= file-id library-id)
|
||||||
;; When we have just updated the library file, give some time for the
|
;; When we have just updated the library file, give some time for the
|
||||||
@ -1400,66 +1422,88 @@
|
|||||||
(rx/buffer 2 1)
|
(rx/buffer 2 1)
|
||||||
(rx/map first))
|
(rx/map first))
|
||||||
|
|
||||||
changes-s
|
;; Barriers open before async inspection and close after detection.
|
||||||
|
pending-sync-barriers* (atom #{})
|
||||||
|
|
||||||
|
start-sync-barrier
|
||||||
|
(fn [{:keys [file-id save-undo?] :as event}]
|
||||||
|
(let [task (when (and save-undo? (uuid? file-id))
|
||||||
|
(wrf/start! :sync-file [file-id]))]
|
||||||
|
(when task
|
||||||
|
(swap! pending-sync-barriers* conj task))
|
||||||
|
[event task]))
|
||||||
|
|
||||||
|
finish-sync-barrier!
|
||||||
|
(fn [task]
|
||||||
|
(when task
|
||||||
|
(wrf/finish! task)
|
||||||
|
(swap! pending-sync-barriers* disj task)))
|
||||||
|
|
||||||
|
commits-s
|
||||||
(->> stream
|
(->> stream
|
||||||
(rx/filter dch/commit?)
|
(rx/filter dch/commit?)
|
||||||
(rx/map deref)
|
(rx/map deref)
|
||||||
(rx/filter #(= :local (:source %)))
|
(rx/filter #(= :local (:source %)))
|
||||||
|
;; Translation commits never propagate component changes.
|
||||||
|
(rx/filter (complement :translation?))
|
||||||
|
;; Keep waits pending while component changes are checked.
|
||||||
|
(rx/map start-sync-barrier)
|
||||||
(rx/observe-on :async))
|
(rx/observe-on :async))
|
||||||
|
|
||||||
check-changes
|
get-component-events
|
||||||
(fn [[event old-data]]
|
(fn [[event old-data]]
|
||||||
(cond
|
(let [{:keys [file-id changes save-undo? undo-group]} event
|
||||||
(nil? old-data)
|
changed-components
|
||||||
(rx/empty)
|
(when (and old-data
|
||||||
|
(or (nil? file-id) (= file-id (:id old-data))))
|
||||||
|
(into #{}
|
||||||
|
(mapcat (partial ch/components-changed old-data))
|
||||||
|
changes))]
|
||||||
|
(cond
|
||||||
|
(empty? changed-components)
|
||||||
|
(rx/empty)
|
||||||
|
|
||||||
(:translation? event)
|
save-undo?
|
||||||
(rx/empty)
|
(do
|
||||||
|
(log/info :hint "detected component changes"
|
||||||
|
:ids (map str changed-components)
|
||||||
|
:undo-group undo-group)
|
||||||
|
(->> (rx/from changed-components)
|
||||||
|
(rx/map #(component-changed
|
||||||
|
% (:id old-data) undo-group))))
|
||||||
|
|
||||||
:else
|
:else
|
||||||
(let [{:keys [file-id changes save-undo? undo-group]} event
|
;; Undos only bump :modified-at.
|
||||||
|
(->> (rx/from changed-components)
|
||||||
|
(rx/map touch-component)))))
|
||||||
|
|
||||||
changed-components
|
component-events-s
|
||||||
(when (or (nil? file-id) (= file-id (:id old-data)))
|
(->> commits-s
|
||||||
(->> changes
|
|
||||||
(map (partial ch/components-changed old-data))
|
|
||||||
(reduce into #{})))]
|
|
||||||
|
|
||||||
(if (d/not-empty? changed-components)
|
|
||||||
(if save-undo?
|
|
||||||
(do (log/info :hint "detected component changes"
|
|
||||||
:ids (map str changed-components)
|
|
||||||
:undo-group undo-group)
|
|
||||||
(->> (rx/from changed-components)
|
|
||||||
(rx/map #(component-changed % (:id old-data) undo-group))))
|
|
||||||
;; save-undo? false (undos): just bump :modified-at
|
|
||||||
(->> (rx/from changed-components)
|
|
||||||
(rx/map touch-component)))
|
|
||||||
|
|
||||||
(rx/empty)))))
|
|
||||||
|
|
||||||
changes-s
|
|
||||||
(->> changes-s
|
|
||||||
(rx/with-latest-from workspace-buffer-s)
|
(rx/with-latest-from workspace-buffer-s)
|
||||||
(rx/mapcat check-changes)
|
(rx/mapcat
|
||||||
|
(fn [[[event task] old-data]]
|
||||||
|
(->> (get-component-events [event old-data])
|
||||||
|
(rx/finalize #(finish-sync-barrier! task)))))
|
||||||
|
;; Close barriers left behind when the page shuts down.
|
||||||
|
(rx/finalize #(wrf/finish-tasks! @pending-sync-barriers*))
|
||||||
(rx/share))
|
(rx/share))
|
||||||
|
|
||||||
notifier-s
|
notifier-s
|
||||||
(->> changes-s
|
(->> component-events-s
|
||||||
(rx/debounce 5000)
|
(rx/debounce 5000)
|
||||||
(rx/tap #(log/trc :hint "buffer initialized")))]
|
(rx/tap #(log/trc :hint "buffer initialized")))]
|
||||||
|
|
||||||
(when (or (contains? cf/flags :component-thumbnails)
|
(when (or (contains? cf/flags :component-thumbnails)
|
||||||
(features/active-feature? state "render-wasm/v1"))
|
(features/active-feature? state "render-wasm/v1"))
|
||||||
(->> (rx/merge
|
(->> (rx/merge
|
||||||
changes-s
|
component-events-s
|
||||||
|
|
||||||
;; WASM only: render the thumbnail on every component
|
;; WASM only: render the thumbnail on every component
|
||||||
;; change so single edits (fill, etc.) update instantly.
|
;; change so single edits (fill, etc.) update instantly.
|
||||||
;; Non-WASM persists on every render, so it stays on the
|
;; Non-WASM persists on every render, so it stays on the
|
||||||
;; debounced path below to avoid per-edit backend posts.
|
;; debounced path below to avoid per-edit backend posts.
|
||||||
(if (features/active-feature? state "render-wasm/v1")
|
(if (features/active-feature? state "render-wasm/v1")
|
||||||
(->> changes-s
|
(->> component-events-s
|
||||||
(rx/filter (ptk/type? ::component-changed))
|
(rx/filter (ptk/type? ::component-changed))
|
||||||
(rx/map deref)
|
(rx/map deref)
|
||||||
(rx/map render-component-thumbnail-event))
|
(rx/map render-component-thumbnail-event))
|
||||||
@ -1467,7 +1511,7 @@
|
|||||||
|
|
||||||
;; Persist to the server in batches, 5s after the user
|
;; Persist to the server in batches, 5s after the user
|
||||||
;; goes idle.
|
;; goes idle.
|
||||||
(->> changes-s
|
(->> component-events-s
|
||||||
(rx/filter (ptk/type? ::component-changed))
|
(rx/filter (ptk/type? ::component-changed))
|
||||||
(rx/map deref)
|
(rx/map deref)
|
||||||
(rx/buffer-until notifier-s)
|
(rx/buffer-until notifier-s)
|
||||||
@ -1476,7 +1520,7 @@
|
|||||||
(update-component-thumbnail component-id file-id))))
|
(update-component-thumbnail component-id file-id))))
|
||||||
|
|
||||||
;; Undo/redo emit touch-component instead.
|
;; Undo/redo emit touch-component instead.
|
||||||
(->> changes-s
|
(->> component-events-s
|
||||||
(rx/filter (ptk/type? ::touch-component))
|
(rx/filter (ptk/type? ::touch-component))
|
||||||
(rx/map deref)
|
(rx/map deref)
|
||||||
(rx/map render-component-thumbnail-event)))
|
(rx/map render-component-thumbnail-event)))
|
||||||
@ -1631,5 +1675,3 @@
|
|||||||
(rx/mapcat (fn [_]
|
(rx/mapcat (fn [_]
|
||||||
(rp/cmd! :get-file-libraries {:file-id file-id})))
|
(rp/cmd! :get-file-libraries {:file-id file-id})))
|
||||||
(rx/map (partial cleanup-unlinked-libraries file-id))))))
|
(rx/map (partial cleanup-unlinked-libraries file-id))))))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
[app.main.broadcast :as mbc]
|
[app.main.broadcast :as mbc]
|
||||||
[app.main.data.plugins :as dp]
|
[app.main.data.plugins :as dp]
|
||||||
[app.main.data.profile :as du]
|
[app.main.data.profile :as du]
|
||||||
|
[app.main.data.workspace :as-alias dw]
|
||||||
[app.main.store :as st]
|
[app.main.store :as st]
|
||||||
[app.plugins.register :as preg]
|
[app.plugins.register :as preg]
|
||||||
[app.util.timers :as ts]
|
[app.util.timers :as ts]
|
||||||
@ -132,7 +133,7 @@
|
|||||||
(assoc :host (str (u/join cf/public-uri "plugins/mcp/"))))
|
(assoc :host (str (u/join cf/public-uri "plugins/mcp/"))))
|
||||||
|
|
||||||
stopper-s (rx/merge
|
stopper-s (rx/merge
|
||||||
(rx/filter (ptk/type? :app.main.data.workspace/finalize-workspace) stream)
|
(rx/filter (ptk/type? ::dw/finalize-workspace) stream)
|
||||||
(rx/filter (ptk/type? ::stop-mcp-plugin) stream))
|
(rx/filter (ptk/type? ::stop-mcp-plugin) stream))
|
||||||
|
|
||||||
extension #js {:getToken (constantly token)
|
extension #js {:getToken (constantly token)
|
||||||
@ -202,7 +203,7 @@
|
|||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ state stream]
|
(watch [_ state stream]
|
||||||
(let [stopper-s (rx/merge
|
(let [stopper-s (rx/merge
|
||||||
(rx/filter (ptk/type? :app.main.data.workspace/finalize-workspace) stream)
|
(rx/filter (ptk/type? ::dw/finalize-workspace) stream)
|
||||||
(rx/filter (ptk/type? ::init) stream))
|
(rx/filter (ptk/type? ::init) stream))
|
||||||
|
|
||||||
session-id (get state :session-id)
|
session-id (get state :session-id)
|
||||||
|
|||||||
@ -5,11 +5,13 @@
|
|||||||
;; Copyright (c) KALEIDOS INC
|
;; Copyright (c) KALEIDOS INC
|
||||||
|
|
||||||
(ns app.main.data.workspace.reflow
|
(ns app.main.data.workspace.reflow
|
||||||
"Tracks the shape ids that have layout/reflow work in flight, broken down by
|
"Tracks the ids that have layout/reflow work in flight, broken down by the
|
||||||
the kind of work so we can tell which type of reflow is blocking each shape.
|
kind of work so we can tell which type of reflow is blocking each id.
|
||||||
|
|
||||||
Pending work is stored as `{shape-id -> {kind -> #{task-id}}}`. Every producer
|
Pending work is stored as `{id -> {kind -> #{task-id}}}`, where ids are page
|
||||||
opens an exact task with `start!` and closes that same task with `finish!`.
|
object ids plus, for `:sync-file`, the id of the file being synced. Every
|
||||||
|
producer opens an exact task with `start!` and closes that same task with
|
||||||
|
`finish!`.
|
||||||
Tasks belong to a workspace generation, so a delayed completion from a
|
Tasks belong to a workspace generation, so a delayed completion from a
|
||||||
finalized workspace cannot drain work opened after the workspace reloads.
|
finalized workspace cannot drain work opened after the workspace reloads.
|
||||||
|
|
||||||
@ -21,8 +23,10 @@
|
|||||||
:layout flex/grid layout reflow (shape-layout)
|
:layout flex/grid layout reflow (shape-layout)
|
||||||
:text-resize text geometry resize (wasm-text, texts)
|
:text-resize text geometry resize (wasm-text, texts)
|
||||||
:text-measure DOM text measurement (texts)
|
:text-measure DOM text measurement (texts)
|
||||||
|
:text-position DOM text fragment geometry (texts)
|
||||||
:text-bridge change awaiting its pipeline (texts)
|
:text-bridge change awaiting its pipeline (texts)
|
||||||
:font font change measurement (texts)"
|
:font font change measurement (texts)
|
||||||
|
:sync-file component/library propagation (libraries)"
|
||||||
(:require
|
(:require
|
||||||
[beicon.v2.core :as rx]
|
[beicon.v2.core :as rx]
|
||||||
[promesa.core :as p]))
|
[promesa.core :as p]))
|
||||||
@ -55,22 +59,35 @@
|
|||||||
acc
|
acc
|
||||||
ids)))
|
ids)))
|
||||||
|
|
||||||
|
;; Single-task operations are wrapped as batches before reaching the reducer.
|
||||||
(defn- reducer
|
(defn- reducer
|
||||||
[acc {:keys [op task ids]}]
|
[acc {:keys [op tasks ids]}]
|
||||||
(case op
|
(case op
|
||||||
:add (add-task acc task)
|
:add (reduce add-task acc tasks)
|
||||||
:remove (remove-task acc task)
|
:remove (reduce remove-task acc tasks)
|
||||||
:cancel (apply dissoc acc ids)
|
:cancel (apply dissoc acc ids)
|
||||||
:reset {}
|
:reset {}
|
||||||
acc))
|
acc))
|
||||||
|
|
||||||
;; Behaviour subject holding `{shape-id -> {kind -> #{task-id}}}`.
|
;; Holds pending tasks and replays them to new waiters.
|
||||||
;; It replays its current value synchronously to new subscribers, which gives
|
;; Reloads rebuild the scan with the latest reducer.
|
||||||
;; `wait-for-layout-update` a free fast-path when there is nothing pending.
|
(def ^:private pending-shapes (rx/behavior-subject {}))
|
||||||
(defonce ^:private pending-shapes
|
|
||||||
(let [sub (rx/behavior-subject {})]
|
(defonce ^:private pending-subscription (atom nil))
|
||||||
(rx/sub! (->> reflow-input (rx/scan reducer {})) sub)
|
|
||||||
sub))
|
(defn- install-pending-subscription!
|
||||||
|
[]
|
||||||
|
;; Settle the old scan before installing the new one.
|
||||||
|
(swap! workspace-generation inc)
|
||||||
|
(rx/push! reflow-input {:op :reset})
|
||||||
|
(when-let [subscription @pending-subscription]
|
||||||
|
(rx/dispose! subscription))
|
||||||
|
(reset! pending-subscription
|
||||||
|
(rx/sub! (->> reflow-input (rx/scan reducer {}))
|
||||||
|
pending-shapes))
|
||||||
|
(rx/push! reflow-input {:op :reset}))
|
||||||
|
|
||||||
|
(install-pending-subscription!)
|
||||||
|
|
||||||
(defn task
|
(defn task
|
||||||
"Creates an opaque task token without opening it."
|
"Creates an opaque task token without opening it."
|
||||||
@ -80,24 +97,42 @@
|
|||||||
:kind kind
|
:kind kind
|
||||||
:ids (into #{} ids)})
|
:ids (into #{} ids)})
|
||||||
|
|
||||||
|
(defn- push-tasks!
|
||||||
|
[op tasks]
|
||||||
|
;; Empty and stale tasks must not affect the active workspace.
|
||||||
|
(let [generation @workspace-generation
|
||||||
|
tasks (into [] (filter #(and (seq (:ids %))
|
||||||
|
(= (:generation %) generation)))
|
||||||
|
tasks)]
|
||||||
|
(when (seq tasks)
|
||||||
|
(rx/push! reflow-input {:op op :tasks tasks}))
|
||||||
|
tasks))
|
||||||
|
|
||||||
|
(defn- start-tasks!
|
||||||
|
"Opens task tokens in one pending-map update."
|
||||||
|
[tasks]
|
||||||
|
(push-tasks! :add tasks))
|
||||||
|
|
||||||
(defn start!
|
(defn start!
|
||||||
"Opens and returns a task. The one-argument form opens a token created with
|
"Opens and returns a task. The one-argument form opens a token created with
|
||||||
`task`; the two-argument form creates and opens it in one step."
|
`task`; the two-argument form creates and opens it in one step."
|
||||||
([task]
|
([task]
|
||||||
(when (and (seq (:ids task))
|
(push-tasks! :add [task])
|
||||||
(= (:generation task) @workspace-generation))
|
|
||||||
(rx/push! reflow-input {:op :add :task task}))
|
|
||||||
task)
|
task)
|
||||||
([kind ids]
|
([kind ids]
|
||||||
(start! (task kind ids))))
|
(start! (task kind ids))))
|
||||||
|
|
||||||
|
(defn finish-tasks!
|
||||||
|
"Closes task tokens from the active workspace generation in one update."
|
||||||
|
[tasks]
|
||||||
|
(push-tasks! :remove tasks)
|
||||||
|
nil)
|
||||||
|
|
||||||
(defn finish!
|
(defn finish!
|
||||||
"Closes `task` if it belongs to the active workspace generation. Repeated or
|
"Closes `task` if it belongs to the active workspace generation. Repeated or
|
||||||
stale completion is a no-op."
|
stale completion is a no-op."
|
||||||
[{:keys [generation ids] :as task}]
|
[task]
|
||||||
(when (and (seq ids)
|
(finish-tasks! [task]))
|
||||||
(= generation @workspace-generation))
|
|
||||||
(rx/push! reflow-input {:op :remove :task task})))
|
|
||||||
|
|
||||||
(defn reset-pending!
|
(defn reset-pending!
|
||||||
"Starts a new workspace generation and forgets every task from the old one."
|
"Starts a new workspace generation and forgets every task from the old one."
|
||||||
@ -136,59 +171,77 @@
|
|||||||
(finish! task)
|
(finish! task)
|
||||||
(throw cause)))))
|
(throw cause)))))
|
||||||
|
|
||||||
(defn pending-signal
|
(defn bridge-pending
|
||||||
"Emits once any of `kinds` is pending for any of `ids`, then completes.
|
"Keeps each id pending until matching work starts."
|
||||||
Emits right away when that work is already in flight."
|
[ids target-kinds bridge-kind]
|
||||||
[ids kinds]
|
(let [ids (into #{} ids)]
|
||||||
(letfn [(id-pending? [pending id]
|
(if (empty? ids)
|
||||||
(some (partial contains? (get pending id)) kinds))
|
(rx/empty)
|
||||||
|
(rx/create
|
||||||
|
(fn [subs]
|
||||||
|
;; Separate tasks let renderer work release each shape independently.
|
||||||
|
(let [tasks-by-id
|
||||||
|
(into {} (map (fn [id] [id (task bridge-kind [id])])) ids)
|
||||||
|
|
||||||
(any-pending? [pending]
|
remaining
|
||||||
(some (partial id-pending? pending) ids))]
|
(atom ids)
|
||||||
(->> pending-shapes
|
|
||||||
(rx/filter any-pending?)
|
|
||||||
(rx/take 1))))
|
|
||||||
|
|
||||||
;; Ceiling for callers that pass no timeout, so a pipeline that never drains
|
release!
|
||||||
;; its marks rejects the promise rather than leaving it unsettled.
|
(fn [released]
|
||||||
(def ^:private default-timeout 30000)
|
(let [released (into #{} (filter @remaining) released)]
|
||||||
|
(when (seq released)
|
||||||
|
(finish-tasks! (map tasks-by-id released))
|
||||||
|
(swap! remaining #(apply disj % released))
|
||||||
|
(when (empty? @remaining)
|
||||||
|
(rx/end! subs)))))
|
||||||
|
|
||||||
(defn wait-for-layout-update
|
matching-task-ids
|
||||||
"Returns a JS Promise that resolves when every id in `shape-ids` has drained
|
(fn [tasks]
|
||||||
from the pending map. A nil `shape-ids` waits for every pending shape; an
|
(into #{}
|
||||||
empty one has nothing to wait for and resolves right away. The promise is
|
(comp
|
||||||
rejected when `timeout` (ms) elapses first; a nil `timeout` uses
|
(filter #(contains? target-kinds (:kind %)))
|
||||||
`default-timeout`.
|
(mapcat :ids)
|
||||||
|
(filter ids))
|
||||||
|
tasks))
|
||||||
|
|
||||||
|
;; Listen before opening bridges so synchronous work is not missed.
|
||||||
|
lifecycle-sub
|
||||||
|
(rx/sub!
|
||||||
|
reflow-input
|
||||||
|
(fn [{:keys [op tasks ids]}]
|
||||||
|
(case op
|
||||||
|
:add
|
||||||
|
(release! (matching-task-ids tasks))
|
||||||
|
|
||||||
|
:cancel
|
||||||
|
(release! ids)
|
||||||
|
|
||||||
|
:reset
|
||||||
|
(release! @remaining)
|
||||||
|
|
||||||
|
nil)))
|
||||||
|
|
||||||
|
_
|
||||||
|
(start-tasks! (vals tasks-by-id))]
|
||||||
|
(fn []
|
||||||
|
(rx/dispose! lifecycle-sub)
|
||||||
|
(when (seq @remaining)
|
||||||
|
(finish-tasks! (map tasks-by-id @remaining))
|
||||||
|
(reset! remaining #{})))))))))
|
||||||
|
|
||||||
|
(defn settled
|
||||||
|
"Observable that emits once every id in `ids` has drained from the pending
|
||||||
|
map, then completes. A nil `ids` waits for every pending id; an empty one has
|
||||||
|
nothing to wait for. Replays on subscribe, so an already drained map emits
|
||||||
|
immediately.
|
||||||
|
|
||||||
Callers waiting on one shape pass its whole subtree: reflow work lands either
|
Callers waiting on one shape pass its whole subtree: reflow work lands either
|
||||||
on the shape (a board laying out its children) or on its descendants (a group
|
on the shape (a board laying out its children) or on its descendants (a group
|
||||||
whose texts are re-measured)."
|
whose texts are re-measured)."
|
||||||
([timeout]
|
[ids]
|
||||||
(wait-for-layout-update nil timeout))
|
(let [done? (if (some? ids)
|
||||||
([shape-ids timeout]
|
(fn [pending] (not-any? #(contains? pending %) ids))
|
||||||
(js/Promise.
|
empty?)]
|
||||||
(fn [resolve reject]
|
(->> pending-shapes
|
||||||
(let [timeout (or timeout default-timeout)
|
(rx/filter done?)
|
||||||
|
(rx/take 1))))
|
||||||
done? (if (some? shape-ids)
|
|
||||||
(fn [pending] (not-any? #(contains? pending %) shape-ids))
|
|
||||||
empty?)
|
|
||||||
|
|
||||||
settled (->> pending-shapes
|
|
||||||
(rx/filter done?)
|
|
||||||
(rx/map (constantly :ok)))
|
|
||||||
|
|
||||||
;; Race the settle signal against the deadline; the loser is
|
|
||||||
;; unsubscribed. `settled` replays on subscribe, so an already
|
|
||||||
;; drained map wins even against a 1ms deadline.
|
|
||||||
source (rx/race (->> (rx/of :timeout)
|
|
||||||
(rx/delay timeout))
|
|
||||||
settled)]
|
|
||||||
(->> source
|
|
||||||
(rx/take 1)
|
|
||||||
(rx/subs!
|
|
||||||
(fn [value]
|
|
||||||
(if (= value :timeout)
|
|
||||||
(reject (js/Error. "waitForLayoutUpdate timeout"))
|
|
||||||
(resolve)))
|
|
||||||
reject)))))))
|
|
||||||
|
|||||||
141
frontend/src/app/main/data/workspace/reflow/signals.cljs
Normal file
141
frontend/src/app/main/data/workspace/reflow/signals.cljs
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
;;
|
||||||
|
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||||
|
|
||||||
|
(ns app.main.data.workspace.reflow.signals
|
||||||
|
"Decides which reflow signals a shape update raises: `:layout/update` for the
|
||||||
|
shapes whose layout attrs changed, `:text/reflow` for the texts the renderer
|
||||||
|
has to re-measure.
|
||||||
|
|
||||||
|
Which text attrs matter depends on the renderer: the DOM one measures every
|
||||||
|
changed text, so its own geometry counts as a change; wasm only resizes
|
||||||
|
auto-sized texts from their content."
|
||||||
|
(:require
|
||||||
|
[app.common.data :as d]
|
||||||
|
[app.common.data.macros :as dm]
|
||||||
|
[app.common.files.changes-builder :as pcb]
|
||||||
|
[app.common.files.helpers :as cfh]
|
||||||
|
[app.common.math :as mth]
|
||||||
|
[app.main.features :as features]))
|
||||||
|
|
||||||
|
;; If anything a translation can mutate is added here, drop the
|
||||||
|
;; `(when-not translation? …)` guard in `update-shapes`.
|
||||||
|
(def ^:private update-layout-attr? #{:hidden})
|
||||||
|
|
||||||
|
;; Text attrs that can start async renderer work.
|
||||||
|
(def ^:private text-reflow-attr?
|
||||||
|
#{:content :grow-type :x :y :width :height})
|
||||||
|
|
||||||
|
(def ^:private wasm-text-reflow-attr?
|
||||||
|
#{:content :grow-type})
|
||||||
|
|
||||||
|
(def ^:private dom-text-geometry-reflow-attr?
|
||||||
|
#{:x :y :width :height})
|
||||||
|
|
||||||
|
(defn- renderer-text-reflow-attr?
|
||||||
|
[state]
|
||||||
|
(if (features/active-feature? state "render-wasm/v1")
|
||||||
|
wasm-text-reflow-attr?
|
||||||
|
text-reflow-attr?))
|
||||||
|
|
||||||
|
(defn- reflow-attr?
|
||||||
|
[state attr]
|
||||||
|
(or (update-layout-attr? attr)
|
||||||
|
((renderer-text-reflow-attr? state) attr)))
|
||||||
|
|
||||||
|
;; Caller metadata can rule out reflow before objects are compared.
|
||||||
|
(defn- reflow-candidate?
|
||||||
|
[attr? {:keys [attrs translation? update-layout?]
|
||||||
|
:or {update-layout? true}}]
|
||||||
|
(and update-layout?
|
||||||
|
(not translation?)
|
||||||
|
(or (nil? attrs)
|
||||||
|
(some attr? attrs))))
|
||||||
|
|
||||||
|
(defn- text-reflow-changed?
|
||||||
|
[state shape changed-shape changed]
|
||||||
|
;; Match the DOM renderer's geometry checks.
|
||||||
|
(let [wasm? (features/active-feature? state "render-wasm/v1")
|
||||||
|
reflow-attr? (renderer-text-reflow-attr? state)]
|
||||||
|
(some
|
||||||
|
(fn [attr]
|
||||||
|
(and (reflow-attr? attr)
|
||||||
|
(or wasm?
|
||||||
|
(not (dom-text-geometry-reflow-attr? attr))
|
||||||
|
(not (mth/close? (get shape attr)
|
||||||
|
(get changed-shape attr))))))
|
||||||
|
changed)))
|
||||||
|
|
||||||
|
(defn- async-text-reflow?
|
||||||
|
"Whether `shape` enters an asynchronous text geometry pipeline. The HTML
|
||||||
|
renderer measures every changed text; WASM only resizes auto-sized texts.
|
||||||
|
A grow-type transition is included because `shape` is the value before the
|
||||||
|
update and may still be fixed."
|
||||||
|
[state shape changed]
|
||||||
|
(and (cfh/text-shape? shape)
|
||||||
|
(or (not (features/active-feature? state "render-wasm/v1"))
|
||||||
|
(not= :fixed (:grow-type shape))
|
||||||
|
(contains? changed :grow-type))))
|
||||||
|
|
||||||
|
(defn- get-reflow-changes
|
||||||
|
[state objects changed-objects ids {:keys [attrs] :as props}]
|
||||||
|
;; Reuse built objects so update functions only run once.
|
||||||
|
(let [reflow-attr? (partial reflow-attr? state)]
|
||||||
|
(when (reflow-candidate? reflow-attr? props)
|
||||||
|
(into []
|
||||||
|
(comp
|
||||||
|
(map (d/getf objects))
|
||||||
|
(keep (fn [shape]
|
||||||
|
(let [changed-shape (get changed-objects (:id shape))
|
||||||
|
changed (pcb/changed-attrs
|
||||||
|
shape objects (constantly changed-shape)
|
||||||
|
{:attrs attrs})]
|
||||||
|
(when (some reflow-attr? changed)
|
||||||
|
[shape changed-shape changed])))))
|
||||||
|
ids))))
|
||||||
|
|
||||||
|
(defn- get-layout-reflow-ids
|
||||||
|
[reflow-changes]
|
||||||
|
(->> reflow-changes
|
||||||
|
(into [] (comp (filter (fn [[_ _ changed]] (some update-layout-attr? changed)))
|
||||||
|
(map (comp :id first))))
|
||||||
|
(not-empty)))
|
||||||
|
|
||||||
|
(defn- get-text-reflow-ids
|
||||||
|
[state page-id reflow-changes]
|
||||||
|
;; Track measurable texts on the active page.
|
||||||
|
(when (= page-id (get state :current-page-id))
|
||||||
|
(let [edition (dm/get-in state [:workspace-local :edition])]
|
||||||
|
(->> reflow-changes
|
||||||
|
(into [] (comp (filter (fn [[shape changed-shape changed]]
|
||||||
|
(and (async-text-reflow? state shape changed)
|
||||||
|
(text-reflow-changed?
|
||||||
|
state shape changed-shape changed))))
|
||||||
|
(map (comp :id first))
|
||||||
|
(remove #(= % edition))))
|
||||||
|
(not-empty)))))
|
||||||
|
|
||||||
|
(defn reflow-ids
|
||||||
|
"Ids a shape update has to signal: `:layout-ids` for `:layout/update`,
|
||||||
|
`:text-ids` for `:text/reflow`. Both are nil when nothing changed.
|
||||||
|
|
||||||
|
Both sets come from one comparison pass, so `update-fn` and the attribute
|
||||||
|
diff only run once per shape."
|
||||||
|
[state page-id objects changed-objects ids props]
|
||||||
|
(let [reflow-changes (get-reflow-changes state objects changed-objects ids props)]
|
||||||
|
{:layout-ids (get-layout-reflow-ids reflow-changes)
|
||||||
|
:text-ids (get-text-reflow-ids state page-id reflow-changes)}))
|
||||||
|
|
||||||
|
(defn text-reflow-candidate?
|
||||||
|
"Whether `props` can start renderer text work, judged from the caller metadata
|
||||||
|
alone. Cheap pre-filter for callers that buffer updates before they have
|
||||||
|
objects to compare."
|
||||||
|
[state props]
|
||||||
|
(reflow-candidate? (renderer-text-reflow-attr? state) props))
|
||||||
|
|
||||||
|
(defn new-text-reflow?
|
||||||
|
"Whether a newly added `shape` enters an asynchronous text geometry pipeline."
|
||||||
|
[state shape]
|
||||||
|
(async-text-reflow? state shape nil))
|
||||||
@ -29,6 +29,7 @@
|
|||||||
[app.main.data.workspace.undo :as dwu]
|
[app.main.data.workspace.undo :as dwu]
|
||||||
[app.main.data.workspace.viewport-wasm :as dwvw]
|
[app.main.data.workspace.viewport-wasm :as dwvw]
|
||||||
[app.main.data.workspace.zoom :as dwz]
|
[app.main.data.workspace.zoom :as dwz]
|
||||||
|
[app.main.features :as features]
|
||||||
[app.main.refs :as refs]
|
[app.main.refs :as refs]
|
||||||
[app.main.router :as rt]
|
[app.main.router :as rt]
|
||||||
[app.main.streams :as ms]
|
[app.main.streams :as ms]
|
||||||
@ -452,6 +453,16 @@
|
|||||||
|
|
||||||
(gpt/subtract new-pos pt-obj)))))
|
(gpt/subtract new-pos pt-obj)))))
|
||||||
|
|
||||||
|
(defn- get-new-dom-text-ids
|
||||||
|
[state changes]
|
||||||
|
(when-not (features/active-feature? state "render-wasm/v1")
|
||||||
|
(->> (:redo-changes changes)
|
||||||
|
(keep (fn [{:keys [type obj]}]
|
||||||
|
(when (and (= type :add-obj)
|
||||||
|
(cfh/text-shape? obj))
|
||||||
|
(:id obj))))
|
||||||
|
(not-empty))))
|
||||||
|
|
||||||
(defn duplicate-shapes
|
(defn duplicate-shapes
|
||||||
[ids & {:keys [move-delta? alt-duplication? change-selection? return-ref]
|
[ids & {:keys [move-delta? alt-duplication? change-selection? return-ref]
|
||||||
:or {move-delta? false alt-duplication? false change-selection? true return-ref nil}}]
|
:or {move-delta? false alt-duplication? false change-selection? true return-ref nil}}]
|
||||||
@ -493,6 +504,9 @@
|
|||||||
(map #(get-in % [:obj :id]))
|
(map #(get-in % [:obj :id]))
|
||||||
(into (d/ordered-set)))
|
(into (d/ordered-set)))
|
||||||
|
|
||||||
|
new-dom-text-ids
|
||||||
|
(get-new-dom-text-ids state changes)
|
||||||
|
|
||||||
id-duplicated (first new-ids)
|
id-duplicated (first new-ids)
|
||||||
|
|
||||||
frames (into #{}
|
frames (into #{}
|
||||||
@ -531,6 +545,11 @@
|
|||||||
;; Warning: This order is important for the focus mode.
|
;; Warning: This order is important for the focus mode.
|
||||||
(->> (rx/of
|
(->> (rx/of
|
||||||
(dwu/start-undo-transaction undo-id)
|
(dwu/start-undo-transaction undo-id)
|
||||||
|
;; Track cloned texts before they mount.
|
||||||
|
(when new-dom-text-ids
|
||||||
|
(ptk/data-event :text/reflow
|
||||||
|
{:ids new-dom-text-ids
|
||||||
|
:page-id (:id page)}))
|
||||||
(dch/commit-changes changes)
|
(dch/commit-changes changes)
|
||||||
(when change-selection?
|
(when change-selection?
|
||||||
(select-shapes new-ids))
|
(select-shapes new-ids))
|
||||||
|
|||||||
@ -23,6 +23,7 @@
|
|||||||
[app.main.data.changes :as dch]
|
[app.main.data.changes :as dch]
|
||||||
[app.main.data.event :as ev]
|
[app.main.data.event :as ev]
|
||||||
[app.main.data.helpers :as dsh]
|
[app.main.data.helpers :as dsh]
|
||||||
|
[app.main.data.workspace :as-alias dw]
|
||||||
[app.main.data.workspace.colors :as cl]
|
[app.main.data.workspace.colors :as cl]
|
||||||
[app.main.data.workspace.grid-layout.editor :as dwge]
|
[app.main.data.workspace.grid-layout.editor :as dwge]
|
||||||
[app.main.data.workspace.modifiers :as dwm]
|
[app.main.data.workspace.modifiers :as dwm]
|
||||||
@ -131,14 +132,14 @@
|
|||||||
(->> stream
|
(->> stream
|
||||||
(rx/filter (ptk/type? ::dwsh/update-shapes-buffer-commit))
|
(rx/filter (ptk/type? ::dwsh/update-shapes-buffer-commit))
|
||||||
(rx/take 1)
|
(rx/take 1)
|
||||||
(rx/take-until (rx/filter (ptk/type? :app.main.data.workspace/finalize) stream))
|
(rx/take-until (rx/filter (ptk/type? ::dw/finalize-workspace) stream))
|
||||||
;; No events are derived from this
|
;; No events are derived from this
|
||||||
(rx/ignore))
|
(rx/ignore))
|
||||||
(rx/empty))]
|
(rx/empty))]
|
||||||
|
|
||||||
(cond->> (rx/concat update-positions-stream drain-stream)
|
(cond->> (rx/concat update-positions-stream drain-stream)
|
||||||
(d/not-empty? reflow-tasks)
|
(d/not-empty? reflow-tasks)
|
||||||
(rx/finalize #(run! wrf/finish! reflow-tasks)))))))
|
(rx/finalize #(wrf/finish-tasks! reflow-tasks)))))))
|
||||||
|
|
||||||
(defn- without-root-board
|
(defn- without-root-board
|
||||||
[ids]
|
[ids]
|
||||||
|
|||||||
@ -24,34 +24,12 @@
|
|||||||
[app.main.data.workspace.collapse :as dwco]
|
[app.main.data.workspace.collapse :as dwco]
|
||||||
[app.main.data.workspace.edition :as dwe]
|
[app.main.data.workspace.edition :as dwe]
|
||||||
[app.main.data.workspace.reflow :as wrf]
|
[app.main.data.workspace.reflow :as wrf]
|
||||||
|
[app.main.data.workspace.reflow.signals :as wrfs]
|
||||||
[app.main.data.workspace.selection :as dws]
|
[app.main.data.workspace.selection :as dws]
|
||||||
[app.main.data.workspace.undo :as dwu]
|
[app.main.data.workspace.undo :as dwu]
|
||||||
[app.main.features :as features]
|
|
||||||
[beicon.v2.core :as rx]
|
[beicon.v2.core :as rx]
|
||||||
[potok.v2.core :as ptk]))
|
[potok.v2.core :as ptk]))
|
||||||
|
|
||||||
;; If anything a translation can mutate is added here, drop the
|
|
||||||
;; `(when-not translation? …)` guard in `update-shapes` below.
|
|
||||||
(def ^:private update-layout-attr? #{:hidden})
|
|
||||||
|
|
||||||
;; Text attrs whose change makes the DOM text pipeline re-measure the shape.
|
|
||||||
(def ^:private text-reflow-attr? #{:content :grow-type})
|
|
||||||
|
|
||||||
(defn- reflow-attr?
|
|
||||||
[attr]
|
|
||||||
(or (update-layout-attr? attr) (text-reflow-attr? attr)))
|
|
||||||
|
|
||||||
(defn- async-text-reflow?
|
|
||||||
"Whether `shape` enters an asynchronous text geometry pipeline. The HTML
|
|
||||||
renderer measures every changed text; WASM only resizes auto-sized texts.
|
|
||||||
A grow-type transition is included because `shape` is the value before the
|
|
||||||
update and may still be fixed."
|
|
||||||
[state shape changed]
|
|
||||||
(and (cfh/text-shape? shape)
|
|
||||||
(or (not (features/active-feature? state "render-wasm/v1"))
|
|
||||||
(not= :fixed (:grow-type shape))
|
|
||||||
(contains? changed :grow-type))))
|
|
||||||
|
|
||||||
(defn- add-undo-group
|
(defn- add-undo-group
|
||||||
[changes state]
|
[changes state]
|
||||||
(let [undo (:workspace-undo state)
|
(let [undo (:workspace-undo state)
|
||||||
@ -82,15 +60,35 @@
|
|||||||
(update [_ state]
|
(update [_ state]
|
||||||
(assoc state ::update-shapes-buffer false))))
|
(assoc state ::update-shapes-buffer false))))
|
||||||
|
|
||||||
|
(defn- get-buffered-text-reflow-event
|
||||||
|
[state page-id ids]
|
||||||
|
(when (= page-id (get state :current-page-id))
|
||||||
|
;; Analyze accumulated objects through the same path as immediate updates.
|
||||||
|
(let [objects (dsh/lookup-page-objects state page-id)
|
||||||
|
changed-objects (-> (get-in state [::update-shapes-buffer-changes page-id])
|
||||||
|
(pcb/lookup-objects))
|
||||||
|
{:keys [text-ids]}
|
||||||
|
(wrfs/reflow-ids state page-id objects changed-objects ids nil)]
|
||||||
|
(when text-ids
|
||||||
|
(ptk/data-event :text/reflow {:ids text-ids :page-id page-id})))))
|
||||||
|
|
||||||
(defn update-shapes-buffer-commit
|
(defn update-shapes-buffer-commit
|
||||||
[]
|
[]
|
||||||
(ptk/reify ::update-shapes-buffer-commit
|
(ptk/reify ::update-shapes-buffer-commit
|
||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ state _]
|
(watch [_ state _]
|
||||||
(->> (get state ::update-shapes-buffer-changes)
|
(let [text-reflow-events
|
||||||
(vals)
|
(->> (get state ::update-shapes-buffer-text-candidates)
|
||||||
(map dch/commit-changes)
|
(keep (fn [[page-id ids]]
|
||||||
(rx/from)))))
|
(get-buffered-text-reflow-event state page-id ids))))
|
||||||
|
|
||||||
|
commits
|
||||||
|
(->> (get state ::update-shapes-buffer-changes)
|
||||||
|
(vals)
|
||||||
|
(map dch/commit-changes))]
|
||||||
|
;; Open bridges before commits start rendering.
|
||||||
|
(rx/concat (rx/from text-reflow-events)
|
||||||
|
(rx/from commits))))))
|
||||||
|
|
||||||
;; Looks for the objects data in the state, if there is an "in progress"
|
;; Looks for the objects data in the state, if there is an "in progress"
|
||||||
;; update-shapes-buffer will return the objeccts inside the current changes
|
;; update-shapes-buffer will return the objeccts inside the current changes
|
||||||
@ -111,7 +109,8 @@
|
|||||||
(update-shapes-buffer ids update-fn nil))
|
(update-shapes-buffer ids update-fn nil))
|
||||||
([ids update-fn
|
([ids update-fn
|
||||||
{:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id
|
{:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id
|
||||||
ignore-touched undo-group with-objects? changed-sub-attr translation?]
|
ignore-touched undo-group with-objects? changed-sub-attr
|
||||||
|
translation?]
|
||||||
:or {reg-objects? false
|
:or {reg-objects? false
|
||||||
save-undo? true
|
save-undo? true
|
||||||
stack-undo? false
|
stack-undo? false
|
||||||
@ -126,9 +125,14 @@
|
|||||||
(assoc state ::update-shapes-buffer-event cur-event)
|
(assoc state ::update-shapes-buffer-event cur-event)
|
||||||
|
|
||||||
(let [page-id (or page-id (get state :current-page-id))
|
(let [page-id (or page-id (get state :current-page-id))
|
||||||
objects (dsh/lookup-page-objects state page-id)]
|
objects (lookup-changed-objects state page-id)
|
||||||
(-> state
|
text-ids
|
||||||
|
(into #{}
|
||||||
|
(filter #(cfh/text-shape? objects %))
|
||||||
|
ids)
|
||||||
|
state
|
||||||
(update-in
|
(update-in
|
||||||
|
state
|
||||||
[::update-shapes-buffer-changes page-id]
|
[::update-shapes-buffer-changes page-id]
|
||||||
(fn [changes]
|
(fn [changes]
|
||||||
(-> (or changes
|
(-> (or changes
|
||||||
@ -148,7 +152,15 @@
|
|||||||
:ignore-touched ignore-touched
|
:ignore-touched ignore-touched
|
||||||
:with-objects? with-objects?})
|
:with-objects? with-objects?})
|
||||||
(cond-> reg-objects? (pcb/resize-parents ids))
|
(cond-> reg-objects? (pcb/resize-parents ids))
|
||||||
(pcb/set-translation? translation?))))))))
|
(pcb/set-translation? translation?))))]
|
||||||
|
;; Check buffered text candidates when the buffer is committed.
|
||||||
|
(if (or (empty? text-ids)
|
||||||
|
(not (wrfs/text-reflow-candidate? state props)))
|
||||||
|
state
|
||||||
|
(update-in state
|
||||||
|
[::update-shapes-buffer-text-candidates page-id]
|
||||||
|
(fnil into #{})
|
||||||
|
text-ids)))))
|
||||||
|
|
||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ state stream]
|
(watch [_ state stream]
|
||||||
@ -165,6 +177,7 @@
|
|||||||
|
|
||||||
(rx/of #(dissoc %
|
(rx/of #(dissoc %
|
||||||
::update-shapes-buffer-changes
|
::update-shapes-buffer-changes
|
||||||
|
::update-shapes-buffer-text-candidates
|
||||||
::update-shapes-buffer-event))))
|
::update-shapes-buffer-event))))
|
||||||
(rx/empty)))))))
|
(rx/empty)))))))
|
||||||
|
|
||||||
@ -174,14 +187,12 @@
|
|||||||
([ids update-fn
|
([ids update-fn
|
||||||
{:as props
|
{:as props
|
||||||
:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id
|
:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id
|
||||||
ignore-touched undo-group with-objects? changed-sub-attr translation?
|
ignore-touched undo-group with-objects? changed-sub-attr translation?]
|
||||||
update-layout?]
|
|
||||||
:or {reg-objects? false
|
:or {reg-objects? false
|
||||||
save-undo? true
|
save-undo? true
|
||||||
stack-undo? false
|
stack-undo? false
|
||||||
ignore-touched false
|
ignore-touched false
|
||||||
with-objects? false
|
with-objects? false}}]
|
||||||
update-layout? true}}]
|
|
||||||
|
|
||||||
(assert (every? uuid? ids) "expect a coll of uuid for `ids`")
|
(assert (every? uuid? ids) "expect a coll of uuid for `ids`")
|
||||||
(assert (fn? update-fn) "the `update-fn` should be a valid function")
|
(assert (fn? update-fn) "the `update-fn` should be a valid function")
|
||||||
@ -197,49 +208,6 @@
|
|||||||
objects (dsh/lookup-page-objects state page-id)
|
objects (dsh/lookup-page-objects state page-id)
|
||||||
ids (into [] (filter some?) ids)
|
ids (into [] (filter some?) ids)
|
||||||
|
|
||||||
;; Pairs of [shape changed-attrs] for the shapes whose change
|
|
||||||
;; matters to a reflow, feeding both id sets below.
|
|
||||||
xf-reflow
|
|
||||||
(comp
|
|
||||||
(map (d/getf objects))
|
|
||||||
(keep (fn [shape]
|
|
||||||
(let [changed (pcb/changed-attrs shape objects update-fn
|
|
||||||
{:attrs attrs :with-objects? with-objects?})]
|
|
||||||
(when (some reflow-attr? changed)
|
|
||||||
[shape changed])))))
|
|
||||||
|
|
||||||
;; `changed-attrs` runs `update-fn` in full for every shape, which
|
|
||||||
;; can be expensive (e.g. `update-bool-shape` recalculates the whole
|
|
||||||
;; boolean path in WASM). Skip the pass entirely when we can prove it
|
|
||||||
;; cannot match: when the caller declares `attrs`, `changed-attrs`
|
|
||||||
;; filters its result to that set, so if no reflow attr is present
|
|
||||||
;; the check is always empty.
|
|
||||||
reflow-changes
|
|
||||||
(when-not (or translation?
|
|
||||||
(not update-layout?)
|
|
||||||
(and (some? attrs)
|
|
||||||
(not (some reflow-attr? attrs))))
|
|
||||||
(into [] xf-reflow ids))
|
|
||||||
|
|
||||||
update-layout-ids
|
|
||||||
(->> reflow-changes
|
|
||||||
(into [] (comp (filter (fn [[_ changed]] (some update-layout-attr? changed)))
|
|
||||||
(map (comp :id first))))
|
|
||||||
(not-empty))
|
|
||||||
|
|
||||||
;; Text shapes the DOM pipeline has to re-measure, narrowed to what
|
|
||||||
;; it actually measures: the active page, never the edited shape.
|
|
||||||
text-reflow-ids
|
|
||||||
(when (= page-id (get state :current-page-id))
|
|
||||||
(let [edition (dm/get-in state [:workspace-local :edition])]
|
|
||||||
(->> reflow-changes
|
|
||||||
(into [] (comp (filter (fn [[shape changed]]
|
|
||||||
(and (async-text-reflow? state shape changed)
|
|
||||||
(some text-reflow-attr? changed))))
|
|
||||||
(map (comp :id first))
|
|
||||||
(remove #(= % edition))))
|
|
||||||
(not-empty))))
|
|
||||||
|
|
||||||
changes
|
changes
|
||||||
(-> (pcb/empty-changes it page-id)
|
(-> (pcb/empty-changes it page-id)
|
||||||
(pcb/set-save-undo? save-undo?)
|
(pcb/set-save-undo? save-undo?)
|
||||||
@ -257,6 +225,12 @@
|
|||||||
(pcb/set-undo-group undo-group))
|
(pcb/set-undo-group undo-group))
|
||||||
(pcb/set-translation? translation?))
|
(pcb/set-translation? translation?))
|
||||||
|
|
||||||
|
changed-objects
|
||||||
|
(pcb/lookup-objects changes)
|
||||||
|
|
||||||
|
{:keys [layout-ids text-ids]}
|
||||||
|
(wrfs/reflow-ids state page-id objects changed-objects ids props)
|
||||||
|
|
||||||
changes
|
changes
|
||||||
(add-undo-group changes state)]
|
(add-undo-group changes state)]
|
||||||
|
|
||||||
@ -264,8 +238,8 @@
|
|||||||
;; Announces the texts still to be re-measured, so a reflow wait
|
;; Announces the texts still to be re-measured, so a reflow wait
|
||||||
;; covers the render that measures them. Goes before the commit,
|
;; covers the render that measures them. Goes before the commit,
|
||||||
;; which is what triggers that render.
|
;; which is what triggers that render.
|
||||||
(if text-reflow-ids
|
(if text-ids
|
||||||
(rx/of (ptk/data-event :text/reflow {:ids text-reflow-ids :page-id page-id}))
|
(rx/of (ptk/data-event :text/reflow {:ids text-ids :page-id page-id}))
|
||||||
(rx/empty))
|
(rx/empty))
|
||||||
|
|
||||||
(if (seq (:redo-changes changes))
|
(if (seq (:redo-changes changes))
|
||||||
@ -274,8 +248,8 @@
|
|||||||
(rx/empty))
|
(rx/empty))
|
||||||
|
|
||||||
;; Update layouts for properties marked
|
;; Update layouts for properties marked
|
||||||
(if update-layout-ids
|
(if layout-ids
|
||||||
(rx/of (ptk/data-event :layout/update {:ids update-layout-ids}))
|
(rx/of (ptk/data-event :layout/update {:ids layout-ids}))
|
||||||
(rx/empty)))))))))
|
(rx/empty)))))))))
|
||||||
|
|
||||||
(defn add-shape
|
(defn add-shape
|
||||||
@ -321,7 +295,7 @@
|
|||||||
(rx/of (dwu/start-undo-transaction undo-id)
|
(rx/of (dwu/start-undo-transaction undo-id)
|
||||||
;; A new text has no geometry until the pipeline measures it,
|
;; A new text has no geometry until the pipeline measures it,
|
||||||
;; so it raises the same signal an edit does.
|
;; so it raises the same signal an edit does.
|
||||||
(when (async-text-reflow? state shape nil)
|
(when (wrfs/new-text-reflow? state shape)
|
||||||
(ptk/data-event :text/reflow {:ids [(:id shape)] :page-id page-id}))
|
(ptk/data-event :text/reflow {:ids [(:id shape)] :page-id page-id}))
|
||||||
(dch/commit-changes changes)
|
(dch/commit-changes changes)
|
||||||
(when-not no-update-layout?
|
(when-not no-update-layout?
|
||||||
|
|||||||
@ -24,9 +24,11 @@
|
|||||||
[app.main.data.changes :as dch]
|
[app.main.data.changes :as dch]
|
||||||
[app.main.data.event :as ev]
|
[app.main.data.event :as ev]
|
||||||
[app.main.data.helpers :as dsh]
|
[app.main.data.helpers :as dsh]
|
||||||
|
[app.main.data.workspace :as-alias dw]
|
||||||
[app.main.data.workspace.common :as dwc]
|
[app.main.data.workspace.common :as dwc]
|
||||||
[app.main.data.workspace.libraries :as dwl]
|
[app.main.data.workspace.libraries :as dwl]
|
||||||
[app.main.data.workspace.modifiers :as dwm]
|
[app.main.data.workspace.modifiers :as dwm]
|
||||||
|
[app.main.data.workspace.pages :as-alias dwpg]
|
||||||
[app.main.data.workspace.reflow :as wrf]
|
[app.main.data.workspace.reflow :as wrf]
|
||||||
[app.main.data.workspace.selection :as dws]
|
[app.main.data.workspace.selection :as dws]
|
||||||
[app.main.data.workspace.shapes :as dwsh]
|
[app.main.data.workspace.shapes :as dwsh]
|
||||||
@ -66,15 +68,19 @@
|
|||||||
"Marks `ids` pending until the text pipeline marks its own work:
|
"Marks `ids` pending until the text pipeline marks its own work:
|
||||||
`:text-measure` in the DOM renderer, `:text-resize` in wasm. Emits nothing."
|
`:text-measure` in the DOM renderer, `:text-resize` in wasm. Emits nothing."
|
||||||
[ids]
|
[ids]
|
||||||
(->> (rx/from ids)
|
(wrf/bridge-pending ids #{:text-measure :text-resize} :text-bridge))
|
||||||
;; Each id owns its bridge. Starting work for one text must not release
|
|
||||||
;; siblings that the renderer has not picked up yet.
|
(defn- page-finalize?
|
||||||
(rx/mapcat
|
[event]
|
||||||
(fn [id]
|
(= ::dwpg/finalize-page (ptk/type event)))
|
||||||
(->> (wrf/pending-signal [id] #{:text-measure :text-resize})
|
|
||||||
(rx/ignore)
|
(defn- text-work-stopper
|
||||||
(wrf/with-pending :text-bridge [id]))))
|
[stream]
|
||||||
(rx/ignore)))
|
(rx/filter
|
||||||
|
(fn [event]
|
||||||
|
(or (= ::dw/finalize-workspace (ptk/type event))
|
||||||
|
(page-finalize? event)))
|
||||||
|
stream))
|
||||||
|
|
||||||
(defn initialize-text-reflow
|
(defn initialize-text-reflow
|
||||||
"Tracks the texts the DOM pipeline still has to re-measure, so a reflow wait
|
"Tracks the texts the DOM pipeline still has to re-measure, so a reflow wait
|
||||||
@ -83,11 +89,15 @@
|
|||||||
(ptk/reify ::initialize-text-reflow
|
(ptk/reify ::initialize-text-reflow
|
||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ _ stream]
|
(watch [_ _ stream]
|
||||||
(let [stopper (rx/filter (ptk/type? ::finalize-text-reflow) stream)]
|
(let [stopper (rx/filter (ptk/type? ::finalize-text-reflow) stream)
|
||||||
|
page-stopper (rx/filter page-finalize? stream)]
|
||||||
(->> stream
|
(->> stream
|
||||||
(rx/filter (ptk/type? :text/reflow))
|
(rx/filter (ptk/type? :text/reflow))
|
||||||
(rx/map deref)
|
(rx/map deref)
|
||||||
(rx/mapcat (fn [{:keys [ids]}] (bridge-to-measurement ids)))
|
(rx/merge-map
|
||||||
|
(fn [{:keys [ids]}]
|
||||||
|
(->> (bridge-to-measurement ids)
|
||||||
|
(rx/take-until page-stopper))))
|
||||||
(rx/take-until stopper))))))
|
(rx/take-until stopper))))))
|
||||||
|
|
||||||
(defn finalize-text-reflow
|
(defn finalize-text-reflow
|
||||||
@ -110,28 +120,51 @@
|
|||||||
:else
|
:else
|
||||||
[])))
|
[])))
|
||||||
|
|
||||||
|
(defn- await-font-faces
|
||||||
|
"Waits for missing WASM faces, then resizes the affected texts."
|
||||||
|
[stream face-keys ids]
|
||||||
|
(let [resize-stream (->> (rx/from ids) (rx/map dwwt/resize-wasm-text))]
|
||||||
|
(if (empty? face-keys)
|
||||||
|
resize-stream
|
||||||
|
(->> (rx/merge wasm.fonts/font-stored-stream
|
||||||
|
wasm.fonts/font-storage-failed-stream)
|
||||||
|
(rx/filter face-keys)
|
||||||
|
(rx/scan disj face-keys)
|
||||||
|
(rx/filter empty?)
|
||||||
|
(rx/take 1)
|
||||||
|
(rx/take-until (text-work-stopper stream))
|
||||||
|
(rx/observe-on :async)
|
||||||
|
(rx/mapcat (constantly resize-stream))
|
||||||
|
(wrf/with-pending :font ids)))))
|
||||||
|
|
||||||
|
(defn- pending-font-faces
|
||||||
|
[ids]
|
||||||
|
(let [objects (dsh/lookup-page-objects @st/state)]
|
||||||
|
(into #{}
|
||||||
|
(comp
|
||||||
|
(map #(get objects %))
|
||||||
|
(keep :content)
|
||||||
|
(mapcat wasm.fonts/get-content-fonts)
|
||||||
|
(map wasm.fonts/make-font-data)
|
||||||
|
(remove wasm.fonts/font-ready?)
|
||||||
|
(map wasm.fonts/font-data-key))
|
||||||
|
ids)))
|
||||||
|
|
||||||
(defn- await-font-resize
|
(defn- await-font-resize
|
||||||
"Marks `ids` as pending font work and dispatches their wasm resize once wasm
|
"Waits for missing font faces, then resizes `ids`."
|
||||||
can measure with `font-id`, draining the marks afterwards. The fetch of that
|
[stream ids]
|
||||||
font is started by the wasm shape sync of the content change these shapes
|
|
||||||
receive, so measuring before it lands would use the fallback font."
|
|
||||||
[stream font-id ids]
|
|
||||||
(if (empty? ids)
|
(if (empty? ids)
|
||||||
(rx/empty)
|
(rx/empty)
|
||||||
(let [stopper (rx/filter (ptk/type? :app.main.data.workspace/finalize) stream)]
|
(->> (rx/of ::await-fonts)
|
||||||
(->> wasm.fonts/font-stored-stream
|
(rx/mapcat
|
||||||
(rx/filter #(= % font-id))
|
(fn [_]
|
||||||
(rx/take 1)
|
(await-font-faces stream (pending-font-faces ids) ids))))))
|
||||||
(rx/take-until stopper)
|
|
||||||
(rx/observe-on :async)
|
|
||||||
(rx/mapcat (fn [_] (rx/from (mapv dwwt/resize-wasm-text ids))))
|
|
||||||
(wrf/with-pending :font ids)))))
|
|
||||||
|
|
||||||
(defn- await-html-font
|
(defn- await-html-font
|
||||||
"Keeps legacy DOM text pending while its new font is loading. The DOM
|
"Keeps legacy DOM text pending while its new font is loading. The DOM
|
||||||
measurement also awaits this promise, so the font task bridges the state
|
measurement also awaits this promise, so the font task bridges the state
|
||||||
update to the renderer commit without relying on a fixed settle delay."
|
update to the renderer commit without relying on a fixed settle delay."
|
||||||
[font-id font-variant-id ids]
|
[stream font-id font-variant-id ids]
|
||||||
(if (or (nil? font-id) (empty? ids))
|
(if (or (nil? font-id) (empty? ids))
|
||||||
(rx/empty)
|
(rx/empty)
|
||||||
(->> (rx/of ::load-font)
|
(->> (rx/of ::load-font)
|
||||||
@ -140,6 +173,7 @@
|
|||||||
;; gap before the task is visible to waiters.
|
;; gap before the task is visible to waiters.
|
||||||
(rx/mapcat (fn [_]
|
(rx/mapcat (fn [_]
|
||||||
(rx/from (fonts/ensure-loaded! font-id font-variant-id))))
|
(rx/from (fonts/ensure-loaded! font-id font-variant-id))))
|
||||||
|
(rx/take-until (text-work-stopper stream))
|
||||||
(rx/ignore)
|
(rx/ignore)
|
||||||
(wrf/with-pending :font ids))))
|
(wrf/with-pending :font ids))))
|
||||||
|
|
||||||
@ -525,7 +559,7 @@
|
|||||||
[id start end attrs]
|
[id start end attrs]
|
||||||
(ptk/reify ::update-text-range
|
(ptk/reify ::update-text-range
|
||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ state _]
|
(watch [_ state stream]
|
||||||
(let [objects (dsh/lookup-page-objects state)
|
(let [objects (dsh/lookup-page-objects state)
|
||||||
shape (get objects id)
|
shape (get objects id)
|
||||||
|
|
||||||
@ -547,7 +581,7 @@
|
|||||||
(rx/map dwwt/resize-wasm-text-debounce))
|
(rx/map dwwt/resize-wasm-text-debounce))
|
||||||
|
|
||||||
(contains? attrs :font-id)
|
(contains? attrs :font-id)
|
||||||
(await-html-font (:font-id attrs) (:font-variant-id attrs) text-ids)
|
(await-html-font stream (:font-id attrs) (:font-variant-id attrs) text-ids)
|
||||||
|
|
||||||
:else
|
:else
|
||||||
(rx/empty)))))))
|
(rx/empty)))))))
|
||||||
@ -798,7 +832,7 @@
|
|||||||
(watch [_ state stream]
|
(watch [_ state stream]
|
||||||
(wrf/start! reflow-task)
|
(wrf/start! reflow-task)
|
||||||
(if (= (::resize-text-debounce-event state) cur-event)
|
(if (= (::resize-text-debounce-event state) cur-event)
|
||||||
(let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))]
|
(let [stopper (->> stream (rx/filter (ptk/type? ::dw/finalize-workspace)))]
|
||||||
(rx/concat
|
(rx/concat
|
||||||
(rx/merge
|
(rx/merge
|
||||||
(->> stream
|
(->> stream
|
||||||
@ -809,7 +843,7 @@
|
|||||||
(rx/take-until stopper))
|
(rx/take-until stopper))
|
||||||
(rx/of (resize-text id new-width new-height)))
|
(rx/of (resize-text id new-width new-height)))
|
||||||
(rx/of (fn [state]
|
(rx/of (fn [state]
|
||||||
(run! wrf/finish! (::resize-text-reflow-tasks state))
|
(wrf/finish-tasks! (::resize-text-reflow-tasks state))
|
||||||
(dissoc state
|
(dissoc state
|
||||||
::resize-text-debounce-props
|
::resize-text-debounce-props
|
||||||
::resize-text-reflow-tasks
|
::resize-text-reflow-tasks
|
||||||
@ -877,7 +911,7 @@
|
|||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ state stream]
|
(watch [_ state stream]
|
||||||
(if (= (::update-text-modifier-debounce-event state) cur-event)
|
(if (= (::update-text-modifier-debounce-event state) cur-event)
|
||||||
(let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))]
|
(let [stopper (->> stream (rx/filter (ptk/type? ::dw/finalize-workspace)))]
|
||||||
(rx/concat
|
(rx/concat
|
||||||
(rx/merge
|
(rx/merge
|
||||||
(->> stream
|
(->> stream
|
||||||
@ -925,40 +959,49 @@
|
|||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ state _]
|
(watch [_ state _]
|
||||||
(let [position-data (::update-position-data state)]
|
(let [position-data (::update-position-data state)]
|
||||||
(rx/concat
|
(rx/of (dwsh/update-shapes
|
||||||
(rx/of (dwsh/update-shapes
|
(keys position-data)
|
||||||
(keys position-data)
|
(fn [shape]
|
||||||
(fn [shape]
|
(-> shape
|
||||||
(-> shape
|
(assoc :position-data (get position-data (:id shape)))))
|
||||||
(assoc :position-data (get position-data (:id shape)))))
|
{:stack-undo? true :reg-objects? false}))))))
|
||||||
{:stack-undo? true :reg-objects? false}))
|
|
||||||
(rx/of (fn [state]
|
|
||||||
(dissoc state ::update-position-data-debounce ::update-position-data))))))))
|
|
||||||
|
|
||||||
(defn update-position-data
|
(defn update-position-data
|
||||||
[id position-data]
|
[id position-data]
|
||||||
|
|
||||||
(let [cur-event (js/Symbol)]
|
(let [cur-event (js/Symbol)
|
||||||
|
reflow-task (wrf/task :text-position [id])]
|
||||||
(ptk/reify ::update-position-data
|
(ptk/reify ::update-position-data
|
||||||
ptk/UpdateEvent
|
ptk/UpdateEvent
|
||||||
(update [_ state]
|
(update [_ state]
|
||||||
(let [state (assoc-in state [:workspace-text-modifier id :position-data] position-data)]
|
(let [state (assoc-in state [:workspace-text-modifier id :position-data] position-data)]
|
||||||
(if (nil? (::update-position-data-debounce state))
|
(-> state
|
||||||
(assoc state ::update-position-data-debounce cur-event)
|
(update ::update-position-data-reflow-tasks (fnil conj []) reflow-task)
|
||||||
(assoc-in state [::update-position-data id] position-data))))
|
(cond-> (nil? (::update-position-data-debounce state))
|
||||||
|
(assoc ::update-position-data-debounce cur-event))
|
||||||
|
(cond-> (some? (::update-position-data-debounce state))
|
||||||
|
(assoc-in [::update-position-data id] position-data)))))
|
||||||
|
|
||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ state stream]
|
(watch [_ state stream]
|
||||||
|
(wrf/start! reflow-task)
|
||||||
(if (= (::update-position-data-debounce state) cur-event)
|
(if (= (::update-position-data-debounce state) cur-event)
|
||||||
(let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))]
|
(let [stopper (text-work-stopper stream)]
|
||||||
(rx/merge
|
(rx/concat
|
||||||
(->> stream
|
(rx/merge
|
||||||
(rx/filter (ptk/type? ::update-position-data))
|
(->> stream
|
||||||
(rx/debounce 50)
|
(rx/filter (ptk/type? ::update-position-data))
|
||||||
(rx/take 1)
|
(rx/debounce 50)
|
||||||
(rx/map #(commit-position-data))
|
(rx/take 1)
|
||||||
(rx/take-until stopper))
|
(rx/map #(commit-position-data))
|
||||||
(rx/of (update-position-data id position-data))))
|
(rx/take-until stopper))
|
||||||
|
(rx/of (update-position-data id position-data)))
|
||||||
|
(rx/of (fn [state]
|
||||||
|
(wrf/finish-tasks! (::update-position-data-reflow-tasks state))
|
||||||
|
(dissoc state
|
||||||
|
::update-position-data-debounce
|
||||||
|
::update-position-data
|
||||||
|
::update-position-data-reflow-tasks)))))
|
||||||
(rx/empty))))))
|
(rx/empty))))))
|
||||||
|
|
||||||
(defn update-attrs
|
(defn update-attrs
|
||||||
@ -1009,7 +1052,7 @@
|
|||||||
(let [auto-ids (into [] (remove #(= :fixed (:grow-type (get objects %)))) text-ids)]
|
(let [auto-ids (into [] (remove #(= :fixed (:grow-type (get objects %)))) text-ids)]
|
||||||
(if (contains? attrs :font-id)
|
(if (contains? attrs :font-id)
|
||||||
;; The geometry depends on the font, so wait until wasm has it.
|
;; The geometry depends on the font, so wait until wasm has it.
|
||||||
(await-font-resize stream (:font-id attrs) auto-ids)
|
(await-font-resize stream auto-ids)
|
||||||
;; No font change: measurable right away.
|
;; No font change: measurable right away.
|
||||||
(->> (rx/from auto-ids)
|
(->> (rx/from auto-ids)
|
||||||
(rx/map dwwt/resize-wasm-text)))))
|
(rx/map dwwt/resize-wasm-text)))))
|
||||||
@ -1018,6 +1061,7 @@
|
|||||||
;; but font loading starts before that render commits.
|
;; but font loading starts before that render commits.
|
||||||
(if (contains? attrs :font-id)
|
(if (contains? attrs :font-id)
|
||||||
(await-html-font
|
(await-html-font
|
||||||
|
stream
|
||||||
(:font-id attrs)
|
(:font-id attrs)
|
||||||
(:font-variant-id attrs)
|
(:font-variant-id attrs)
|
||||||
text-ids)
|
text-ids)
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
[app.common.geom.point :as gpt]
|
[app.common.geom.point :as gpt]
|
||||||
[app.common.types.modifiers :as ctm]
|
[app.common.types.modifiers :as ctm]
|
||||||
[app.main.data.helpers :as dsh]
|
[app.main.data.helpers :as dsh]
|
||||||
|
[app.main.data.workspace :as-alias dw]
|
||||||
[app.main.data.workspace.modifiers :as dwm]
|
[app.main.data.workspace.modifiers :as dwm]
|
||||||
[app.main.data.workspace.reflow :as wrf]
|
[app.main.data.workspace.reflow :as wrf]
|
||||||
[app.main.data.workspace.shapes :as dwsh]
|
[app.main.data.workspace.shapes :as dwsh]
|
||||||
@ -159,7 +160,7 @@
|
|||||||
(watch [_ state stream]
|
(watch [_ state stream]
|
||||||
(wrf/start! reflow-task)
|
(wrf/start! reflow-task)
|
||||||
(if (= (::resize-wasm-text-debounce-event state) cur-event)
|
(if (= (::resize-wasm-text-debounce-event state) cur-event)
|
||||||
(let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))]
|
(let [stopper (->> stream (rx/filter (ptk/type? ::dw/finalize-workspace)))]
|
||||||
(rx/concat
|
(rx/concat
|
||||||
(rx/merge
|
(rx/merge
|
||||||
(->> stream
|
(->> stream
|
||||||
@ -179,7 +180,7 @@
|
|||||||
;; pending until the resize is applied. All exact tasks in the
|
;; pending until the resize is applied. All exact tasks in the
|
||||||
;; batch are retained in state and finished by the cleanup.
|
;; batch are retained in state and finished by the cleanup.
|
||||||
(rx/of (fn [state]
|
(rx/of (fn [state]
|
||||||
(run! wrf/finish! (::resize-wasm-text-reflow-tasks state))
|
(wrf/finish-tasks! (::resize-wasm-text-reflow-tasks state))
|
||||||
(dissoc state
|
(dissoc state
|
||||||
::resize-wasm-text-debounce-ids
|
::resize-wasm-text-debounce-ids
|
||||||
::resize-wasm-text-reflow-tasks
|
::resize-wasm-text-reflow-tasks
|
||||||
@ -198,15 +199,15 @@
|
|||||||
content (dm/get-in objects [id :content])
|
content (dm/get-in objects [id :content])
|
||||||
fonts (wasm.fonts/get-content-fonts content)
|
fonts (wasm.fonts/get-content-fonts content)
|
||||||
|
|
||||||
fonts-loaded?
|
fonts-ready?
|
||||||
(->> fonts
|
(->> fonts
|
||||||
(every?
|
(every?
|
||||||
(fn [font]
|
(fn [font]
|
||||||
(let [font-data (wasm.fonts/make-font-data font)]
|
(let [font-data (wasm.fonts/make-font-data font)]
|
||||||
(wasm.fonts/font-stored? font-data (:emoji? font-data))))))
|
(wasm.fonts/font-ready? font-data)))))
|
||||||
|
|
||||||
resize-wasm-stream
|
resize-wasm-stream
|
||||||
(if fonts-loaded?
|
(if fonts-ready?
|
||||||
(let [pass-opts (when (or (some? undo-group) (some? undo-id))
|
(let [pass-opts (when (or (some? undo-group) (some? undo-id))
|
||||||
(cond-> {}
|
(cond-> {}
|
||||||
(some? undo-group) (assoc :undo-group undo-group)
|
(some? undo-group) (assoc :undo-group undo-group)
|
||||||
@ -232,15 +233,32 @@
|
|||||||
(watch [_ state stream]
|
(watch [_ state stream]
|
||||||
(let [resize-stream
|
(let [resize-stream
|
||||||
(->> (rx/from ids)
|
(->> (rx/from ids)
|
||||||
(rx/map #(resize-wasm-text-debounce % opts)))]
|
(rx/map #(resize-wasm-text-debounce % opts)))
|
||||||
|
|
||||||
|
buffer-finished-stream
|
||||||
|
(->> (rx/merge
|
||||||
|
(->> stream
|
||||||
|
(rx/filter (ptk/type? ::dwsh/update-shapes-buffer-commit))
|
||||||
|
(rx/map (constantly :commit)))
|
||||||
|
;; Let a buffered commit beat the stop signal.
|
||||||
|
(->> stream
|
||||||
|
(rx/filter (ptk/type? ::dwsh/update-shapes-buffer-stop))
|
||||||
|
(rx/observe-on :async)
|
||||||
|
(rx/map (constantly :stop)))
|
||||||
|
(->> stream
|
||||||
|
(rx/filter (ptk/type? ::dw/finalize-workspace))
|
||||||
|
(rx/map (constantly :finalize))))
|
||||||
|
(rx/take 1))]
|
||||||
(if (::dwsh/update-shapes-buffer state)
|
(if (::dwsh/update-shapes-buffer state)
|
||||||
;; If we're in the middle of a token propagation we wait until is finished to
|
;; If we're in the middle of a token propagation we wait until is finished to
|
||||||
;; recalculate the text sizes. The shapes stay pending for that whole wait,
|
;; recalculate the text sizes. The shapes stay pending for that whole wait,
|
||||||
;; since the per-shape debounce only marks them once dispatched.
|
;; since the per-shape debounce only marks them once dispatched.
|
||||||
(wrf/with-pending
|
(wrf/with-pending
|
||||||
:text-resize ids
|
:text-resize ids
|
||||||
(->> stream
|
(->> buffer-finished-stream
|
||||||
(rx/filter (ptk/type? ::dwsh/update-shapes-buffer-commit))
|
(rx/mapcat
|
||||||
(rx/take 1)
|
(fn [reason]
|
||||||
(rx/mapcat (constantly resize-stream))))
|
(if (= reason :finalize)
|
||||||
|
(rx/empty)
|
||||||
|
resize-stream)))))
|
||||||
resize-stream))))))
|
resize-stream))))))
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
[app.util.globals :as globals]
|
[app.util.globals :as globals]
|
||||||
[app.util.http :as http]
|
[app.util.http :as http]
|
||||||
[app.util.object :as obj]
|
[app.util.object :as obj]
|
||||||
|
[app.util.timers :as tm]
|
||||||
[beicon.v2.core :as rx]
|
[beicon.v2.core :as rx]
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
[okulary.core :as l]
|
[okulary.core :as l]
|
||||||
@ -243,8 +244,10 @@
|
|||||||
(defmulti ^:private load-font :backend)
|
(defmulti ^:private load-font :backend)
|
||||||
|
|
||||||
(defmethod load-font :default
|
(defmethod load-font :default
|
||||||
[{:keys [backend] :as font}]
|
[{:keys [backend ::on-failed] :as font}]
|
||||||
(log/wrn :msg "no implementation found for" :backend backend))
|
(log/wrn :msg "no implementation found for" :backend backend)
|
||||||
|
(when (fn? on-failed)
|
||||||
|
(on-failed (ex-info "unsupported font backend" {:backend backend}))))
|
||||||
|
|
||||||
(defmethod load-font :builtin
|
(defmethod load-font :builtin
|
||||||
[{:keys [id ::on-loaded] :as font}]
|
[{:keys [id ::on-loaded] :as font}]
|
||||||
@ -269,23 +272,30 @@
|
|||||||
(let [base (u/join cf/public-uri "internal/gfonts/font")]
|
(let [base (u/join cf/public-uri "internal/gfonts/font")]
|
||||||
(str/replace css "https://fonts.gstatic.com/s" (dm/str base))))
|
(str/replace css "https://fonts.gstatic.com/s" (dm/str base))))
|
||||||
|
|
||||||
(defn- fetch-gfont-css
|
(defn- request-gfont-css
|
||||||
[url]
|
[url]
|
||||||
(->> (http/send! {:method :get :uri url :mode :cors :response-type :text})
|
(->> (http/send! {:method :get :uri url :mode :cors :response-type :text})
|
||||||
(rx/map :body)
|
(rx/map :body)))
|
||||||
(rx/catch (fn [err]
|
|
||||||
(log/wrn :hint "cannot find the font" :cause err)
|
(defn- fetch-gfont-css
|
||||||
|
[url]
|
||||||
|
(->> (request-gfont-css url)
|
||||||
|
(rx/catch (fn [cause]
|
||||||
|
;; Keep CSS streams alive when a font cannot load.
|
||||||
|
(log/wrn :hint "cannot find the font" :cause cause)
|
||||||
(rx/empty)))))
|
(rx/empty)))))
|
||||||
|
|
||||||
(defmethod load-font :google
|
(defmethod load-font :google
|
||||||
[{:keys [id ::on-loaded] :as font}]
|
[{:keys [id ::on-loaded ::on-failed] :as font}]
|
||||||
(when (globals/browser?)
|
(when (globals/browser?)
|
||||||
(log/dbg :hint "load-font" :font-id id :backend "google")
|
(log/dbg :hint "load-font" :font-id id :backend "google")
|
||||||
(let [url (generate-gfonts-url font)]
|
(let [url (generate-gfonts-url font)]
|
||||||
(->> (fetch-gfont-css url)
|
;; Keep raw errors so the loader can use its fallback.
|
||||||
|
(->> (request-gfont-css url)
|
||||||
(rx/map process-gfont-css)
|
(rx/map process-gfont-css)
|
||||||
(rx/tap #(on-loaded id))
|
(rx/tap #(on-loaded id))
|
||||||
(rx/subs! (partial add-font-css! id)))
|
(rx/subs! (partial add-font-css! id)
|
||||||
|
#(when (fn? on-failed) (on-failed %))))
|
||||||
nil)))
|
nil)))
|
||||||
|
|
||||||
;; --- LOADER: CUSTOM
|
;; --- LOADER: CUSTOM
|
||||||
@ -358,15 +368,30 @@
|
|||||||
|
|
||||||
;; First caller, we create the promise and then wait
|
;; First caller, we create the promise and then wait
|
||||||
:else
|
:else
|
||||||
(let [on-load (fn [resolve]
|
(let [settle! (fn [resolve loaded?]
|
||||||
(swap! loaded conj font-id)
|
;; Defer cleanup until a synchronous load is cached.
|
||||||
(swap! loading dissoc font-id)
|
(tm/schedule
|
||||||
(resolve font-id))
|
#(do
|
||||||
|
(when loaded?
|
||||||
|
(swap! loaded conj font-id))
|
||||||
|
(swap! loading dissoc font-id)
|
||||||
|
(resolve font-id))))
|
||||||
|
|
||||||
|
on-load (fn [resolve]
|
||||||
|
(settle! resolve true))
|
||||||
|
|
||||||
|
on-failed
|
||||||
|
(fn [resolve cause]
|
||||||
|
(log/wrn :hint "font load failed; using fallback"
|
||||||
|
:font-id font-id
|
||||||
|
:cause cause)
|
||||||
|
(settle! resolve false))
|
||||||
|
|
||||||
load-p (-> (p/create
|
load-p (-> (p/create
|
||||||
(fn [resolve _]
|
(fn [resolve _]
|
||||||
(-> font
|
(-> font
|
||||||
(assoc ::on-loaded (partial on-load resolve))
|
(assoc ::on-loaded (partial on-load resolve))
|
||||||
|
(assoc ::on-failed (partial on-failed resolve))
|
||||||
(load-font))))
|
(load-font))))
|
||||||
;; We need to wait for the font to be loaded
|
;; We need to wait for the font to be loaded
|
||||||
(p/then (partial p/delay 120)))]
|
(p/then (partial p/delay 120)))]
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
[app.common.geom.point :as gpt]
|
[app.common.geom.point :as gpt]
|
||||||
[app.common.geom.shapes :as gsh]
|
[app.common.geom.shapes :as gsh]
|
||||||
[app.common.geom.shapes.text :as gsht]
|
[app.common.geom.shapes.text :as gsht]
|
||||||
|
[app.common.logging :as log]
|
||||||
[app.common.math :as mth]
|
[app.common.math :as mth]
|
||||||
[app.common.types.modifiers :as ctm]
|
[app.common.types.modifiers :as ctm]
|
||||||
[app.common.types.text :as txt]
|
[app.common.types.text :as txt]
|
||||||
@ -96,9 +97,12 @@
|
|||||||
(st/emit! (dwt/resize-text id width height)))))
|
(st/emit! (dwt/resize-text id width height)))))
|
||||||
|
|
||||||
(st/emit! (dwt/clean-text-modifier id))))
|
(st/emit! (dwt/clean-text-modifier id))))
|
||||||
;; Swallowed so a text whose position data cannot be computed still
|
;; Always clear the task and log measurement errors.
|
||||||
;; settles and still reports its measurement as finished.
|
(p/catch (fn [cause]
|
||||||
(p/catch (fn [_] nil))))
|
(log/error :hint "Could not measure text shape"
|
||||||
|
:shape-id id
|
||||||
|
:cause cause)
|
||||||
|
nil))))
|
||||||
|
|
||||||
(defn- update-text-modifier
|
(defn- update-text-modifier
|
||||||
[{:keys [grow-type id] :as shape} node]
|
[{:keys [grow-type id] :as shape} node]
|
||||||
|
|||||||
@ -28,7 +28,6 @@
|
|||||||
[app.main.data.workspace.groups :as dwg]
|
[app.main.data.workspace.groups :as dwg]
|
||||||
[app.main.data.workspace.media :as dwm]
|
[app.main.data.workspace.media :as dwm]
|
||||||
[app.main.data.workspace.pages :as dwpg]
|
[app.main.data.workspace.pages :as dwpg]
|
||||||
[app.main.data.workspace.reflow :as wrf]
|
|
||||||
[app.main.data.workspace.selection :as dws]
|
[app.main.data.workspace.selection :as dws]
|
||||||
[app.main.data.workspace.variants :as dwv]
|
[app.main.data.workspace.variants :as dwv]
|
||||||
[app.main.data.workspace.wasm-text :as dwwt]
|
[app.main.data.workspace.wasm-text :as dwwt]
|
||||||
@ -47,6 +46,7 @@
|
|||||||
[app.plugins.local-storage :as local-storage]
|
[app.plugins.local-storage :as local-storage]
|
||||||
[app.plugins.page :as page]
|
[app.plugins.page :as page]
|
||||||
[app.plugins.parser :as parser]
|
[app.plugins.parser :as parser]
|
||||||
|
[app.plugins.reflow :as wrfp]
|
||||||
[app.plugins.shape :as shape]
|
[app.plugins.shape :as shape]
|
||||||
[app.plugins.system-events :as se]
|
[app.plugins.system-events :as se]
|
||||||
[app.plugins.user :as user]
|
[app.plugins.user :as user]
|
||||||
@ -416,7 +416,10 @@
|
|||||||
(cb/with-objects (:objects page))
|
(cb/with-objects (:objects page))
|
||||||
(cb/add-object shape))]
|
(cb/add-object shape))]
|
||||||
|
|
||||||
(st/emit! (ch/commit-changes changes)
|
;; Track the commit until the renderer starts.
|
||||||
|
(st/emit! (ptk/data-event :text/reflow {:ids [(:id shape)]
|
||||||
|
:page-id (:id page)})
|
||||||
|
(ch/commit-changes changes)
|
||||||
(se/event plugin-id "create-shape" :type :text))
|
(se/event plugin-id "create-shape" :type :text))
|
||||||
|
|
||||||
(when (features/active-feature? @st/state "render-wasm/v1")
|
(when (features/active-feature? @st/state "render-wasm/v1")
|
||||||
@ -734,10 +737,5 @@
|
|||||||
|
|
||||||
:waitForLayoutUpdate
|
:waitForLayoutUpdate
|
||||||
(fn [timeout]
|
(fn [timeout]
|
||||||
;; Always a promise, so a bad argument travels as a rejection.
|
;; Resolves once every shape with reflow work in flight has settled.
|
||||||
(if (u/valid-timeout? timeout)
|
(wrfp/wait-for-layout-update timeout))))
|
||||||
;; Resolves once every shape with reflow work in flight has settled.
|
|
||||||
(wrf/wait-for-layout-update timeout)
|
|
||||||
(js/Promise.
|
|
||||||
(fn [_ reject]
|
|
||||||
(u/reject-not-valid reject :waitForLayoutUpdate timeout)))))))
|
|
||||||
|
|||||||
77
frontend/src/app/plugins/reflow.cljs
Normal file
77
frontend/src/app/plugins/reflow.cljs
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
;;
|
||||||
|
;; Copyright (c) KALEIDOS INC
|
||||||
|
|
||||||
|
(ns app.plugins.reflow
|
||||||
|
"Promise adapter for the plugin `waitForLayoutUpdate` methods. Owns the
|
||||||
|
argument validation, the default deadline and the rejection shape; the
|
||||||
|
workspace only reports when its pending work has drained."
|
||||||
|
(:require
|
||||||
|
[app.common.data.macros :as dm]
|
||||||
|
[app.common.files.helpers :as cfh]
|
||||||
|
[app.common.uuid :as uuid]
|
||||||
|
[app.main.data.workspace.reflow :as wrf]
|
||||||
|
[beicon.v2.core :as rx]))
|
||||||
|
|
||||||
|
;; Ceiling for callers that pass no timeout, so a pipeline that never drains
|
||||||
|
;; its marks rejects the promise rather than leaving it unsettled.
|
||||||
|
(def ^:private default-timeout 30000)
|
||||||
|
|
||||||
|
;; Largest value a signed 32-bit timer accepts.
|
||||||
|
(def ^:private max-timeout 2147483647)
|
||||||
|
|
||||||
|
(defn- valid-timeout?
|
||||||
|
"Checks that a plugin timeout fits a signed 32-bit timer."
|
||||||
|
[value]
|
||||||
|
(or (nil? value)
|
||||||
|
(and (number? value)
|
||||||
|
(pos? value)
|
||||||
|
(<= value max-timeout)
|
||||||
|
(js/Number.isFinite value))))
|
||||||
|
|
||||||
|
(defn- reject-invalid!
|
||||||
|
[reject value]
|
||||||
|
(let [msg (dm/str "[PENPOT PLUGIN] Value not valid: " value
|
||||||
|
". Code: " :waitForLayoutUpdate)]
|
||||||
|
(.error js/console msg)
|
||||||
|
(reject (js/Error. msg))))
|
||||||
|
|
||||||
|
(defn shape-wait-ids
|
||||||
|
"Ids a per-shape wait covers: the shape subtree, its ancestors, and the file
|
||||||
|
its components sync from."
|
||||||
|
[objects file-id id]
|
||||||
|
(-> (into #{} (cfh/get-children-ids-with-self objects id))
|
||||||
|
(into (cfh/get-parent-ids objects id))
|
||||||
|
(conj file-id)
|
||||||
|
(disj uuid/zero)))
|
||||||
|
|
||||||
|
(defn wait-for-layout-update
|
||||||
|
"Returns a JS Promise that resolves once every id in `ids` has drained from
|
||||||
|
the workspace pending map. A nil `ids` waits for every pending id; an empty
|
||||||
|
one has nothing to wait for and resolves right away.
|
||||||
|
|
||||||
|
The promise is rejected when `timeout` (ms) is not a valid timer value, or
|
||||||
|
when it elapses first; a nil `timeout` uses `default-timeout`."
|
||||||
|
([timeout]
|
||||||
|
(wait-for-layout-update nil timeout))
|
||||||
|
([ids timeout]
|
||||||
|
(js/Promise.
|
||||||
|
(fn [resolve reject]
|
||||||
|
(if-not (valid-timeout? timeout)
|
||||||
|
(reject-invalid! reject timeout)
|
||||||
|
;; Race the settle signal against the deadline; the loser is
|
||||||
|
;; unsubscribed. `settled` replays on subscribe, so an already drained
|
||||||
|
;; map wins even against a 1ms deadline.
|
||||||
|
(->> (rx/race (->> (rx/of :timeout)
|
||||||
|
(rx/delay (or timeout default-timeout)))
|
||||||
|
(->> (wrf/settled ids)
|
||||||
|
(rx/map (constantly :ok))))
|
||||||
|
(rx/take 1)
|
||||||
|
(rx/subs!
|
||||||
|
(fn [value]
|
||||||
|
(if (= value :timeout)
|
||||||
|
(reject (js/Error. "waitForLayoutUpdate timeout"))
|
||||||
|
(resolve)))
|
||||||
|
reject)))))))
|
||||||
@ -43,7 +43,6 @@
|
|||||||
[app.main.data.workspace.guides :as dwgu]
|
[app.main.data.workspace.guides :as dwgu]
|
||||||
[app.main.data.workspace.interactions :as dwi]
|
[app.main.data.workspace.interactions :as dwi]
|
||||||
[app.main.data.workspace.libraries :as dwl]
|
[app.main.data.workspace.libraries :as dwl]
|
||||||
[app.main.data.workspace.reflow :as wrf]
|
|
||||||
[app.main.data.workspace.selection :as dws]
|
[app.main.data.workspace.selection :as dws]
|
||||||
[app.main.data.workspace.shape-layout :as dwsl]
|
[app.main.data.workspace.shape-layout :as dwsl]
|
||||||
[app.main.data.workspace.shapes :as dwsh]
|
[app.main.data.workspace.shapes :as dwsh]
|
||||||
@ -58,6 +57,7 @@
|
|||||||
[app.plugins.format :as format]
|
[app.plugins.format :as format]
|
||||||
[app.plugins.grid :as grid]
|
[app.plugins.grid :as grid]
|
||||||
[app.plugins.parser :as parser]
|
[app.plugins.parser :as parser]
|
||||||
|
[app.plugins.reflow :as wrfp]
|
||||||
[app.plugins.register :as r]
|
[app.plugins.register :as r]
|
||||||
[app.plugins.ruler-guides :as rg]
|
[app.plugins.ruler-guides :as rg]
|
||||||
[app.plugins.shadows :as shadows]
|
[app.plugins.shadows :as shadows]
|
||||||
@ -1057,15 +1057,11 @@
|
|||||||
|
|
||||||
:waitForLayoutUpdate
|
:waitForLayoutUpdate
|
||||||
(fn [timeout]
|
(fn [timeout]
|
||||||
;; Always a promise, so a bad argument travels as a rejection.
|
;; Wait for layout work that can affect this shape.
|
||||||
(if (u/valid-timeout? timeout)
|
(let [objects (u/locate-objects file-id page-id)]
|
||||||
;; Resolves once the reflow work of this shape's subtree has
|
(wrfp/wait-for-layout-update
|
||||||
;; settled: it can be marked on the shape or on its descendants.
|
(wrfp/shape-wait-ids objects file-id id)
|
||||||
(let [objects (u/locate-objects file-id page-id)]
|
timeout)))
|
||||||
(wrf/wait-for-layout-update (cfh/get-children-ids-with-self objects id) timeout))
|
|
||||||
(js/Promise.
|
|
||||||
(fn [_ reject]
|
|
||||||
(u/reject-not-valid reject :waitForLayoutUpdate timeout)))))
|
|
||||||
|
|
||||||
;; Plugin data
|
;; Plugin data
|
||||||
:getPluginData
|
:getPluginData
|
||||||
|
|||||||
@ -499,10 +499,10 @@
|
|||||||
(u/not-valid plugin-id :growType "Cannot modify a page that is not currently active")
|
(u/not-valid plugin-id :growType "Cannot modify a page that is not currently active")
|
||||||
|
|
||||||
:else
|
:else
|
||||||
(st/emit!
|
(do
|
||||||
(dwsh/update-shapes [id] #(assoc % :grow-type value))
|
(st/emit! (dwsh/update-shapes [id] #(assoc % :grow-type value)))
|
||||||
(when (features/active-feature? @st/state "render-wasm/v1")
|
(when (features/active-feature? @st/state "render-wasm/v1")
|
||||||
(st/emit! (dwwt/resize-wasm-text-debounce id)))))))}
|
(st/emit! (dwwt/resize-wasm-text-debounce id)))))))}
|
||||||
|
|
||||||
{:name "fontId"
|
{:name "fontId"
|
||||||
:get #(-> % u/proxy->shape text-props :font-id format/format-mixed)
|
:get #(-> % u/proxy->shape text-props :font-id format/format-mixed)
|
||||||
|
|||||||
@ -291,14 +291,6 @@
|
|||||||
(throw-not-valid code value)
|
(throw-not-valid code value)
|
||||||
(display-not-valid code value)))
|
(display-not-valid code value)))
|
||||||
|
|
||||||
(defn valid-timeout?
|
|
||||||
"A plugin timeout argument: omitted, or a finite positive number of msecs."
|
|
||||||
[value]
|
|
||||||
(or (nil? value)
|
|
||||||
(and (number? value)
|
|
||||||
(pos? value)
|
|
||||||
(js/Number.isFinite value))))
|
|
||||||
|
|
||||||
(defn reject-not-valid
|
(defn reject-not-valid
|
||||||
[reject code value]
|
[reject code value]
|
||||||
(let [msg (dm/str "[PENPOT PLUGIN] Value not valid: " value ". Code: " code)]
|
(let [msg (dm/str "[PENPOT PLUGIN] Value not valid: " value ". Code: " code)]
|
||||||
|
|||||||
@ -30,11 +30,30 @@
|
|||||||
(def ^:private custom-fonts
|
(def ^:private custom-fonts
|
||||||
(l/derived :fonts st/state))
|
(l/derived :fonts st/state))
|
||||||
|
|
||||||
;; Emits the font-id of every font whose glyphs wasm can already shape and
|
;; Emits every font face that WASM can measure.
|
||||||
;; measure with. The browser-side loading of `app.main.fonts` is a separate
|
|
||||||
;; signal: it only says the DOM can render the font.
|
|
||||||
(defonce font-stored-stream (rx/subject))
|
(defonce font-stored-stream (rx/subject))
|
||||||
|
|
||||||
|
;; Emits failed font faces so layout can fall back.
|
||||||
|
(defonce font-storage-failed-stream (rx/subject))
|
||||||
|
|
||||||
|
;; Stores faces that currently use WASM fallbacks.
|
||||||
|
(defonce ^:private failed-font-data-keys (atom #{}))
|
||||||
|
|
||||||
|
(defn font-data-key
|
||||||
|
"Returns the identity WASM uses to distinguish stored faces in one family."
|
||||||
|
[font-data]
|
||||||
|
(select-keys font-data [:font-id :weight :style :emoji?]))
|
||||||
|
|
||||||
|
(defn- clear-font-storage-failure!
|
||||||
|
[font-data]
|
||||||
|
(swap! failed-font-data-keys disj (font-data-key font-data)))
|
||||||
|
|
||||||
|
(defn- report-font-storage-failed!
|
||||||
|
[font-data]
|
||||||
|
(let [key (font-data-key font-data)]
|
||||||
|
(swap! failed-font-data-keys conj key)
|
||||||
|
(rx/push! font-storage-failed-stream key)))
|
||||||
|
|
||||||
(def ^:private default-font-size 14)
|
(def ^:private default-font-size 14)
|
||||||
(def ^:private default-line-height 1.2)
|
(def ^:private default-line-height 1.2)
|
||||||
(def ^:private default-letter-spacing 0.0)
|
(def ^:private default-letter-spacing 0.0)
|
||||||
@ -157,39 +176,91 @@
|
|||||||
(:style font-data)
|
(:style font-data)
|
||||||
emoji?
|
emoji?
|
||||||
fallback?)
|
fallback?)
|
||||||
|
(clear-font-storage-failure! font-data)
|
||||||
;; Reported after the store call: subscribers react by measuring text.
|
;; Reported after the store call: subscribers react by measuring text.
|
||||||
(rx/push! font-stored-stream (:font-id font-data))
|
(rx/push! font-stored-stream (font-data-key font-data))
|
||||||
true)))
|
true)))
|
||||||
|
|
||||||
;; Tracks fonts currently being fetched: {url -> fallback?}
|
;; Tracks every font face waiting on each shared request.
|
||||||
;; When the same font is requested as both primary and fallback,
|
|
||||||
;; the fallback flag is upgraded to true so it gets registered
|
|
||||||
;; in WASM's fallback_fonts set.
|
|
||||||
(def fetching (atom {}))
|
(def fetching (atom {}))
|
||||||
|
|
||||||
|
(defn- register-font-fetch!
|
||||||
|
[font-url font-data emoji? fallback?]
|
||||||
|
(let [key (font-data-key font-data)]
|
||||||
|
(clear-font-storage-failure! font-data)
|
||||||
|
(swap! fetching
|
||||||
|
update-in
|
||||||
|
[font-url key]
|
||||||
|
(fn [request]
|
||||||
|
{:font-data font-data
|
||||||
|
:emoji? emoji?
|
||||||
|
:fallback? (or fallback? (:fallback? request))}))))
|
||||||
|
|
||||||
|
(defn- take-font-fetches!
|
||||||
|
[font-url]
|
||||||
|
(let [requests (vals (get @fetching font-url))]
|
||||||
|
(swap! fetching dissoc font-url)
|
||||||
|
requests))
|
||||||
|
|
||||||
|
(defn- fail-font-fetches!
|
||||||
|
[font-url cause]
|
||||||
|
(let [requests (take-font-fetches! font-url)]
|
||||||
|
(log/error :hint "Could not fetch font"
|
||||||
|
:font-url font-url
|
||||||
|
:cause cause)
|
||||||
|
(doseq [{:keys [font-data]} requests]
|
||||||
|
(report-font-storage-failed! font-data))))
|
||||||
|
|
||||||
|
(defn- store-font-fetch!
|
||||||
|
[body {:keys [font-data emoji? fallback?]}]
|
||||||
|
(try
|
||||||
|
(let [stored? (store-font-buffer font-data body emoji? fallback?)]
|
||||||
|
(when-not stored?
|
||||||
|
(report-font-storage-failed! font-data))
|
||||||
|
stored?)
|
||||||
|
(catch :default cause
|
||||||
|
(log/error :hint "Could not store font"
|
||||||
|
:font-id (:font-id font-data)
|
||||||
|
:cause cause)
|
||||||
|
(report-font-storage-failed! font-data)
|
||||||
|
false)))
|
||||||
|
|
||||||
(defn- fetch-font
|
(defn- fetch-font
|
||||||
[font-data font-url emoji? fallback?]
|
[font-data font-url emoji? fallback?]
|
||||||
(if (contains? @fetching font-url)
|
(cond
|
||||||
(do (when fallback? (swap! fetching assoc font-url true))
|
(nil? font-url)
|
||||||
nil)
|
;; Fail missing font assets without sharing a nil request.
|
||||||
(do
|
(do
|
||||||
(swap! fetching assoc font-url fallback?)
|
(clear-font-storage-failure! font-data)
|
||||||
|
(tm/schedule #(report-font-storage-failed! font-data))
|
||||||
|
nil)
|
||||||
|
|
||||||
|
(contains? @fetching font-url)
|
||||||
|
(do
|
||||||
|
(register-font-fetch! font-url font-data emoji? fallback?)
|
||||||
|
nil)
|
||||||
|
|
||||||
|
:else
|
||||||
|
(do
|
||||||
|
(register-font-fetch! font-url font-data emoji? fallback?)
|
||||||
{:key font-url
|
{:key font-url
|
||||||
:callback
|
:callback
|
||||||
(fn []
|
(fn []
|
||||||
(->> (http/send! {:method :get
|
(try
|
||||||
:uri font-url
|
(->> (http/send! {:method :get
|
||||||
:response-type :buffer})
|
:uri font-url
|
||||||
(rx/map (fn [{:keys [body]}]
|
:response-type :buffer})
|
||||||
(let [fallback? (get @fetching font-url fallback?)]
|
(rx/map
|
||||||
(swap! fetching dissoc font-url)
|
(fn [{:keys [body]}]
|
||||||
(store-font-buffer font-data body emoji? fallback?))))
|
(let [requests (take-font-fetches! font-url)]
|
||||||
(rx/catch (fn [cause]
|
(mapv (partial store-font-fetch! body) requests))))
|
||||||
(swap! fetching dissoc font-url)
|
(rx/catch
|
||||||
(log/error :hint "Could not fetch font"
|
(fn [cause]
|
||||||
:font-url font-url
|
(fail-font-fetches! font-url cause)
|
||||||
:cause cause)
|
(rx/empty))))
|
||||||
(rx/empty)))))})))
|
(catch :default cause
|
||||||
|
(fail-font-fetches! font-url cause)
|
||||||
|
(rx/empty))))})))
|
||||||
|
|
||||||
(defn- google-font-ttf-url
|
(defn- google-font-ttf-url
|
||||||
[font-id font-variant-id font-weight font-style]
|
[font-id font-variant-id font-weight font-style]
|
||||||
@ -220,9 +291,15 @@
|
|||||||
(:style font-data)
|
(:style font-data)
|
||||||
emoji?))))
|
emoji?))))
|
||||||
|
|
||||||
|
(defn font-ready?
|
||||||
|
"Returns true when WASM can lay out with the requested face or its fallback."
|
||||||
|
[font-data]
|
||||||
|
(or (contains? @failed-font-data-keys (font-data-key font-data))
|
||||||
|
(font-stored? font-data (:emoji? font-data))))
|
||||||
|
|
||||||
(defn- store-font-id
|
(defn- store-font-id
|
||||||
[font-data asset-id emoji? fallback?]
|
[font-data asset-id emoji? fallback?]
|
||||||
(when asset-id
|
(if asset-id
|
||||||
(let [uri (font-id->ttf-url
|
(let [uri (font-id->ttf-url
|
||||||
(:font-id font-data) asset-id
|
(:font-id font-data) asset-id
|
||||||
(:font-variant-id font-data)
|
(:font-variant-id font-data)
|
||||||
@ -234,8 +311,16 @@
|
|||||||
(if font-stored?
|
(if font-stored?
|
||||||
;; Deferred so consumers, which subscribe after dispatching the sync
|
;; Deferred so consumers, which subscribe after dispatching the sync
|
||||||
;; that lands here, are listening when an already-stored font reports.
|
;; that lands here, are listening when an already-stored font reports.
|
||||||
(tm/schedule #(rx/push! font-stored-stream (:font-id font-data)))
|
(do
|
||||||
(fetch-font font-data uri emoji? fallback?)))))
|
(clear-font-storage-failure! font-data)
|
||||||
|
(tm/schedule #(rx/push! font-stored-stream (font-data-key font-data))))
|
||||||
|
(fetch-font font-data uri emoji? fallback?)))
|
||||||
|
;; Report missing font assets asynchronously.
|
||||||
|
(do
|
||||||
|
(clear-font-storage-failure! font-data)
|
||||||
|
(tm/schedule
|
||||||
|
#(report-font-storage-failed! font-data))
|
||||||
|
nil)))
|
||||||
|
|
||||||
(defn serialize-font-style
|
(defn serialize-font-style
|
||||||
[font-style]
|
[font-style]
|
||||||
|
|||||||
@ -6,12 +6,22 @@
|
|||||||
|
|
||||||
(ns frontend-tests.data.workspace-reflow-test
|
(ns frontend-tests.data.workspace-reflow-test
|
||||||
"Tests the reflow tasks the layout and text pipelines feed to
|
"Tests the reflow tasks the layout and text pipelines feed to
|
||||||
`app.main.data.workspace.reflow`, which is what plugin waits observe."
|
`app.main.data.workspace.reflow`, which is what plugin waits observe. The
|
||||||
|
promise view of the settle signal lives in `app.plugins.reflow`; these tests
|
||||||
|
use it because it is the wait the plugin API ships."
|
||||||
(:require
|
(:require
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
[app.main.data.workspace.reflow :as wrf]
|
[app.main.data.workspace.reflow :as wrf]
|
||||||
[app.main.data.workspace.shape-layout :as dwsl]
|
[app.main.data.workspace.shape-layout :as dwsl]
|
||||||
|
[app.main.data.workspace.shapes :as dwsh]
|
||||||
[app.main.data.workspace.texts :as dwtxt]
|
[app.main.data.workspace.texts :as dwtxt]
|
||||||
|
[app.main.data.workspace.wasm-text :as dwwt]
|
||||||
|
[app.main.fonts :as fonts]
|
||||||
|
[app.plugins.reflow :as pwrf]
|
||||||
|
[app.render-wasm.api.fonts :as wasm.fonts]
|
||||||
|
[app.util.globals :as globals]
|
||||||
|
[app.util.http :as http]
|
||||||
|
[beicon.v2.core :as rx]
|
||||||
[cljs.test :as t :include-macros true]
|
[cljs.test :as t :include-macros true]
|
||||||
[potok.v2.core :as ptk]))
|
[potok.v2.core :as ptk]))
|
||||||
|
|
||||||
@ -43,7 +53,7 @@
|
|||||||
(t/async done
|
(t/async done
|
||||||
(let [store (start-pipeline!)]
|
(let [store (start-pipeline!)]
|
||||||
(ptk/emit! store (ptk/data-event :layout/update {:ids [uuid/zero]}))
|
(ptk/emit! store (ptk/data-event :layout/update {:ids [uuid/zero]}))
|
||||||
(-> (wrf/wait-for-layout-update nil 20)
|
(-> (pwrf/wait-for-layout-update nil 20)
|
||||||
(.then #(t/is true "resolved with no pending work"))
|
(.then #(t/is true "resolved with no pending work"))
|
||||||
(.catch #(t/is false "a root-only update was marked as pending work"))
|
(.catch #(t/is false "a root-only update was marked as pending work"))
|
||||||
(.then (fn []
|
(.then (fn []
|
||||||
@ -59,16 +69,41 @@
|
|||||||
_ (wrf/reset-pending!)
|
_ (wrf/reset-pending!)
|
||||||
current-task (wrf/start! :text-measure [id])]
|
current-task (wrf/start! :text-measure [id])]
|
||||||
(wrf/finish! stale-task)
|
(wrf/finish! stale-task)
|
||||||
(-> (wrf/wait-for-layout-update [id] 20)
|
(-> (pwrf/wait-for-layout-update [id] 20)
|
||||||
(.then #(t/is false "a stale completion drained current work"))
|
(.then #(t/is false "a stale completion drained current work"))
|
||||||
(.catch #(t/is true "current work stayed pending"))
|
(.catch #(t/is true "current work stayed pending"))
|
||||||
(.then (fn []
|
(.then (fn []
|
||||||
(wrf/finish! current-task)
|
(wrf/finish! current-task)
|
||||||
(wrf/wait-for-layout-update [id] 100)))
|
(pwrf/wait-for-layout-update [id] 100)))
|
||||||
(.then #(t/is true "the exact current task drained normally"))
|
(.then #(t/is true "the exact current task drained normally"))
|
||||||
(.catch #(t/is false "the current task did not drain"))
|
(.catch #(t/is false "the current task did not drain"))
|
||||||
(.then (fn [] (done)))))))
|
(.then (fn [] (done)))))))
|
||||||
|
|
||||||
|
(t/deftest reinstalling-the-pending-scan-resets-work-and-keeps-tracking
|
||||||
|
;; Reinstall the pending scan with the latest reducer.
|
||||||
|
(t/async done
|
||||||
|
(let [id (uuid/next)
|
||||||
|
stale (wrf/start! :text-measure [id])
|
||||||
|
current* (atom nil)]
|
||||||
|
(#'wrf/install-pending-subscription!)
|
||||||
|
(-> (pwrf/wait-for-layout-update [id] 100)
|
||||||
|
(.then #(t/is true "reinstalling the scan reset its previous generation"))
|
||||||
|
(.catch #(t/is false "the replaced scan kept stale work pending"))
|
||||||
|
(.then
|
||||||
|
(fn []
|
||||||
|
(reset! current* (wrf/start! :text-measure [id]))
|
||||||
|
(pwrf/wait-for-layout-update [id] 20)))
|
||||||
|
(.then #(t/is false "the replacement scan did not track new work"))
|
||||||
|
(.catch #(t/is true "the replacement scan tracked new work"))
|
||||||
|
(.then
|
||||||
|
(fn []
|
||||||
|
(wrf/finish! stale)
|
||||||
|
(wrf/finish! @current*)
|
||||||
|
(pwrf/wait-for-layout-update [id] 100)))
|
||||||
|
(.then #(t/is true "the replacement scan drained its exact task"))
|
||||||
|
(.catch #(t/is false "the replacement scan did not drain"))
|
||||||
|
(.then (fn [] (done)))))))
|
||||||
|
|
||||||
(t/deftest pending-promise-finishes-at-the-operation-boundary
|
(t/deftest pending-promise-finishes-at-the-operation-boundary
|
||||||
;; Imperative render work is pending from before its thunk starts until the
|
;; Imperative render work is pending from before its thunk starts until the
|
||||||
;; exact promise returned by that thunk settles; no timer is involved.
|
;; exact promise returned by that thunk settles; no timer is involved.
|
||||||
@ -82,12 +117,12 @@
|
|||||||
(fn []
|
(fn []
|
||||||
(reset! started? true)
|
(reset! started? true)
|
||||||
(js/Promise. (fn [resolve _] (reset! resolve* resolve)))))
|
(js/Promise. (fn [resolve _] (reset! resolve* resolve)))))
|
||||||
(-> (wrf/wait-for-layout-update [id] 20)
|
(-> (pwrf/wait-for-layout-update [id] 20)
|
||||||
(.then #(t/is false "resolved while the render operation was pending"))
|
(.then #(t/is false "resolved while the render operation was pending"))
|
||||||
(.catch #(t/is @started? "the task was opened before running the operation"))
|
(.catch #(t/is @started? "the task was opened before running the operation"))
|
||||||
(.then (fn []
|
(.then (fn []
|
||||||
(@resolve*)
|
(@resolve*)
|
||||||
(wrf/wait-for-layout-update [id] 100)))
|
(pwrf/wait-for-layout-update [id] 100)))
|
||||||
(.then #(t/is true "resolved as soon as the render operation settled"))
|
(.then #(t/is true "resolved as soon as the render operation settled"))
|
||||||
(.catch #(t/is false "the settled render operation stayed pending"))
|
(.catch #(t/is false "the settled render operation stayed pending"))
|
||||||
(.then (fn [] (done)))))))
|
(.then (fn [] (done)))))))
|
||||||
@ -98,7 +133,7 @@
|
|||||||
(wrf/run-pending! :text-measure [id] #(throw (js/Error. "boom")))
|
(wrf/run-pending! :text-measure [id] #(throw (js/Error. "boom")))
|
||||||
(catch :default _))
|
(catch :default _))
|
||||||
(t/async done
|
(t/async done
|
||||||
(-> (wrf/wait-for-layout-update [id] 100)
|
(-> (pwrf/wait-for-layout-update [id] 100)
|
||||||
(.then #(t/is true "a synchronous failure drained its exact task"))
|
(.then #(t/is true "a synchronous failure drained its exact task"))
|
||||||
(.catch #(t/is false "a synchronous failure leaked pending work"))
|
(.catch #(t/is false "a synchronous failure leaked pending work"))
|
||||||
(.then (fn [] (done)))))))
|
(.then (fn [] (done)))))))
|
||||||
@ -110,10 +145,10 @@
|
|||||||
task-a (wrf/start! :text-bridge [id-a])
|
task-a (wrf/start! :text-bridge [id-a])
|
||||||
task-b (wrf/start! :text-bridge [id-b])]
|
task-b (wrf/start! :text-bridge [id-b])]
|
||||||
(wrf/cancel-shapes! [id-a])
|
(wrf/cancel-shapes! [id-a])
|
||||||
(-> (wrf/wait-for-layout-update [id-a] 100)
|
(-> (pwrf/wait-for-layout-update [id-a] 100)
|
||||||
(.then #(t/is true "deleted shape work was cancelled"))
|
(.then #(t/is true "deleted shape work was cancelled"))
|
||||||
(.catch #(t/is false "deleted shape work stayed pending"))
|
(.catch #(t/is false "deleted shape work stayed pending"))
|
||||||
(.then #(wrf/wait-for-layout-update [id-b] 20))
|
(.then #(pwrf/wait-for-layout-update [id-b] 20))
|
||||||
(.then #(t/is false "cancelling one shape drained its sibling"))
|
(.then #(t/is false "cancelling one shape drained its sibling"))
|
||||||
(.catch #(t/is true "sibling work stayed pending"))
|
(.catch #(t/is true "sibling work stayed pending"))
|
||||||
(.then (fn []
|
(.then (fn []
|
||||||
@ -130,28 +165,281 @@
|
|||||||
(ptk/emit! store (ptk/data-event :text/reflow {:ids [id-a id-b]}))
|
(ptk/emit! store (ptk/data-event :text/reflow {:ids [id-a id-b]}))
|
||||||
(let [task-a (wrf/start! :text-measure [id-a])]
|
(let [task-a (wrf/start! :text-measure [id-a])]
|
||||||
(wrf/finish! task-a))
|
(wrf/finish! task-a))
|
||||||
(-> (wrf/wait-for-layout-update [id-b] 20)
|
(-> (pwrf/wait-for-layout-update [id-b] 20)
|
||||||
(.then #(t/is false "the first text released its sibling bridge"))
|
(.then #(t/is false "the first text released its sibling bridge"))
|
||||||
(.catch #(t/is true "the sibling bridge stayed pending"))
|
(.catch #(t/is true "the sibling bridge stayed pending"))
|
||||||
(.then (fn []
|
(.then (fn []
|
||||||
(let [task-b (wrf/start! :text-measure [id-b])]
|
(let [task-b (wrf/start! :text-measure [id-b])]
|
||||||
(wrf/finish! task-b))
|
(wrf/finish! task-b))
|
||||||
(wrf/wait-for-layout-update [id-b] 100)))
|
(pwrf/wait-for-layout-update [id-b] 100)))
|
||||||
(.then #(t/is true "the sibling drained after its own measurement"))
|
(.then #(t/is true "the sibling drained after its own measurement"))
|
||||||
(.catch #(t/is false "the sibling never drained"))
|
(.catch #(t/is false "the sibling never drained"))
|
||||||
(.then (fn []
|
(.then (fn []
|
||||||
(stop-text-pipeline! store)
|
(stop-text-pipeline! store)
|
||||||
(done)))))))
|
(done)))))))
|
||||||
|
|
||||||
|
(t/deftest text-bridge-observes-out-of-order-work
|
||||||
|
;; Start all bridges before matching work can finish.
|
||||||
|
(t/async done
|
||||||
|
(let [store (start-text-pipeline!)
|
||||||
|
id-a (uuid/next)
|
||||||
|
id-b (uuid/next)]
|
||||||
|
(ptk/emit! store (ptk/data-event :text/reflow {:ids [id-a id-b]}))
|
||||||
|
(let [task-b (wrf/start! :text-measure [id-b])]
|
||||||
|
(wrf/finish! task-b))
|
||||||
|
(let [task-a (wrf/start! :text-measure [id-a])]
|
||||||
|
(wrf/finish! task-a))
|
||||||
|
(-> (pwrf/wait-for-layout-update [id-a id-b] 100)
|
||||||
|
(.then #(t/is true "both out-of-order bridges observed their work"))
|
||||||
|
(.catch #(t/is false "a bridge missed work that started out of order"))
|
||||||
|
(.then (fn []
|
||||||
|
(stop-text-pipeline! store)
|
||||||
|
(done)))))))
|
||||||
|
|
||||||
|
(t/deftest text-bridge-does-not-consume-preexisting-work
|
||||||
|
;; Ignore matching work that started before the bridge.
|
||||||
|
(t/async done
|
||||||
|
(let [store (start-text-pipeline!)
|
||||||
|
id (uuid/next)
|
||||||
|
prior-task (wrf/start! :text-measure [id])]
|
||||||
|
(ptk/emit! store (ptk/data-event :text/reflow {:ids [id]}))
|
||||||
|
(wrf/finish! prior-task)
|
||||||
|
(-> (pwrf/wait-for-layout-update [id] 20)
|
||||||
|
(.then #(t/is false "preexisting work released the new bridge"))
|
||||||
|
(.catch #(t/is true "the new bridge remained pending"))
|
||||||
|
(.then (fn []
|
||||||
|
(let [current-task (wrf/start! :text-measure [id])]
|
||||||
|
(wrf/finish! current-task))
|
||||||
|
(pwrf/wait-for-layout-update [id] 100)))
|
||||||
|
(.then #(t/is true "work started after the bridge drained it"))
|
||||||
|
(.catch #(t/is false "the causal measurement did not drain the bridge"))
|
||||||
|
(.then (fn []
|
||||||
|
(stop-text-pipeline! store)
|
||||||
|
(done)))))))
|
||||||
|
|
||||||
|
(t/deftest cancelling-a-bridge-does-not-block-later-reflow-events
|
||||||
|
(t/async done
|
||||||
|
(let [store (start-text-pipeline!)
|
||||||
|
id-a (uuid/next)
|
||||||
|
id-b (uuid/next)]
|
||||||
|
(ptk/emit! store (ptk/data-event :text/reflow {:ids [id-a]}))
|
||||||
|
(wrf/cancel-shapes! [id-a])
|
||||||
|
(ptk/emit! store (ptk/data-event :text/reflow {:ids [id-b]}))
|
||||||
|
(-> (pwrf/wait-for-layout-update [id-b] 20)
|
||||||
|
(.then #(t/is false "the later bridge was not opened"))
|
||||||
|
(.catch #(t/is true "the later bridge stayed pending"))
|
||||||
|
(.then (fn []
|
||||||
|
(let [task-b (wrf/start! :text-measure [id-b])]
|
||||||
|
(wrf/finish! task-b))
|
||||||
|
(pwrf/wait-for-layout-update [id-b] 100)))
|
||||||
|
(.then #(t/is true "the later bridge drained after its own work"))
|
||||||
|
(.catch #(t/is false "the cancelled bridge blocked the pipeline"))
|
||||||
|
(.then (fn []
|
||||||
|
(stop-text-pipeline! store)
|
||||||
|
(done)))))))
|
||||||
|
|
||||||
|
(t/deftest finalizing-a-page-cancels-its-text-bridges
|
||||||
|
(t/async done
|
||||||
|
(let [store (start-text-pipeline!)
|
||||||
|
id (uuid/next)]
|
||||||
|
(ptk/emit! store (ptk/data-event :text/reflow {:ids [id]}))
|
||||||
|
(ptk/emit! store (ptk/data-event :app.main.data.workspace.pages/finalize-page))
|
||||||
|
(-> (pwrf/wait-for-layout-update [id] 100)
|
||||||
|
(.then #(t/is true "page teardown drained the unmeasured text bridge"))
|
||||||
|
(.catch #(t/is false "page teardown left text work pending"))
|
||||||
|
(.then (fn []
|
||||||
|
(stop-text-pipeline! store)
|
||||||
|
(done)))))))
|
||||||
|
|
||||||
|
(t/deftest failed-wasm-font-storage-falls-back-and-drains
|
||||||
|
(t/async done
|
||||||
|
(let [id (uuid/next)
|
||||||
|
font-key {:font-id "gfont-does-not-load"
|
||||||
|
:weight 400
|
||||||
|
:style 0
|
||||||
|
:emoji? false}
|
||||||
|
stream (rx/subject)
|
||||||
|
events (atom [])]
|
||||||
|
(->> (#'dwtxt/await-font-faces stream #{font-key} [id])
|
||||||
|
(rx/subs! #(swap! events conj %)))
|
||||||
|
(#'wasm.fonts/report-font-storage-failed! font-key)
|
||||||
|
(-> (pwrf/wait-for-layout-update [id] 100)
|
||||||
|
(.then (fn []
|
||||||
|
(t/is (= 1 (count @events))
|
||||||
|
"font failure dispatches one fallback resize")
|
||||||
|
(t/is (wasm.fonts/font-ready? font-key)
|
||||||
|
"the resize gate accepts the failed face's fallback")
|
||||||
|
(done)))
|
||||||
|
(.catch (fn [_]
|
||||||
|
(t/is false "font failure leaked pending work")
|
||||||
|
(done)))))))
|
||||||
|
|
||||||
|
(t/deftest failed-dom-font-load-falls-back-and-drains
|
||||||
|
(t/async done
|
||||||
|
(let [id (uuid/next)
|
||||||
|
font-id "gfont-layout-failure-test"]
|
||||||
|
(swap! fonts/fontsdb assoc font-id
|
||||||
|
{:id font-id
|
||||||
|
:backend :google
|
||||||
|
:family "Layout Failure Test"
|
||||||
|
:variants [{:id "regular"}]})
|
||||||
|
(swap! fonts/loaded disj font-id)
|
||||||
|
(swap! fonts/loading dissoc font-id)
|
||||||
|
(with-redefs [globals/browser? (constantly true)
|
||||||
|
http/send! (fn [_] (rx/throw (js/Error. "font fetch failed")))]
|
||||||
|
(wrf/run-pending! :font [id] #(fonts/ensure-loaded! font-id)))
|
||||||
|
(-> (pwrf/wait-for-layout-update [id] 500)
|
||||||
|
(.then (fn []
|
||||||
|
(t/is (not (contains? @fonts/loading font-id))
|
||||||
|
"a failed load must not remain cached as loading")))
|
||||||
|
(.catch #(t/is false "failed DOM font load leaked pending work"))
|
||||||
|
(.then (fn []
|
||||||
|
(swap! fonts/fontsdb dissoc font-id)
|
||||||
|
(swap! fonts/loaded disj font-id)
|
||||||
|
(swap! fonts/loading dissoc font-id)
|
||||||
|
(done)))))))
|
||||||
|
|
||||||
|
(t/deftest failed-google-font-css-does-not-abort-shared-consumers
|
||||||
|
(t/async done
|
||||||
|
(let [font-id "gfont-optional-css-test"
|
||||||
|
values (atom [])
|
||||||
|
cleanup #(swap! fonts/fontsdb dissoc font-id)]
|
||||||
|
(swap! fonts/fontsdb assoc font-id
|
||||||
|
{:id font-id
|
||||||
|
:backend :google
|
||||||
|
:family "Optional CSS Test"
|
||||||
|
:variants [{:id "regular"}]})
|
||||||
|
(with-redefs [http/send! (fn [_] (rx/throw (js/Error. "font css fetch failed")))]
|
||||||
|
(->> (fonts/fetch-font-css {:font-id font-id})
|
||||||
|
(rx/subs!
|
||||||
|
#(swap! values conj %)
|
||||||
|
(fn [_]
|
||||||
|
(cleanup)
|
||||||
|
(t/is false "an optional font CSS failure escaped the shared helper")
|
||||||
|
(done))
|
||||||
|
(fn []
|
||||||
|
(cleanup)
|
||||||
|
(t/is (empty? @values)
|
||||||
|
"a failed optional font contributes no CSS")
|
||||||
|
(done))))))))
|
||||||
|
|
||||||
|
(t/deftest deduplicated-wasm-font-failure-settles-every-face
|
||||||
|
(t/async done
|
||||||
|
(let [font-url "https://example.test/shared-font.ttf"
|
||||||
|
regular {:font-id "gfont-shared-regular"
|
||||||
|
:weight 400
|
||||||
|
:style 0
|
||||||
|
:emoji? false}
|
||||||
|
bold {:font-id "gfont-shared-bold"
|
||||||
|
:weight 700
|
||||||
|
:style 0
|
||||||
|
:emoji? false}]
|
||||||
|
(with-redefs [http/send! (fn [_] (rx/throw (js/Error. "shared fetch failed")))]
|
||||||
|
(let [request (#'wasm.fonts/fetch-font regular font-url false false)
|
||||||
|
duplicate (#'wasm.fonts/fetch-font bold font-url false false)]
|
||||||
|
(t/is (some? request) "the first face owns the shared fetch")
|
||||||
|
(t/is (nil? duplicate) "the second face reuses the shared fetch")
|
||||||
|
(->> ((:callback request))
|
||||||
|
(rx/subs!
|
||||||
|
(fn [_])
|
||||||
|
(fn [_]
|
||||||
|
(t/is false "the shared fetch failure escaped its fallback")
|
||||||
|
(done))
|
||||||
|
(fn []
|
||||||
|
(t/is (wasm.fonts/font-ready? regular)
|
||||||
|
"the first face settled through fallback")
|
||||||
|
(t/is (wasm.fonts/font-ready? bold)
|
||||||
|
"the deduplicated face settled through fallback")
|
||||||
|
(done)))))))))
|
||||||
|
|
||||||
|
(t/deftest missing-wasm-font-url-settles-without-entering-fetch-map
|
||||||
|
(t/async done
|
||||||
|
(let [font-data {:font-id "gfont-missing-url"
|
||||||
|
:weight 400
|
||||||
|
:style 0
|
||||||
|
:emoji? false}]
|
||||||
|
(t/is (nil? (#'wasm.fonts/fetch-font font-data nil false false))
|
||||||
|
"a missing URL starts no request")
|
||||||
|
(t/is (not (contains? @wasm.fonts/fetching nil))
|
||||||
|
"missing URLs are not deduplicated under nil")
|
||||||
|
(js/setTimeout
|
||||||
|
(fn []
|
||||||
|
(t/is (wasm.fonts/font-ready? font-data)
|
||||||
|
"the missing face settled through fallback")
|
||||||
|
(done))
|
||||||
|
0))))
|
||||||
|
|
||||||
|
(t/deftest wasm-font-resize-waits-for-every-face
|
||||||
|
(t/async done
|
||||||
|
(let [id (uuid/next)
|
||||||
|
regular-key {:font-id "gfont-mixed"
|
||||||
|
:weight 400
|
||||||
|
:style 0
|
||||||
|
:emoji? false}
|
||||||
|
bold-key {:font-id "gfont-mixed"
|
||||||
|
:weight 700
|
||||||
|
:style 0
|
||||||
|
:emoji? false}
|
||||||
|
stream (rx/subject)
|
||||||
|
events (atom [])]
|
||||||
|
(->> (#'dwtxt/await-font-faces stream #{regular-key bold-key} [id])
|
||||||
|
(rx/subs! #(swap! events conj %)))
|
||||||
|
(rx/push! wasm.fonts/font-stored-stream regular-key)
|
||||||
|
(-> (pwrf/wait-for-layout-update [id] 20)
|
||||||
|
(.then #(t/is false "the first face released the font task"))
|
||||||
|
(.catch #(t/is true "the second face remained pending"))
|
||||||
|
(.then (fn []
|
||||||
|
(rx/push! wasm.fonts/font-storage-failed-stream bold-key)
|
||||||
|
(pwrf/wait-for-layout-update [id] 100)))
|
||||||
|
(.then (fn []
|
||||||
|
(t/is (= 1 (count @events))
|
||||||
|
"all faces settling dispatches exactly one resize")
|
||||||
|
(done)))
|
||||||
|
(.catch (fn [_]
|
||||||
|
(t/is false "the complete face set did not drain")
|
||||||
|
(done)))))))
|
||||||
|
|
||||||
|
(t/deftest buffered-wasm-resize-releases-on-stop-without-a-commit
|
||||||
|
(t/async done
|
||||||
|
(let [store (ptk/store {:state {} :on-error #(js/console.error %)})
|
||||||
|
id (uuid/next)]
|
||||||
|
(ptk/emit! store (dwsh/update-shapes-buffer-start))
|
||||||
|
(ptk/emit! store (dwwt/resize-wasm-text-all [id]))
|
||||||
|
(-> (pwrf/wait-for-layout-update [id] 20)
|
||||||
|
(.then #(t/is false "the buffered resize was not marked pending"))
|
||||||
|
(.catch #(t/is true "the resize stayed pending while the buffer was open"))
|
||||||
|
(.then (fn []
|
||||||
|
(ptk/emit! store (dwsh/update-shapes-buffer-stop))
|
||||||
|
(pwrf/wait-for-layout-update [id] 500)))
|
||||||
|
(.then #(t/is true "buffer stop released the fallback resize"))
|
||||||
|
(.catch #(t/is false "buffer stop without a commit leaked pending work"))
|
||||||
|
(.then (fn [] (done)))))))
|
||||||
|
|
||||||
|
(t/deftest buffered-wasm-resize-releases-on-workspace-finalize
|
||||||
|
(t/async done
|
||||||
|
(let [store (ptk/store {:state {} :on-error #(js/console.error %)})
|
||||||
|
id (uuid/next)]
|
||||||
|
(ptk/emit! store (dwsh/update-shapes-buffer-start))
|
||||||
|
(ptk/emit! store (dwwt/resize-wasm-text-all [id]))
|
||||||
|
(-> (pwrf/wait-for-layout-update [id] 20)
|
||||||
|
(.then #(t/is false "the buffered resize was not marked pending"))
|
||||||
|
(.catch #(t/is true "the resize stayed pending while the buffer was open"))
|
||||||
|
(.then (fn []
|
||||||
|
(ptk/emit! store (ptk/data-event :app.main.data.workspace/finalize-workspace))
|
||||||
|
(pwrf/wait-for-layout-update [id] 500)))
|
||||||
|
(.then #(t/is true "workspace finalization released the buffered resize"))
|
||||||
|
(.catch #(t/is false "workspace finalization leaked pending work"))
|
||||||
|
(.then (fn [] (done)))))))
|
||||||
|
|
||||||
(t/deftest layout-update-is-pending-until-the-buffer-flushes
|
(t/deftest layout-update-is-pending-until-the-buffer-flushes
|
||||||
;; A shape id is marked on arrival and drained when the update is processed.
|
;; A shape id is marked on arrival and drained when the update is processed.
|
||||||
(t/async done
|
(t/async done
|
||||||
(let [store (start-pipeline!)]
|
(let [store (start-pipeline!)]
|
||||||
(ptk/emit! store (ptk/data-event :layout/update {:ids [(uuid/next) uuid/zero]}))
|
(ptk/emit! store (ptk/data-event :layout/update {:ids [(uuid/next) uuid/zero]}))
|
||||||
(-> (wrf/wait-for-layout-update nil 20)
|
(-> (pwrf/wait-for-layout-update nil 20)
|
||||||
(.then #(t/is false "resolved while the update was still buffered"))
|
(.then #(t/is false "resolved while the update was still buffered"))
|
||||||
(.catch #(t/is true "stayed pending until the flush"))
|
(.catch #(t/is true "stayed pending until the flush"))
|
||||||
(.then #(wrf/wait-for-layout-update nil 5000))
|
(.then #(pwrf/wait-for-layout-update nil 5000))
|
||||||
(.then #(t/is true "resolved once the update was processed"))
|
(.then #(t/is true "resolved once the update was processed"))
|
||||||
(.catch #(t/is false "the pipeline never drained its mark"))
|
(.catch #(t/is false "the pipeline never drained its mark"))
|
||||||
(.then (fn []
|
(.then (fn []
|
||||||
|
|||||||
@ -11,9 +11,11 @@
|
|||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
[app.main.data.workspace.reflow :as wrf]
|
[app.main.data.workspace.reflow :as wrf]
|
||||||
[app.main.data.workspace.shapes :as dwsh]
|
[app.main.data.workspace.shapes :as dwsh]
|
||||||
|
[app.main.data.workspace.texts :as dwtxt]
|
||||||
[app.main.data.workspace.wasm-text :as dwwt]
|
[app.main.data.workspace.wasm-text :as dwwt]
|
||||||
[app.main.store :as st]
|
[app.main.store :as st]
|
||||||
[app.plugins.api :as api]
|
[app.plugins.api :as api]
|
||||||
|
[app.plugins.reflow :as pwrf]
|
||||||
[app.plugins.shape :as shape]
|
[app.plugins.shape :as shape]
|
||||||
[app.util.object :as obj]
|
[app.util.object :as obj]
|
||||||
[beicon.v2.core :as rx]
|
[beicon.v2.core :as rx]
|
||||||
@ -445,6 +447,24 @@
|
|||||||
(set! st/stream (ptk/input-stream test-store))
|
(set! st/stream (ptk/input-stream test-store))
|
||||||
test-store))
|
test-store))
|
||||||
|
|
||||||
|
(t/deftest test-update-shapes-invokes-update-function-once
|
||||||
|
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)
|
||||||
|
{:renderer :svg})
|
||||||
|
_ (set! st/state store)
|
||||||
|
_ (set! st/stream (ptk/input-stream store))
|
||||||
|
^js ctx (api/create-context zero-id)
|
||||||
|
^js rect (.createRectangle ctx)
|
||||||
|
id (obj/get rect "$id")
|
||||||
|
calls (atom 0)]
|
||||||
|
(ptk/emit! store
|
||||||
|
(dwsh/update-shapes
|
||||||
|
[id]
|
||||||
|
(fn [shape]
|
||||||
|
(swap! calls inc)
|
||||||
|
(assoc shape :opacity 0.5))))
|
||||||
|
(t/is (= 1 @calls) "the update function ran once for the committed shape")
|
||||||
|
(t/is (= 0.5 (.-opacity rect)) "the single computed result was committed")))
|
||||||
|
|
||||||
(t/deftest test-wait-for-layout-update-no-pending
|
(t/deftest test-wait-for-layout-update-no-pending
|
||||||
;; When nothing is pending the promise resolves immediately via the fast path
|
;; When nothing is pending the promise resolves immediately via the fast path
|
||||||
;; (the behavior-subject replays the empty map on subscribe).
|
;; (the behavior-subject replays the empty map on subscribe).
|
||||||
@ -459,6 +479,209 @@
|
|||||||
(t/is false (str "unexpected rejection: " err))
|
(t/is false (str "unexpected rejection: " err))
|
||||||
(done)))))))
|
(done)))))))
|
||||||
|
|
||||||
|
(t/deftest test-create-text-bridges-dom-measurement
|
||||||
|
(t/async done
|
||||||
|
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)
|
||||||
|
{:renderer :svg})]
|
||||||
|
(set! st/state store)
|
||||||
|
(set! st/stream (ptk/input-stream store))
|
||||||
|
(ptk/emit! store (dwtxt/initialize-text-reflow))
|
||||||
|
(let [^js ctx (api/create-context zero-id)
|
||||||
|
^js text (.createText ctx "Measure me")
|
||||||
|
id (obj/get text "$id")]
|
||||||
|
(-> (.waitForLayoutUpdate ctx 20)
|
||||||
|
(.then #(t/is false "createText resolved before DOM measurement started"))
|
||||||
|
(.catch #(t/is true "createText stayed bridged to DOM measurement"))
|
||||||
|
(.then (fn []
|
||||||
|
(let [task (wrf/start! :text-measure [id])]
|
||||||
|
(wrf/finish! task))
|
||||||
|
(.waitForLayoutUpdate ctx 100)))
|
||||||
|
(.then #(t/is true "the bridge drained after measurement started"))
|
||||||
|
(.catch #(t/is false "the createText bridge did not drain"))
|
||||||
|
(.then (fn []
|
||||||
|
(ptk/emit! store (dwtxt/finalize-text-reflow))
|
||||||
|
(done))))))))
|
||||||
|
|
||||||
|
(t/deftest test-dom-position-data-stays-pending-until-commit
|
||||||
|
(t/async done
|
||||||
|
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)
|
||||||
|
{:renderer :svg})]
|
||||||
|
(set! st/state store)
|
||||||
|
(set! st/stream (ptk/input-stream store))
|
||||||
|
(ptk/emit! store (dwtxt/initialize-text-reflow))
|
||||||
|
(let [^js ctx (api/create-context zero-id)
|
||||||
|
^js text (.createText ctx "Position me")
|
||||||
|
id (obj/get text "$id")
|
||||||
|
task (wrf/start! :text-measure [id])
|
||||||
|
position-data
|
||||||
|
[{:x 10 :y 20 :width 30 :height 12}]]
|
||||||
|
(wrf/finish! task)
|
||||||
|
(-> (.waitForLayoutUpdate text 100)
|
||||||
|
(.then
|
||||||
|
(fn []
|
||||||
|
(ptk/emit! store (dwtxt/update-position-data id position-data))
|
||||||
|
(-> (.waitForLayoutUpdate text 20)
|
||||||
|
(.then (constantly false))
|
||||||
|
(.catch (constantly true)))))
|
||||||
|
(.then
|
||||||
|
(fn [timed-out?]
|
||||||
|
(t/is timed-out?
|
||||||
|
"position data stayed pending across its debounce")
|
||||||
|
(.waitForLayoutUpdate text 500)))
|
||||||
|
(.then
|
||||||
|
(fn []
|
||||||
|
(let [bounds (.-textBounds text)]
|
||||||
|
(t/is (= 30 (obj/get bounds "width"))
|
||||||
|
"the wait exposed the committed text bounds"))
|
||||||
|
(ptk/emit! store (dwtxt/finalize-text-reflow))
|
||||||
|
(done)))
|
||||||
|
(.catch
|
||||||
|
(fn [cause]
|
||||||
|
(ptk/emit! store (dwtxt/finalize-text-reflow))
|
||||||
|
(t/is false (str "position-data wait did not settle: " cause))
|
||||||
|
(done))))))))
|
||||||
|
|
||||||
|
(t/deftest test-buffered-text-update-bridges-dom-measurement
|
||||||
|
(t/async done
|
||||||
|
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)
|
||||||
|
{:renderer :svg})]
|
||||||
|
(set! st/state store)
|
||||||
|
(set! st/stream (ptk/input-stream store))
|
||||||
|
(ptk/emit! store (dwtxt/initialize-text-reflow))
|
||||||
|
(let [^js ctx (api/create-context zero-id)
|
||||||
|
^js text (.createText ctx "Before")
|
||||||
|
id (obj/get text "$id")
|
||||||
|
task (wrf/start! :text-measure [id])]
|
||||||
|
(wrf/finish! task)
|
||||||
|
(-> (.waitForLayoutUpdate text 100)
|
||||||
|
(.then
|
||||||
|
(fn []
|
||||||
|
(ptk/emit! store (dwsh/update-shapes-buffer-start))
|
||||||
|
(set! (.-characters text) "After")
|
||||||
|
(ptk/emit! store (dwsh/update-shapes-buffer-stop))
|
||||||
|
(.waitForLayoutUpdate text 20)))
|
||||||
|
(.then #(t/is false "buffered update resolved before DOM measurement"))
|
||||||
|
(.catch #(t/is true "buffered update stayed bridged to DOM measurement"))
|
||||||
|
(.then
|
||||||
|
(fn []
|
||||||
|
(let [task (wrf/start! :text-measure [id])]
|
||||||
|
(wrf/finish! task))
|
||||||
|
(.waitForLayoutUpdate text 100)))
|
||||||
|
(.then #(t/is true "the buffered update bridge drained"))
|
||||||
|
(.catch #(t/is false "the buffered update bridge did not drain"))
|
||||||
|
(.then
|
||||||
|
(fn []
|
||||||
|
(ptk/emit! store (dwtxt/finalize-text-reflow))
|
||||||
|
(done))))))))
|
||||||
|
|
||||||
|
(t/deftest test-wasm-grow-type-wait-observes-its-resize
|
||||||
|
(t/async done
|
||||||
|
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1))]
|
||||||
|
(set! st/state store)
|
||||||
|
(set! st/stream (ptk/input-stream store))
|
||||||
|
(ptk/emit! store (dwtxt/initialize-text-reflow))
|
||||||
|
(let [^js ctx (api/create-context zero-id)
|
||||||
|
^js text (.createText ctx "Resize after grow type")]
|
||||||
|
(-> (.waitForLayoutUpdate text 500)
|
||||||
|
(.then
|
||||||
|
(fn []
|
||||||
|
(set! (.-growType text) "fixed")
|
||||||
|
(.waitForLayoutUpdate text 500)))
|
||||||
|
(.then
|
||||||
|
(fn []
|
||||||
|
(t/is (= "fixed" (.-growType text))
|
||||||
|
"the grow-type bridge drained after its WASM resize")
|
||||||
|
(ptk/emit! store (dwtxt/finalize-text-reflow))
|
||||||
|
(done)))
|
||||||
|
(.catch
|
||||||
|
(fn [cause]
|
||||||
|
(ptk/emit! store (dwtxt/finalize-text-reflow))
|
||||||
|
(t/is false (str "grow-type wait did not settle: " cause))
|
||||||
|
(done))))))))
|
||||||
|
|
||||||
|
(t/deftest test-cloned-text-bridges-dom-measurement
|
||||||
|
(t/async done
|
||||||
|
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)
|
||||||
|
{:renderer :svg})]
|
||||||
|
(set! st/state store)
|
||||||
|
(set! st/stream (ptk/input-stream store))
|
||||||
|
(ptk/emit! store (dwtxt/initialize-text-reflow))
|
||||||
|
(let [^js ctx (api/create-context zero-id)
|
||||||
|
^js text (.createText ctx "Clone me")
|
||||||
|
id (obj/get text "$id")
|
||||||
|
task (wrf/start! :text-measure [id])]
|
||||||
|
(wrf/finish! task)
|
||||||
|
(-> (.waitForLayoutUpdate text 100)
|
||||||
|
(.then
|
||||||
|
(fn []
|
||||||
|
(let [^js clone (.clone text)
|
||||||
|
clone-id (obj/get clone "$id")]
|
||||||
|
(-> (.waitForLayoutUpdate clone 20)
|
||||||
|
(.then #(t/is false "clone resolved before DOM measurement"))
|
||||||
|
(.catch #(t/is true "clone stayed bridged to DOM measurement"))
|
||||||
|
(.then
|
||||||
|
(fn []
|
||||||
|
(let [task (wrf/start! :text-measure [clone-id])]
|
||||||
|
(wrf/finish! task))
|
||||||
|
(.waitForLayoutUpdate clone 100)))))))
|
||||||
|
(.then #(t/is true "the cloned text bridge drained"))
|
||||||
|
(.catch #(t/is false "the cloned text bridge did not drain"))
|
||||||
|
(.then
|
||||||
|
(fn []
|
||||||
|
(ptk/emit! store (dwtxt/finalize-text-reflow))
|
||||||
|
(done))))))))
|
||||||
|
|
||||||
|
(t/deftest test-fixed-text-resize-bridges-dom-measurement
|
||||||
|
(t/async done
|
||||||
|
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)
|
||||||
|
{:renderer :svg})]
|
||||||
|
(set! st/state store)
|
||||||
|
(set! st/stream (ptk/input-stream store))
|
||||||
|
(ptk/emit! store (dwtxt/initialize-text-reflow))
|
||||||
|
(let [^js ctx (api/create-context zero-id)
|
||||||
|
^js text (.createText ctx "Resize me")
|
||||||
|
id (obj/get text "$id")
|
||||||
|
task (wrf/start! :text-measure [id])]
|
||||||
|
(wrf/finish! task)
|
||||||
|
(-> (.waitForLayoutUpdate text 100)
|
||||||
|
(.then
|
||||||
|
(fn []
|
||||||
|
;; Finish the grow-type update before resizing.
|
||||||
|
(set! (.-growType text) "fixed")
|
||||||
|
(let [task (wrf/start! :text-measure [id])]
|
||||||
|
(wrf/finish! task))
|
||||||
|
(.waitForLayoutUpdate text 100)))
|
||||||
|
(.then
|
||||||
|
(fn []
|
||||||
|
(.resize text 240 80)
|
||||||
|
;; Turn only this short wait into a boolean.
|
||||||
|
(-> (.waitForLayoutUpdate text 20)
|
||||||
|
(.then (fn [] false))
|
||||||
|
(.catch (fn [_] true)))))
|
||||||
|
(.then
|
||||||
|
(fn [timed-out?]
|
||||||
|
(t/is timed-out?
|
||||||
|
"fixed text resize stayed bridged to DOM measurement")
|
||||||
|
(let [task (wrf/start! :text-measure [id])]
|
||||||
|
(wrf/finish! task))
|
||||||
|
(.waitForLayoutUpdate text 100)))
|
||||||
|
(.then
|
||||||
|
(fn []
|
||||||
|
(t/is true "the resize bridge drained after measurement")
|
||||||
|
;; Match the DOM renderer's 0.001 geometry tolerance.
|
||||||
|
(.resize text 240.0005 80)
|
||||||
|
(.waitForLayoutUpdate text 100)))
|
||||||
|
(.then
|
||||||
|
(fn []
|
||||||
|
(t/is true "a sub-tolerance resize opened no DOM bridge")
|
||||||
|
(ptk/emit! store (dwtxt/finalize-text-reflow))
|
||||||
|
(done)))
|
||||||
|
(.catch
|
||||||
|
(fn [cause]
|
||||||
|
(ptk/emit! store (dwtxt/finalize-text-reflow))
|
||||||
|
(t/is false (str "unexpected resize bridge rejection: " cause))
|
||||||
|
(done))))))))
|
||||||
|
|
||||||
(t/deftest test-wait-for-layout-update-pending
|
(t/deftest test-wait-for-layout-update-pending
|
||||||
;; While a shape is pending the context promise stays unresolved; it resolves
|
;; While a shape is pending the context promise stays unresolved; it resolves
|
||||||
;; once that shape is marked done.
|
;; once that shape is marked done.
|
||||||
@ -584,6 +807,54 @@
|
|||||||
20))
|
20))
|
||||||
20))))))
|
20))))))
|
||||||
|
|
||||||
|
(t/deftest test-wait-for-layout-update-ancestor
|
||||||
|
;; A shape wait also covers layout on its parents.
|
||||||
|
(t/async done
|
||||||
|
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1))]
|
||||||
|
(set! st/state store)
|
||||||
|
(set! st/stream (ptk/input-stream store))
|
||||||
|
(let [^js ctx (api/create-context zero-id)
|
||||||
|
^js board (.createBoard ctx)
|
||||||
|
^js rect (.createRectangle ctx)]
|
||||||
|
(.appendChild board rect)
|
||||||
|
(let [board-id (obj/get board "$id")
|
||||||
|
task (wrf/start! :layout [board-id])
|
||||||
|
resolved (atom false)]
|
||||||
|
(-> (.waitForLayoutUpdate rect)
|
||||||
|
(.then (fn [] (reset! resolved true)))
|
||||||
|
(.catch (fn [err]
|
||||||
|
(t/is false (str "unexpected rejection: " err)))))
|
||||||
|
(js/setTimeout
|
||||||
|
(fn []
|
||||||
|
(t/is (false? @resolved) "child wait must block on a pending ancestor")
|
||||||
|
(wrf/finish! task)
|
||||||
|
(js/setTimeout
|
||||||
|
(fn []
|
||||||
|
(t/is (true? @resolved) "resolves once the ancestor drains")
|
||||||
|
(done))
|
||||||
|
20))
|
||||||
|
20))))))
|
||||||
|
|
||||||
|
(t/deftest test-shape-wait-observes-file-sync
|
||||||
|
(t/async done
|
||||||
|
(let [file (cthf/sample-file :file1 :page-label :page1)
|
||||||
|
store (ths/setup-store file)
|
||||||
|
_ (set! st/state store)
|
||||||
|
_ (set! st/stream (ptk/input-stream store))
|
||||||
|
^js ctx (api/create-context zero-id)
|
||||||
|
^js shape (.createRectangle ctx)
|
||||||
|
task (wrf/start! :sync-file [(:id file)])]
|
||||||
|
(-> (.waitForLayoutUpdate shape 20)
|
||||||
|
(.then #(t/is false "shape wait ignored its pending file sync"))
|
||||||
|
(.catch
|
||||||
|
(fn []
|
||||||
|
(t/is true "shape wait remained pending for its file sync")
|
||||||
|
(wrf/finish! task)
|
||||||
|
(.waitForLayoutUpdate shape 100)))
|
||||||
|
(.then #(t/is true "shape wait drained after the file sync"))
|
||||||
|
(.catch #(t/is false "shape wait did not drain its file sync"))
|
||||||
|
(.then (fn [] (done)))))))
|
||||||
|
|
||||||
(t/deftest test-wait-for-layout-update-invalid-timeout
|
(t/deftest test-wait-for-layout-update-invalid-timeout
|
||||||
;; A non-numeric or non-positive timeout is an invalid argument. The method
|
;; A non-numeric or non-positive timeout is an invalid argument. The method
|
||||||
;; always hands back a promise and rejects it, whatever the plugin's
|
;; always hands back a promise and rejects it, whatever the plugin's
|
||||||
@ -603,6 +874,7 @@
|
|||||||
(rejected? (.waitForLayoutUpdate ctx -5))
|
(rejected? (.waitForLayoutUpdate ctx -5))
|
||||||
(rejected? (.waitForLayoutUpdate ctx js/NaN))
|
(rejected? (.waitForLayoutUpdate ctx js/NaN))
|
||||||
(rejected? (.waitForLayoutUpdate ctx js/Infinity))
|
(rejected? (.waitForLayoutUpdate ctx js/Infinity))
|
||||||
|
(rejected? (.waitForLayoutUpdate ctx 2147483648))
|
||||||
(rejected? (.waitForLayoutUpdate shape "soon"))])
|
(rejected? (.waitForLayoutUpdate shape "soon"))])
|
||||||
(.then (fn [results]
|
(.then (fn [results]
|
||||||
(t/is (every? true? (array-seq results))
|
(t/is (every? true? (array-seq results))
|
||||||
@ -661,7 +933,7 @@
|
|||||||
resolved (atom false)]
|
resolved (atom false)]
|
||||||
(ptk/emit! store (dwsh/update-shapes-buffer-start))
|
(ptk/emit! store (dwsh/update-shapes-buffer-start))
|
||||||
(ptk/emit! store (dwwt/resize-wasm-text-all [id]))
|
(ptk/emit! store (dwwt/resize-wasm-text-all [id]))
|
||||||
(-> (wrf/wait-for-layout-update [id] nil)
|
(-> (pwrf/wait-for-layout-update [id] nil)
|
||||||
(.then (fn [] (reset! resolved true)))
|
(.then (fn [] (reset! resolved true)))
|
||||||
(.catch (fn [err]
|
(.catch (fn [err]
|
||||||
(t/is false (str "unexpected rejection: " err)))))
|
(t/is false (str "unexpected rejection: " err)))))
|
||||||
|
|||||||
@ -1,6 +1,13 @@
|
|||||||
import { expect, expectReject } from '../framework/expect';
|
import { expect, expectReject } from '../framework/expect';
|
||||||
import { describe, test } from '../framework/registry';
|
import { describe, test } from '../framework/registry';
|
||||||
import type { Board, Font, Group, Shape, Text } from '@penpot/plugin-types';
|
import type {
|
||||||
|
Board,
|
||||||
|
Font,
|
||||||
|
Group,
|
||||||
|
Penpot,
|
||||||
|
Shape,
|
||||||
|
Text,
|
||||||
|
} from '@penpot/plugin-types';
|
||||||
import type { TestContext } from '../framework/types';
|
import type { TestContext } from '../framework/types';
|
||||||
|
|
||||||
// waitForLayoutUpdate (context-level and per-shape).
|
// waitForLayoutUpdate (context-level and per-shape).
|
||||||
@ -43,8 +50,17 @@ function byX(rects: [Shape, Shape]): { left: Shape; right: Shape } {
|
|||||||
return a.x <= b.x ? { left: a, right: b } : { left: b, right: a };
|
return a.x <= b.x ? { left: a, right: b } : { left: b, right: a };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Font ids already handed out by `unloadedFont`. */
|
/** Tracks fonts used by this test run. */
|
||||||
const claimedFonts = new Set<string>();
|
const claimedFontsByRun = new WeakMap<Penpot, Set<string>>();
|
||||||
|
|
||||||
|
function claimedFonts(ctx: TestContext): Set<string> {
|
||||||
|
let claimed = claimedFontsByRun.get(ctx.penpot);
|
||||||
|
if (!claimed) {
|
||||||
|
claimed = new Set<string>();
|
||||||
|
claimedFontsByRun.set(ctx.penpot, claimed);
|
||||||
|
}
|
||||||
|
return claimed;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Picks an unclaimed font differing from the text's current one, so assigning
|
* Picks an unclaimed font differing from the text's current one, so assigning
|
||||||
@ -53,11 +69,12 @@ const claimedFonts = new Set<string>();
|
|||||||
*/
|
*/
|
||||||
function unloadedFont(ctx: TestContext, t: Text): Font {
|
function unloadedFont(ctx: TestContext, t: Text): Font {
|
||||||
const all = ctx.penpot.fonts.all;
|
const all = ctx.penpot.fonts.all;
|
||||||
|
const claimed = claimedFonts(ctx);
|
||||||
for (let i = all.length - 1; i >= 0; i--) {
|
for (let i = all.length - 1; i >= 0; i--) {
|
||||||
const f = all[i];
|
const f = all[i];
|
||||||
if (f.fontId === t.fontId || f.variants.length === 0) continue;
|
if (f.fontId === t.fontId || f.variants.length === 0) continue;
|
||||||
if (claimedFonts.has(f.fontId)) continue;
|
if (claimed.has(f.fontId)) continue;
|
||||||
claimedFonts.add(f.fontId);
|
claimed.add(f.fontId);
|
||||||
return f;
|
return f;
|
||||||
}
|
}
|
||||||
throw new Error('no alternative font available');
|
throw new Error('no alternative font available');
|
||||||
@ -351,6 +368,89 @@ describe('WaitForLayoutUpdate', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Components', () => {
|
||||||
|
test('wait covers propagation from a component main to its copy', async (ctx) => {
|
||||||
|
const source = ctx.penpot.createRectangle();
|
||||||
|
ctx.board.appendChild(source);
|
||||||
|
const component = ctx.penpot.library.local.createComponent([source]);
|
||||||
|
const main = component.mainInstance() as Board;
|
||||||
|
const copy = component.instance() as Board;
|
||||||
|
ctx.board.appendChild(copy);
|
||||||
|
await ctx.penpot.waitForLayoutUpdate();
|
||||||
|
|
||||||
|
const mainChild = main.children[0];
|
||||||
|
const copyChild = copy.children[0];
|
||||||
|
mainChild.opacity = 0.37;
|
||||||
|
|
||||||
|
await ctx.penpot.waitForLayoutUpdate();
|
||||||
|
expect(copyChild.opacity).toBeCloseTo(0.37);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shape wait covers propagation from a component main to its copy', async (ctx) => {
|
||||||
|
const source = ctx.penpot.createRectangle();
|
||||||
|
ctx.board.appendChild(source);
|
||||||
|
const component = ctx.penpot.library.local.createComponent([source]);
|
||||||
|
const main = component.mainInstance() as Board;
|
||||||
|
const copy = component.instance() as Board;
|
||||||
|
ctx.board.appendChild(copy);
|
||||||
|
await ctx.penpot.waitForLayoutUpdate();
|
||||||
|
|
||||||
|
const mainChild = main.children[0];
|
||||||
|
const copyChild = copy.children[0];
|
||||||
|
mainChild.opacity = 0.63;
|
||||||
|
|
||||||
|
await copyChild.waitForLayoutUpdate();
|
||||||
|
expect(copyChild.opacity).toBeCloseTo(0.63);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wait covers layout triggered by component propagation', async (ctx) => {
|
||||||
|
const host = flexBoard(ctx);
|
||||||
|
const flex = host.addFlexLayout();
|
||||||
|
flex.dir = 'row';
|
||||||
|
flex.columnGap = 10;
|
||||||
|
const first = ctx.penpot.createRectangle();
|
||||||
|
first.resize(50, 50);
|
||||||
|
flex.appendChild(first);
|
||||||
|
const second = ctx.penpot.createRectangle();
|
||||||
|
second.resize(50, 50);
|
||||||
|
flex.appendChild(second);
|
||||||
|
|
||||||
|
const component = ctx.penpot.library.local.createComponent([host]);
|
||||||
|
const main = component.mainInstance() as Board;
|
||||||
|
const copy = component.instance() as Board;
|
||||||
|
ctx.board.appendChild(copy);
|
||||||
|
await ctx.penpot.waitForLayoutUpdate();
|
||||||
|
|
||||||
|
const { left: mainLeft } = byX([main.children[0], main.children[1]]);
|
||||||
|
mainLeft.resize(120, 50);
|
||||||
|
|
||||||
|
await ctx.penpot.waitForLayoutUpdate();
|
||||||
|
const { left: copyLeft, right: copyRight } = byX([
|
||||||
|
copy.children[0],
|
||||||
|
copy.children[1],
|
||||||
|
]);
|
||||||
|
expect(copyLeft.width).toBeCloseTo(120, 0);
|
||||||
|
expect(copyRight.x - copyLeft.x).toBeCloseTo(130, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Library assets', () => {
|
||||||
|
test('wait covers propagation from a local color to a referenced shape', async (ctx) => {
|
||||||
|
const color = ctx.penpot.library.local.createColor();
|
||||||
|
color.color = '#112233';
|
||||||
|
color.opacity = 1;
|
||||||
|
const rect = ctx.penpot.createRectangle();
|
||||||
|
rect.fills = [color.asFill()];
|
||||||
|
ctx.board.appendChild(rect);
|
||||||
|
await ctx.penpot.waitForLayoutUpdate();
|
||||||
|
|
||||||
|
color.color = '#aabbcc';
|
||||||
|
|
||||||
|
await ctx.penpot.waitForLayoutUpdate();
|
||||||
|
expect(rect.fills[0]?.fillColor).toBe('#aabbcc');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// A font applied to a group reaches its text descendants, so the work is
|
// A font applied to a group reaches its text descendants, so the work is
|
||||||
// pending on the children and never on the group itself. Skipped under a
|
// pending on the children and never on the group itself. Skipped under a
|
||||||
// mocked backend for the same reason as the Text group: no fonts are served,
|
// mocked backend for the same reason as the Text group: no fonts are served,
|
||||||
|
|||||||
@ -60,15 +60,41 @@ body {
|
|||||||
background-color: var(--background-secondary);
|
background-color: var(--background-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-summary {
|
.group-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-8, 8px);
|
gap: var(--spacing-8, 8px);
|
||||||
padding: var(--spacing-8, 8px);
|
padding: var(--spacing-8, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Makes the full header row toggle the group. */
|
||||||
|
.group-toggle {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-8, 8px);
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: start;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.group-chevron {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--foreground-secondary);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-toggle[aria-expanded='true'] .group-chevron {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
.group-name {
|
.group-name {
|
||||||
color: var(--foreground-primary);
|
color: var(--foreground-primary);
|
||||||
}
|
}
|
||||||
@ -144,6 +170,11 @@ body {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keeps hidden test lists out of the layout. */
|
||||||
|
.test-list[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.test-row {
|
.test-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto auto;
|
grid-template-columns: 1fr auto auto;
|
||||||
|
|||||||
@ -128,6 +128,13 @@ function reloadIcon(): SVGSVGElement {
|
|||||||
return svgIcon(['M13 8a5 5 0 1 1-1.46-3.54', 'M13 2.5v3h-3'], false);
|
return svgIcon(['M13 8a5 5 0 1 1-1.46-3.54', 'M13 2.5v3h-3'], false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shows whether a group is expanded. */
|
||||||
|
function chevronIcon(): SVGSVGElement {
|
||||||
|
const icon = svgIcon(['M6 3.5 10.5 8 6 12.5'], false);
|
||||||
|
icon.classList.add('group-chevron');
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
root.replaceChildren(
|
root.replaceChildren(
|
||||||
renderHeader(),
|
renderHeader(),
|
||||||
@ -273,9 +280,12 @@ function renderRow(test: TestMeta): HTMLElement {
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGroupSummary(
|
/** Builds a group header with separate select, toggle, and run controls. */
|
||||||
|
function renderGroupHeader(
|
||||||
name: string,
|
name: string,
|
||||||
groupTestList: TestMeta[],
|
groupTestList: TestMeta[],
|
||||||
|
panelId: string,
|
||||||
|
expanded: boolean,
|
||||||
): HTMLElement {
|
): HTMLElement {
|
||||||
const statuses = groupTestList.map(
|
const statuses = groupTestList.map(
|
||||||
(t) => results.get(t.id)?.status ?? 'pending',
|
(t) => results.get(t.id)?.status ?? 'pending',
|
||||||
@ -291,12 +301,12 @@ function renderGroupSummary(
|
|||||||
const groupCheckbox = el('input', {
|
const groupCheckbox = el('input', {
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
className: 'checkbox-input',
|
className: 'checkbox-input',
|
||||||
|
title: `Select every test in "${name}"`,
|
||||||
|
ariaLabel: `Select every test in "${name}"`,
|
||||||
checked: selectedCount === total && total > 0,
|
checked: selectedCount === total && total > 0,
|
||||||
disabled: running,
|
disabled: running,
|
||||||
});
|
});
|
||||||
groupCheckbox.indeterminate = selectedCount > 0 && selectedCount < total;
|
groupCheckbox.indeterminate = selectedCount > 0 && selectedCount < total;
|
||||||
// Keep the checkbox from toggling the <details> when clicked.
|
|
||||||
groupCheckbox.addEventListener('click', (e) => e.stopPropagation());
|
|
||||||
groupCheckbox.addEventListener('change', () => {
|
groupCheckbox.addEventListener('change', () => {
|
||||||
if (groupCheckbox.checked) ids.forEach((id) => selected.add(id));
|
if (groupCheckbox.checked) ids.forEach((id) => selected.add(id));
|
||||||
else ids.forEach((id) => selected.delete(id));
|
else ids.forEach((id) => selected.delete(id));
|
||||||
@ -305,17 +315,14 @@ function renderGroupSummary(
|
|||||||
|
|
||||||
const runButton = el('button', {
|
const runButton = el('button', {
|
||||||
className: 'icon-button run-group',
|
className: 'icon-button run-group',
|
||||||
|
type: 'button',
|
||||||
title: `Run "${name}"`,
|
title: `Run "${name}"`,
|
||||||
ariaLabel: `Run "${name}"`,
|
ariaLabel: `Run "${name}"`,
|
||||||
disabled: running,
|
disabled: running,
|
||||||
});
|
});
|
||||||
runButton.dataset.appearance = 'secondary';
|
runButton.dataset.appearance = 'secondary';
|
||||||
runButton.append(playIcon());
|
runButton.append(playIcon());
|
||||||
runButton.addEventListener('click', (e) => {
|
runButton.addEventListener('click', () => run(ids));
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
run(ids);
|
|
||||||
});
|
|
||||||
|
|
||||||
const counts = el('span', { className: 'group-counts' }, [
|
const counts = el('span', { className: 'group-counts' }, [
|
||||||
el('span', { className: 'count-pass', textContent: `${passed}` }),
|
el('span', { className: 'count-pass', textContent: `${passed}` }),
|
||||||
@ -327,14 +334,26 @@ function renderGroupSummary(
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return el('summary', { className: 'group-summary' }, [
|
const toggle = el('button', { className: 'group-toggle', type: 'button' }, [
|
||||||
groupCheckbox,
|
chevronIcon(),
|
||||||
el('span', {
|
el('span', {
|
||||||
className: `status-dot dot-${aggregate}`,
|
className: `status-dot dot-${aggregate}`,
|
||||||
title: statusLabel(aggregate),
|
title: statusLabel(aggregate),
|
||||||
}),
|
}),
|
||||||
el('span', { className: 'group-name', textContent: name }),
|
el('span', { className: 'group-name', textContent: name }),
|
||||||
counts,
|
counts,
|
||||||
|
]);
|
||||||
|
toggle.setAttribute('aria-expanded', String(expanded));
|
||||||
|
toggle.setAttribute('aria-controls', panelId);
|
||||||
|
toggle.addEventListener('click', () => {
|
||||||
|
if (expanded) expandedGroups.delete(name);
|
||||||
|
else expandedGroups.add(name);
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
return el('div', { className: 'group-header' }, [
|
||||||
|
groupCheckbox,
|
||||||
|
toggle,
|
||||||
runButton,
|
runButton,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -342,25 +361,24 @@ function renderGroupSummary(
|
|||||||
function renderList(): HTMLElement {
|
function renderList(): HTMLElement {
|
||||||
const container = el('div', { className: 'groups' });
|
const container = el('div', { className: 'groups' });
|
||||||
|
|
||||||
for (const group of groupTests()) {
|
groupTests().forEach((group, index) => {
|
||||||
const details = el('details', { className: 'group' });
|
|
||||||
// Groups are collapsed by default; remember the ones the user expands.
|
// Groups are collapsed by default; remember the ones the user expands.
|
||||||
details.open = expandedGroups.has(group.name);
|
const expanded = expandedGroups.has(group.name);
|
||||||
details.addEventListener('toggle', () => {
|
const panelId = `group-panel-${index}`;
|
||||||
if (details.open) expandedGroups.add(group.name);
|
|
||||||
else expandedGroups.delete(group.name);
|
|
||||||
});
|
|
||||||
|
|
||||||
details.append(renderGroupSummary(group.name, group.tests));
|
const list = el('ul', { className: 'test-list', id: panelId });
|
||||||
|
list.hidden = !expanded;
|
||||||
const list = el('ul', { className: 'test-list' });
|
|
||||||
for (const test of group.tests) {
|
for (const test of group.tests) {
|
||||||
list.append(renderRow(test));
|
list.append(renderRow(test));
|
||||||
}
|
}
|
||||||
details.append(list);
|
|
||||||
|
|
||||||
container.append(details);
|
container.append(
|
||||||
}
|
el('div', { className: 'group' }, [
|
||||||
|
renderGroupHeader(group.name, group.tests, panelId, expanded),
|
||||||
|
list,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|||||||
16
plugins/libs/plugin-types/index.d.ts
vendored
16
plugins/libs/plugin-types/index.d.ts
vendored
@ -1353,12 +1353,13 @@ export interface Context {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* This method returns a promise that will be resolved when all the
|
* This method returns a promise that will be resolved when all the
|
||||||
* pending layout updates have finished. If no layout work is pending
|
* pending layout updates have finished and the components have synchronized.
|
||||||
* the promise resolves immediately.
|
* If no layout work is pending the promise resolves immediately.
|
||||||
* @param timeout Maximum time to wait, in milliseconds. If the timeout
|
* @param timeout Maximum time to wait, in milliseconds. If the timeout
|
||||||
* elapses before the layout settles, the promise is rejected. Defaults to
|
* elapses before the layout settles, the promise is rejected. Defaults to
|
||||||
* 30000; the promise never waits indefinitely.
|
* 30000; the promise never waits indefinitely.
|
||||||
* @return The promise to be resolved when the layout is updated
|
* @return The promise to be resolved when the layout is updated. It is
|
||||||
|
* rejected with an Error, both on timeout and on an invalid timeout value.
|
||||||
*/
|
*/
|
||||||
waitForLayoutUpdate(timeout?: number): Promise<void>;
|
waitForLayoutUpdate(timeout?: number): Promise<void>;
|
||||||
}
|
}
|
||||||
@ -4109,13 +4110,14 @@ export interface ShapeBase extends PluginData {
|
|||||||
remove(): void;
|
remove(): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This method returns a promise that will be resolved when the pending
|
* This method returns a promise that will be resolved when all the
|
||||||
* layout updates for this shape and its children have finished. If no layout
|
* pending layout updates have finished and the components have synchronized.
|
||||||
* work is pending for them the promise resolves immediately.
|
* If no layout work is pending the promise resolves immediately.
|
||||||
* @param timeout Maximum time to wait, in milliseconds. If the timeout
|
* @param timeout Maximum time to wait, in milliseconds. If the timeout
|
||||||
* elapses before the shape's layout settles, the promise is rejected.
|
* elapses before the shape's layout settles, the promise is rejected.
|
||||||
* Defaults to 30000; the promise never waits indefinitely.
|
* Defaults to 30000; the promise never waits indefinitely.
|
||||||
* @return The promise to be resolved when the shape's layout is updated
|
* @return The promise to be resolved when the shape's layout is updated. It
|
||||||
|
* is rejected with an Error, both on timeout and on an invalid timeout value.
|
||||||
*/
|
*/
|
||||||
waitForLayoutUpdate(timeout?: number): Promise<void>;
|
waitForLayoutUpdate(timeout?: number): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user