🐛 Fix WASM viewport interaction lifecycle for pan/zoom

This commit is contained in:
Alejandro Alonso 2026-05-08 09:41:39 +02:00
parent 97c8fcd4ad
commit 9ebf7875ea
7 changed files with 158 additions and 41 deletions

View File

@ -1578,6 +1578,7 @@
(dm/export dwv/initialize-viewport) (dm/export dwv/initialize-viewport)
(dm/export dwv/update-viewport-position) (dm/export dwv/update-viewport-position)
(dm/export dwv/update-viewport-size) (dm/export dwv/update-viewport-size)
(dm/export dwv/sync-wasm-workspace-viewport)
(dm/export dwv/start-panning) (dm/export dwv/start-panning)
(dm/export dwv/finish-panning) (dm/export dwv/finish-panning)

View File

@ -16,13 +16,19 @@
[app.common.math :as mth] [app.common.math :as mth]
[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.viewport-wasm :as dwvw]
[app.util.mouse :as mse] [app.util.mouse :as mse]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
[potok.v2.core :as ptk])) [potok.v2.core :as ptk]))
(defn- render-context-lost? (defn sync-wasm-workspace-viewport
[state] "Effect-only: pushes the current workspace zoom/view box to WASM after other
(true? (get-in state [:render-state :lost]))) events (e.g. `update-viewport-size`) have updated the store."
[]
(ptk/reify ::sync-wasm-workspace-viewport
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-sync-workspace-local-viewport! state))))
(defn initialize-viewport (defn initialize-viewport
[{:keys [width height] :as size}] [{:keys [width height] :as size}]
@ -86,7 +92,11 @@
(update [_ state] (update [_ state]
(update state :workspace-local (update state :workspace-local
(fn [local] (fn [local]
(setup state local))))))) (setup state local))))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-sync-workspace-local-viewport! state)))))
(defn calculate-centered-viewbox (defn calculate-centered-viewbox
"Updates the viewbox coordinates for a given center position" "Updates the viewbox coordinates for a given center position"
@ -105,9 +115,13 @@
(ptk/reify ::update-viewport-position-center (ptk/reify ::update-viewport-position-center
ptk/UpdateEvent ptk/UpdateEvent
(update [_ state] (update [_ state]
(if (render-context-lost? state) (if (dwvw/render-context-lost? state)
state state
(update state :workspace-local calculate-centered-viewbox position))))) (update state :workspace-local calculate-centered-viewbox position)))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-sync-workspace-local-viewport! state))))
(defn update-viewport-position (defn update-viewport-position
[{:keys [x y] :or {x identity y identity}}] [{:keys [x y] :or {x identity y identity}}]
@ -124,13 +138,17 @@
ptk/UpdateEvent ptk/UpdateEvent
(update [_ state] (update [_ state]
(if (render-context-lost? state) (if (dwvw/render-context-lost? state)
state state
(update-in state [:workspace-local :vbox] (update-in state [:workspace-local :vbox]
(fn [vbox] (fn [vbox]
(-> vbox (-> vbox
(update :x x) (update :x x)
(update :y y)))))))) (update :y y))))))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-sync-workspace-local-viewport! state))))
(defn update-viewport-size (defn update-viewport-size
[resize-type {:keys [width height] :as size}] [resize-type {:keys [width height] :as size}]
@ -174,16 +192,27 @@
(assoc-in [:vbox :width] vbox-width') (assoc-in [:vbox :width] vbox-width')
(assoc-in [:vbox :height] vbox-height'))))))))) (assoc-in [:vbox :height] vbox-height')))))))))
(defn- activate-panning []
(ptk/reify ::activate-panning
ptk/UpdateEvent
(update [_ state]
(-> state
(assoc-in [:workspace-local :panning] true)))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-view-interaction-start! state))))
(defn start-panning [] (defn start-panning []
(ptk/reify ::start-panning (ptk/reify ::start-panning
ptk/WatchEvent ptk/WatchEvent
(watch [_ state stream] (watch [_ state stream]
(let [stopper (->> stream (rx/filter (ptk/type? ::finish-panning))) (let [stopper (->> stream (rx/filter (ptk/type? ::finish-panning)))
zoom (get-in state [:workspace-local :zoom])] zoom (get-in state [:workspace-local :zoom])]
(when (and (not (render-context-lost? state)) (when (and (not (dwvw/render-context-lost? state))
(not (get-in state [:workspace-local :panning]))) (not (get-in state [:workspace-local :panning])))
(rx/concat (rx/concat
(rx/of #(-> % (assoc-in [:workspace-local :panning] true))) (rx/of (activate-panning))
(->> stream (->> stream
(rx/filter mse/pointer-event?) (rx/filter mse/pointer-event?)
(rx/filter #(some? (mse/get-pointer-movement %))) (rx/filter #(some? (mse/get-pointer-movement %)))
@ -200,4 +229,8 @@
ptk/UpdateEvent ptk/UpdateEvent
(update [_ state] (update [_ state]
(-> state (-> state
(update :workspace-local dissoc :panning))))) (update :workspace-local dissoc :panning)))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-view-interaction-end! state))))

View File

@ -0,0 +1,30 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.data.workspace.viewport-wasm
(:require
[app.main.features :as features]
[app.render-wasm.api :as wasm.api]))
(defn render-context-lost?
[state]
(true? (get-in state [:render-state :lost])))
(defn maybe-sync-workspace-local-viewport!
"When `render-wasm/v1` is active, pushes workspace zoom and vbox into WASM."
[state]
(when (and (features/active-feature? state "render-wasm/v1") (not (render-context-lost? state)))
(wasm.api/sync-workspace-local-viewport! state)))
(defn maybe-view-interaction-start!
[state]
(when (and (features/active-feature? state "render-wasm/v1") (not (render-context-lost? state)))
(wasm.api/view-interaction-start!)))
(defn maybe-view-interaction-end!
[state]
(when (and (features/active-feature? state "render-wasm/v1") (not (render-context-lost? state)))
(wasm.api/view-interaction-end!)))

View File

@ -16,15 +16,12 @@
[app.common.geom.shapes :as gsh] [app.common.geom.shapes :as gsh]
[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.viewport-wasm :as dwvw]
[app.main.streams :as ms] [app.main.streams :as ms]
[app.util.mouse :as mse] [app.util.mouse :as mse]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
[potok.v2.core :as ptk])) [potok.v2.core :as ptk]))
(defn- render-context-lost?
[state]
(true? (get-in state [:render-state :lost])))
(defn impl-update-zoom (defn impl-update-zoom
[{:keys [vbox] :as local} center zoom] [{:keys [vbox] :as local} center zoom]
(let [new-zoom (if (fn? zoom) (zoom (:zoom local)) zoom) (let [new-zoom (if (fn? zoom) (zoom (:zoom local)) zoom)
@ -47,11 +44,15 @@
ptk/UpdateEvent ptk/UpdateEvent
(update [_ state] (update [_ state]
(if (render-context-lost? state) (if (dwvw/render-context-lost? state)
state state
(let [center (if (= center ::auto) @ms/mouse-position center)] (let [center (if (= center ::auto) @ms/mouse-position center)]
(update state :workspace-local (update state :workspace-local
#(impl-update-zoom % center (fn [z] (min (* z 1.3) 200)))))))))) #(impl-update-zoom % center (fn [z] (min (* z 1.3) 200)))))))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-sync-workspace-local-viewport! state)))))
(defn decrease-zoom (defn decrease-zoom
([] ([]
@ -62,11 +63,15 @@
ptk/UpdateEvent ptk/UpdateEvent
(update [_ state] (update [_ state]
(if (render-context-lost? state) (if (dwvw/render-context-lost? state)
state state
(let [center (if (= center ::auto) @ms/mouse-position center)] (let [center (if (= center ::auto) @ms/mouse-position center)]
(update state :workspace-local (update state :workspace-local
#(impl-update-zoom % center (fn [z] (max (/ z 1.3) 0.01)))))))))) #(impl-update-zoom % center (fn [z] (max (/ z 1.3) 0.01)))))))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-sync-workspace-local-viewport! state)))))
(defn set-zoom (defn set-zoom
([scale] ([scale]
@ -77,7 +82,7 @@
ptk/UpdateEvent ptk/UpdateEvent
(update [_ state] (update [_ state]
(if (render-context-lost? state) (if (dwvw/render-context-lost? state)
state state
(let [vp (dm/get-in state [:workspace-local :vbox]) (let [vp (dm/get-in state [:workspace-local :vbox])
x (+ (:x vp) (/ (:width vp) 2)) x (+ (:x vp) (/ (:width vp) 2))
@ -86,22 +91,30 @@
(update state :workspace-local (update state :workspace-local
#(impl-update-zoom % center (fn [z] (-> (* z scale) #(impl-update-zoom % center (fn [z] (-> (* z scale)
(max 0.01) (max 0.01)
(min 200))))))))))) (min 200))))))))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-sync-workspace-local-viewport! state)))))
(def reset-zoom (def reset-zoom
(ptk/reify ::reset-zoom (ptk/reify ::reset-zoom
ptk/UpdateEvent ptk/UpdateEvent
(update [_ state] (update [_ state]
(if (render-context-lost? state) (if (dwvw/render-context-lost? state)
state state
(update state :workspace-local (update state :workspace-local
#(impl-update-zoom % nil 1)))))) #(impl-update-zoom % nil 1))))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-sync-workspace-local-viewport! state))))
(def zoom-to-fit-all (def zoom-to-fit-all
(ptk/reify ::zoom-to-fit-all (ptk/reify ::zoom-to-fit-all
ptk/UpdateEvent ptk/UpdateEvent
(update [_ state] (update [_ state]
(if (render-context-lost? state) (if (dwvw/render-context-lost? state)
state state
(let [page-id (:current-page-id state) (let [page-id (:current-page-id state)
objects (dsh/lookup-page-objects state page-id) objects (dsh/lookup-page-objects state page-id)
@ -116,13 +129,17 @@
(-> local (-> local
(assoc :zoom zoom) (assoc :zoom zoom)
(assoc :zoom-inverse (/ 1 zoom)) (assoc :zoom-inverse (/ 1 zoom))
(update :vbox merge srect))))))))))) (update :vbox merge srect)))))))))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-sync-workspace-local-viewport! state))))
(def zoom-to-selected-shape (def zoom-to-selected-shape
(ptk/reify ::zoom-to-selected-shape (ptk/reify ::zoom-to-selected-shape
ptk/UpdateEvent ptk/UpdateEvent
(update [_ state] (update [_ state]
(if (render-context-lost? state) (if (dwvw/render-context-lost? state)
state state
(let [selected (dsh/lookup-selected state)] (let [selected (dsh/lookup-selected state)]
(if (empty? selected) (if (empty? selected)
@ -139,14 +156,18 @@
(-> local (-> local
(assoc :zoom zoom) (assoc :zoom zoom)
(assoc :zoom-inverse (/ 1 zoom)) (assoc :zoom-inverse (/ 1 zoom))
(update :vbox merge srect)))))))))))) (update :vbox merge srect))))))))))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-sync-workspace-local-viewport! state))))
(defn fit-to-shapes (defn fit-to-shapes
[ids] [ids]
(ptk/reify ::fit-to-shapes (ptk/reify ::fit-to-shapes
ptk/UpdateEvent ptk/UpdateEvent
(update [_ state] (update [_ state]
(if (or (render-context-lost? state) (empty? ids)) (if (or (dwvw/render-context-lost? state) (empty? ids))
state state
(let [page-id (:current-page-id state) (let [page-id (:current-page-id state)
objects (dsh/lookup-page-objects state page-id) objects (dsh/lookup-page-objects state page-id)
@ -164,16 +185,21 @@
(-> local (-> local
(assoc :zoom zoom) (assoc :zoom zoom)
(assoc :zoom-inverse (/ 1 zoom)) (assoc :zoom-inverse (/ 1 zoom))
(update :vbox merge srect)))))))))) (update :vbox merge srect))))))))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-sync-workspace-local-viewport! state))))
(defn start-zooming [pt] (defn start-zooming [pt]
(ptk/reify ::start-zooming (ptk/reify ::start-zooming
ptk/WatchEvent ptk/WatchEvent
(watch [_ state stream] (watch [_ state stream]
(let [stopper (->> stream (rx/filter (ptk/type? ::finish-zooming)))] (let [stopper (->> stream (rx/filter (ptk/type? ::finish-zooming)))]
(when (and (not (render-context-lost? state)) (when (and (not (dwvw/render-context-lost? state))
(not (get-in state [:workspace-local :zooming]))) (not (get-in state [:workspace-local :zooming])))
(rx/concat (rx/concat
(rx/of (fn [s] (dwvw/maybe-view-interaction-start! s) s))
(rx/of #(-> % (assoc-in [:workspace-local :zooming] true))) (rx/of #(-> % (assoc-in [:workspace-local :zooming] true)))
(->> stream (->> stream
(rx/filter mse/pointer-event?) (rx/filter mse/pointer-event?)
@ -189,4 +215,7 @@
ptk/UpdateEvent ptk/UpdateEvent
(update [_ state] (update [_ state]
(-> state (-> state
(update :workspace-local dissoc :zooming))))) (update :workspace-local dissoc :zooming)))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-view-interaction-end! state))))

View File

@ -77,7 +77,8 @@
(mf/deps vport) (mf/deps vport)
(fn [resize-type size] (fn [resize-type size]
(when (and vport (not= size vport)) (when (and vport (not= size vport))
(st/emit! (dw/update-viewport-size resize-type size))))) (st/emit! (dw/update-viewport-size resize-type size)
(dw/sync-wasm-workspace-viewport)))))
on-resize-palette on-resize-palette
(mf/use-fn (mf/use-fn

View File

@ -436,7 +436,8 @@
(mf/with-effect [vport] (mf/with-effect [vport]
(when (and @canvas-init? @initialized?) (when (and @canvas-init? @initialized?)
(wasm.api/resize-viewbox (:width vport) (:height vport)))) (wasm.api/resize-viewbox (:width vport) (:height vport))
(wasm.api/set-view-box zoom vbox)))
(mf/with-effect [@canvas-init? preview-blend] (mf/with-effect [@canvas-init? preview-blend]
(when (and @canvas-init? preview-blend) (when (and @canvas-init? preview-blend)
@ -467,10 +468,6 @@
(wasm.api/clear-focus-mode) (wasm.api/clear-focus-mode)
(wasm.api/set-focus-mode focus))))) (wasm.api/set-focus-mode focus)))))
(mf/with-effect [vbox zoom]
(when (and @canvas-init? @initialized?)
(wasm.api/set-view-box zoom vbox)))
(mf/with-effect [background] (mf/with-effect [background]
(when (and @canvas-init? @initialized?) (when (and @canvas-init? @initialized?)
(wasm.api/set-canvas-background background))) (wasm.api/set-canvas-background background)))

View File

@ -208,6 +208,8 @@
(def ^:const DEBOUNCE_DELAY_MS 100) (def ^:const DEBOUNCE_DELAY_MS 100)
(defonce ^:private view-interaction-active? (atom false))
;; Time budget (ms) per chunk of shape processing before yielding to browser ;; Time budget (ms) per chunk of shape processing before yielding to browser
(def ^:private ^:const CHUNK_TIME_BUDGET_MS 8) (def ^:private ^:const CHUNK_TIME_BUDGET_MS 8)
;; Threshold below which we use synchronous processing (no chunking overhead) ;; Threshold below which we use synchronous processing (no chunking overhead)
@ -1146,14 +1148,26 @@
(= result 1)) (= result 1))
false)) false))
(defn view-interaction-start!
[]
(when-not @view-interaction-active?
(h/call wasm/internal-module "_set_view_start")
(reset! view-interaction-active? true)))
(defn view-interaction-end!
[]
(when @view-interaction-active?
(perf/begin-measure "render-finish")
(h/call wasm/internal-module "_set_view_end")
(perf/end-measure "render-finish")
(reset! view-interaction-active? false)))
(def render-finish (def render-finish
(letfn [(do-render [] (letfn [(do-render []
;; Check if context is still initialized before executing ;; Check if context is still initialized before executing
;; to prevent errors when navigating quickly ;; to prevent errors when navigating quickly
(when (and wasm/context-initialized? (not @wasm/context-lost?)) (when (and wasm/context-initialized? (not @wasm/context-lost?))
(perf/begin-measure "render-finish") (view-interaction-end!)
(h/call wasm/internal-module "_set_view_end")
(perf/end-measure "render-finish")
;; Use async _render: visible tiles render synchronously ;; Use async _render: visible tiles render synchronously
;; (no yield), interest-area tiles render progressively ;; (no yield), interest-area tiles render progressively
;; via rAF. _set_view_end already rebuilt the tile ;; via rAF. _set_view_end already rebuilt the tile
@ -1167,7 +1181,7 @@
(defn set-view-box (defn set-view-box
[zoom vbox] [zoom vbox]
(perf/begin-measure "set-view-box") (perf/begin-measure "set-view-box")
(h/call wasm/internal-module "_set_view_start") (view-interaction-start!)
(h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox)))
(perf/end-measure "set-view-box") (perf/end-measure "set-view-box")
@ -1176,6 +1190,16 @@
(render-finish) (render-finish)
(perf/end-measure "render-from-cache")) (perf/end-measure "render-from-cache"))
(defn sync-workspace-local-viewport!
"Pushes `[:workspace-local :zoom]` and `:vbox` into WASM."
[state]
(when (and wasm/context-initialized?
(not @wasm/context-lost?))
(let [zoom (get-in state [:workspace-local :zoom])
vbox (get-in state [:workspace-local :vbox])]
(when (and zoom vbox)
(set-view-box zoom vbox)))))
(defn- ensure-text-content (defn- ensure-text-content
"Guarantee that the shape always sends a valid text tree to WASM. When the "Guarantee that the shape always sends a valid text tree to WASM. When the
content is nil (freshly created text) we fall back to content is nil (freshly created text) we fall back to
@ -1344,6 +1368,7 @@
;; Rebuild the tile index so _render knows which shapes ;; Rebuild the tile index so _render knows which shapes
;; map to which tiles after a page switch. ;; map to which tiles after a page switch.
(h/call wasm/internal-module "_set_view_end") (h/call wasm/internal-module "_set_view_end")
(reset! view-interaction-active? false)
;; Text layouts must run after _end_loading (they ;; Text layouts must run after _end_loading (they
;; depend on state that is only correct when loading ;; depend on state that is only correct when loading
@ -1402,6 +1427,7 @@
;; Rebuild the tile index so _render knows which shapes ;; Rebuild the tile index so _render knows which shapes
;; map to which tiles after a page switch. ;; map to which tiles after a page switch.
(h/call wasm/internal-module "_set_view_end") (h/call wasm/internal-module "_set_view_end")
(reset! view-interaction-active? false)
(process-pending shapes thumbnails full (process-pending shapes thumbnails full
(fn [] (fn []
(if render-callback (if render-callback