diff --git a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs index a58e1512ba..71bdf0b7d4 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs @@ -30,6 +30,7 @@ [app.util.timers :as timers] [cuerdas.core :as str] [okulary.core :as l] + [promesa.core :as p] [rumext.v2 :as mf])) ;; FIXME: can we unify this two refs in one? @@ -72,18 +73,21 @@ (mf/use-fn (mf/deps id current-page-id) (fn [] - ;; For the wasm renderer, apply a blur effect to the viewport canvas - ;; when we navigate to a different page. + ;; WASM page transitions: + ;; - Capture the current page (A) once + ;; - Show a blurred snapshot while the target page (B/C/...) renders + ;; - If the user clicks again during the transition, keep showing the original (A) snapshot (if (and (features/active-feature? @st/state "render-wasm/v1") (not= id current-page-id)) (do - (wasm.api/capture-canvas-pixels) - (wasm.api/apply-canvas-blur) - ;; NOTE: it seems we need two RAF so the blur is actually applied and visible - ;; in the canvas :( - (timers/raf - (fn [] - (timers/raf navigate-fn)))) + (-> (wasm.api/apply-canvas-blur) + (p/finally + (fn [] + ;; NOTE: it seems we need two RAF so the blur is actually applied and visible + ;; in the canvas :( + (timers/raf + (fn [] + (timers/raf navigate-fn))))))) (navigate-fn)))) on-delete diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 724c165c21..e90a584baa 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -132,21 +132,21 @@ (apply-modifiers-to-objects base-objects wasm-modifiers)) ;; STATE - alt? (mf/use-state false) - shift? (mf/use-state false) - mod? (mf/use-state false) - space? (mf/use-state false) - z? (mf/use-state false) - cursor (mf/use-state (utils/get-cursor :pointer-inner)) - hover-ids (mf/use-state nil) - hover (mf/use-state nil) - measure-hover (mf/use-state nil) - hover-disabled? (mf/use-state false) - hover-top-frame-id (mf/use-state nil) - frame-hover (mf/use-state nil) - active-frames (mf/use-state #{}) - canvas-init? (mf/use-state false) - initialized? (mf/use-state false) + alt? (mf/use-state false) + shift? (mf/use-state false) + mod? (mf/use-state false) + space? (mf/use-state false) + z? (mf/use-state false) + cursor (mf/use-state (utils/get-cursor :pointer-inner)) + hover-ids (mf/use-state nil) + hover (mf/use-state nil) + measure-hover (mf/use-state nil) + hover-disabled? (mf/use-state false) + hover-top-frame-id (mf/use-state nil) + frame-hover (mf/use-state nil) + active-frames (mf/use-state #{}) + canvas-init? (mf/use-state false) + initialized? (mf/use-state false) ;; REFS [viewport-ref @@ -205,6 +205,9 @@ mode-inspect? (= options-mode :inspect) + ;; True when we are opening a new file or switching to a new page + page-transition? (mf/deref wasm.api/page-transition?) + on-click (actions/on-click hover selected edition path-drawing? drawing-tool space? selrect z?) on-context-menu (actions/on-context-menu hover hover-ids read-only?) on-double-click (actions/on-double-click hover hover-ids hover-top-frame-id path-drawing? base-objects edition drawing-tool z? read-only?) @@ -234,41 +237,46 @@ show-cursor-tooltip? tooltip show-draw-area? drawing-obj show-gradient-handlers? (= (count selected) 1) - show-grids? (contains? layout :display-guides) + show-grids? (and (contains? layout :display-guides) (not page-transition?)) - show-frame-outline? (and (= transform :move) (not panning)) + show-frame-outline? (and (= transform :move) (not panning) (not page-transition?)) show-outlines? (and (nil? transform) (not panning) (not edition) (not drawing-obj) - (not (#{:comments :path :curve} drawing-tool))) + (not (#{:comments :path :curve} drawing-tool)) + (not page-transition?)) show-pixel-grid? (and (contains? layout :show-pixel-grid) - (>= zoom 8)) - show-text-editor? (and editing-shape (= :text (:type editing-shape))) + (>= zoom 8) + (not page-transition?)) + show-text-editor? (and editing-shape (= :text (:type editing-shape)) (not page-transition?)) hover-grid? (and (some? @hover-top-frame-id) - (ctl/grid-layout? objects @hover-top-frame-id)) + (ctl/grid-layout? objects @hover-top-frame-id) + (not page-transition?)) - show-grid-editor? (and editing-shape (ctl/grid-layout? editing-shape)) - show-presence? page-id - show-prototypes? (= options-mode :prototype) - show-selection-handlers? (and (seq selected) (not show-text-editor?)) + show-grid-editor? (and editing-shape (ctl/grid-layout? editing-shape) (not page-transition?)) + show-presence? (and page-id (not page-transition?)) + show-prototypes? (and (= options-mode :prototype) (not page-transition?)) + show-selection-handlers? (and (seq selected) (not show-text-editor?) (not page-transition?)) show-snap-distance? (and (contains? layout :dynamic-alignment) (= transform :move) - (seq selected)) + (seq selected) + (not page-transition?)) show-snap-points? (and (or (contains? layout :dynamic-alignment) (contains? layout :snap-guides)) - (or drawing-obj transform)) - show-selrect? (and selrect (empty? drawing) (not text-editing?)) + (or drawing-obj transform) + (not page-transition?)) + show-selrect? (and selrect (empty? drawing) (not text-editing?) (not page-transition?)) show-measures? (and (not transform) (not path-editing?) - (or show-distances? mode-inspect?)) - show-artboard-names? (contains? layout :display-artboard-names) + (or show-distances? mode-inspect?) + (not page-transition?)) + show-artboard-names? (and (contains? layout :display-artboard-names) (not page-transition?)) hide-ui? (contains? layout :hide-ui) show-rulers? (and (contains? layout :rulers) (not hide-ui?)) - disabled-guides? (or drawing-tool transform path-drawing? path-editing?) single-select? (= (count selected-shapes) 1) @@ -279,6 +287,8 @@ (or (ctk/is-variant-container? first-shape) (ctk/is-variant? first-shape))) + show-scrollbar? (not page-transition?) + add-variant (mf/use-fn (mf/deps first-shape) @@ -311,7 +321,8 @@ rule-area-size (/ rulers/ruler-area-size zoom) preview-blend (-> refs/workspace-preview-blend (mf/deref)) - shapes-loading? (mf/deref wasm.api/shapes-loading?)] + shapes-loading? (mf/deref wasm.api/shapes-loading?) + transition-image-url (mf/deref wasm.api/transition-image-url*)] ;; NOTE: We need this page-id dependency to react to it and reset the ;; canvas, even though we are not using `page-id` inside the hook. @@ -341,15 +352,7 @@ (cond init? (do - (reset! canvas-init? true) - (wasm.api/apply-canvas-blur) - (if (wasm.api/has-captured-pixels?) - ;; Page switch: restore previously captured pixels (blurred) - (wasm.api/restore-previous-canvas-pixels) - ;; First load: try to draw a blurred page thumbnail - (when-let [frame-id (get page :thumbnail-frame-id)] - (when-let [uri (dm/get-in @st/state [:thumbnails frame-id])] - (wasm.api/draw-thumbnail-to-canvas uri))))) + (reset! canvas-init? true)) (pos? retries) (vreset! timeout-id-ref @@ -392,14 +395,15 @@ (when @canvas-init? (if (not @initialized?) (do + ;; Initial file open uses the same transition workflow as page switches, + ;; but with a solid background-color blurred placeholder. + (wasm.api/start-initial-load-transition! background) ;; Keep the blurred previous-page preview (page switch) or ;; blank canvas (first load) visible while shapes load. ;; The loading overlay is suppressed because on-shapes-ready ;; is set. (wasm.api/initialize-viewport - base-objects zoom vbox background 1 nil - (fn [] - (wasm.api/clear-canvas-pixels))) + base-objects zoom vbox background) (reset! initialized? true) (mf/set-ref-val! last-file-version-id-ref file-version-id)) (when (and (some? file-version-id) @@ -476,6 +480,21 @@ :style {:background-color background :pointer-events "none"}}] + ;; Show the transition image when we are opening a new file or switching to a new page + (when (and page-transition? (some? transition-image-url)) + (let [src transition-image-url] + [:img {:data-testid "canvas-wasm-transition" + :src src + :draggable false + :style {:position "absolute" + :inset 0 + :width "100%" + :height "100%" + :object-fit "cover" + :pointer-events "none" + :filter "blur(4px)"}}])) + + [:svg.viewport-controls {:xmlns "http://www.w3.org/2000/svg" :xmlnsXlink "http://www.w3.org/1999/xlink" @@ -776,9 +795,10 @@ (get objects-modified @hover-top-frame-id)) :view-only (not show-grid-editor?)}])] - [:g.scrollbar-wrapper {:clipPath "url(#clip-handlers)"} - [:> scroll-bars/viewport-scrollbars* - {:objects base-objects - :zoom zoom - :vbox vbox - :bottom-padding (when palete-size (+ palete-size 8))}]]]]])) + (when show-scrollbar? + [:g.scrollbar-wrapper {:clipPath "url(#clip-handlers)"} + [:> scroll-bars/viewport-scrollbars* + {:objects base-objects + :zoom zoom + :vbox vbox + :bottom-padding (when palete-size (+ palete-size 8))}]])]]])) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 0d69dd5bc2..1ebda86633 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -55,6 +55,109 @@ (def use-dpr? (contains? cf/flags :render-wasm-dpr)) +;; --- Page transition state (WASM viewport) +;; +;; Goal: avoid showing tile-by-tile rendering during page switches (and initial load), +;; by keeping a blurred snapshot overlay visible until WASM dispatches +;; `penpot:wasm:tiles-complete`. +;; +;; - `page-transition?`: true while the overlay should be considered active. +;; - `transition-image-url*`: URL used by the UI overlay (usually `blob:` from the +;; current WebGL canvas snapshot; on initial load it may be a tiny SVG data-url +;; derived from the page background color). +;; - `transition-epoch*`: monotonic counter used to ignore stale async work/events +;; when the user clicks pages rapidly (A -> B -> C). +;; - `transition-tiles-handler*`: the currently installed DOM event handler for +;; `penpot:wasm:tiles-complete`, so we can remove/replace it safely. +(defonce page-transition? (atom false)) +(defonce transition-image-url* (atom nil)) +(defonce transition-epoch* (atom 0)) +(defonce transition-tiles-handler* (atom nil)) + +(def ^:private transition-blur-css "blur(4px)") + +(defn- set-transition-blur! + [] + (when-let [canvas ^js wasm/canvas] + (dom/set-style! canvas "filter" transition-blur-css)) + (when-let [nodes (.querySelectorAll ^js ug/document ".blurrable")] + (doseq [^js node (array-seq nodes)] + (dom/set-style! node "filter" transition-blur-css)))) + +(defn- clear-transition-blur! + [] + (when-let [canvas ^js wasm/canvas] + (dom/set-style! canvas "filter" "")) + (when-let [nodes (.querySelectorAll ^js ug/document ".blurrable")] + (doseq [^js node (array-seq nodes)] + (dom/set-style! node "filter" "")))) + +(defn set-transition-image-from-background! + "Sets `transition-image-url*` to a data URL representing a solid background color." + [background] + (when (string? background) + (let [svg (str "" + "" + "")] + (reset! transition-image-url* + (str "data:image/svg+xml;charset=utf-8," (js/encodeURIComponent svg)))))) + +(defn begin-page-transition! + [] + (reset! page-transition? true) + (swap! transition-epoch* inc)) + +(defn end-page-transition! + [] + (reset! page-transition? false) + (when-let [prev @transition-tiles-handler*] + (.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" prev)) + (reset! transition-tiles-handler* nil) + (reset! transition-image-url* nil) + (clear-transition-blur!) + ;; Clear captured pixels so future transitions must explicitly capture again. + (set! wasm/canvas-snapshot-url nil)) + +(defn- set-transition-tiles-complete-handler! + "Installs a tiles-complete handler bound to the current transition epoch. + Replaces any previous handler so rapid page switching doesn't end the wrong transition." + [epoch f] + (when-let [prev @transition-tiles-handler*] + (.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" prev)) + (letfn [(handler [_] + (when (= epoch @transition-epoch*) + (.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" handler) + (reset! transition-tiles-handler* nil) + (f)))] + (reset! transition-tiles-handler* handler) + (.addEventListener ^js ug/document "penpot:wasm:tiles-complete" handler))) + +(defn start-initial-load-transition! + "Starts a page-transition workflow for initial file open. + + - Sets `page-transition?` to true + - Installs a tiles-complete handler to end the transition + - Uses a solid background-color placeholder as the transition image" + [background] + ;; If something already toggled `page-transition?` (e.g. legacy init code paths), + ;; ensure we still have a deterministic placeholder on initial load. + (when (or (not @page-transition?) (nil? @transition-image-url*)) + (set-transition-image-from-background! background)) + (when-not @page-transition? + ;; Start transition + bind the tiles-complete handler to this epoch. + (let [epoch (begin-page-transition!)] + (set-transition-tiles-complete-handler! epoch end-page-transition!)))) + +(defn listen-tiles-render-complete-once! + "Registers a one-shot listener for `penpot:wasm:tiles-complete`, dispatched from WASM + when a full tile pass finishes." + [f] + (.addEventListener ^js ug/document + "penpot:wasm:tiles-complete" + (fn [_] + (f)) + #js {:once true})) + (defn text-editor-wasm? [] (or (contains? cf/flags :feature-text-editor-wasm) @@ -94,16 +197,9 @@ (def ^:const TEXT_EDITOR_EVENT_NEEDS_LAYOUT 4) ;; Re-export public WebGL functions -(def capture-canvas-pixels webgl/capture-canvas-pixels) -(def restore-previous-canvas-pixels webgl/restore-previous-canvas-pixels) -(def clear-canvas-pixels webgl/clear-canvas-pixels) +(def capture-canvas-snapshot-url webgl/capture-canvas-snapshot-url) (def draw-thumbnail-to-canvas webgl/draw-thumbnail-to-canvas) -(defn has-captured-pixels? - "Returns true if there are saved canvas pixels from a previous page." - [] - (some? wasm/canvas-pixels)) - ;; Re-export public text editor functions (def text-editor-focus text-editor/text-editor-focus) (def text-editor-blur text-editor/text-editor-blur) @@ -1778,9 +1874,36 @@ (defn apply-canvas-blur [] - (when wasm/canvas (dom/set-style! wasm/canvas "filter" "blur(4px)")) - (let [controls-to-blur (dom/query-all (dom/get-element "viewport-controls") ".blurrable")] - (run! #(dom/set-style! % "filter" "blur(4px)") controls-to-blur))) + (let [already? @page-transition? + epoch (begin-page-transition!)] + (set-transition-tiles-complete-handler! epoch end-page-transition!) + ;; Two-phase transition: + ;; - Apply CSS blur to the live canvas immediately (no async wait), so the user + ;; sees the transition right away. + ;; - In parallel, capture a `blob:` snapshot URL; once ready, switch the overlay + ;; to that fixed image (and guard with `epoch` to avoid stale async updates). + (set-transition-blur!) + ;; Lock the snapshot for the whole transition: if the user clicks to another page + ;; while the transition is active, keep showing the original page snapshot until + ;; the final target page finishes rendering. + (if already? + (p/resolved nil) + (do + ;; If we already have a snapshot URL, use it immediately. + (when-let [url wasm/canvas-snapshot-url] + (when (string? url) + (reset! transition-image-url* url))) + + ;; Capture a fresh snapshot asynchronously and update the overlay as soon + ;; as it is ready (guarded by `epoch` to avoid stale async updates). + (-> (capture-canvas-snapshot-url) + (p/then (fn [url] + (when (and (string? url) + @page-transition? + (= epoch @transition-epoch*)) + (reset! transition-image-url* url)) + url)) + (p/catch (fn [_] nil))))))) (defn render-shape-pixels [shape-id scale] diff --git a/frontend/src/app/render_wasm/api/webgl.cljs b/frontend/src/app/render_wasm/api/webgl.cljs index eac0c42df7..7442947953 100644 --- a/frontend/src/app/render_wasm/api/webgl.cljs +++ b/frontend/src/app/render_wasm/api/webgl.cljs @@ -9,7 +9,6 @@ (:require [app.common.logging :as log] [app.render-wasm.wasm :as wasm] - [app.util.dom :as dom] [promesa.core :as p])) (defn max-texture-size @@ -144,38 +143,29 @@ void main() { (.bindTexture ^js gl (.-TEXTURE_2D ^js gl) nil) (.deleteTexture ^js gl texture)))) -(defn restore-previous-canvas-pixels - "Restores previous canvas pixels into the new canvas" - [] - (when-let [previous-canvas-pixels wasm/canvas-pixels] - (when-let [gl wasm/gl-context] - (draw-imagedata-to-webgl gl previous-canvas-pixels) - (set! wasm/canvas-pixels nil)))) +(defn capture-canvas-snapshot-url + "Captures the current viewport canvas as a PNG `blob:` URL and stores it in + `wasm/canvas-snapshot-url`. -(defn clear-canvas-pixels + Returns a promise resolving to the URL string (or nil)." [] - (when wasm/canvas - (let [context wasm/gl-context] - (.clearColor ^js context 0 0 0 0.0) - (.clear ^js context (.-COLOR_BUFFER_BIT ^js context)) - (.clear ^js context (.-DEPTH_BUFFER_BIT ^js context)) - (.clear ^js context (.-STENCIL_BUFFER_BIT ^js context))) - (dom/set-style! wasm/canvas "filter" "none") - (let [controls-to-unblur (dom/query-all (dom/get-element "viewport-controls") ".blurrable")] - (run! #(dom/set-style! % "filter" "none") controls-to-unblur)) - (set! wasm/canvas-pixels nil))) - -(defn capture-canvas-pixels - "Captures the pixels of the viewport canvas" - [] - (when wasm/canvas - (let [context wasm/gl-context - width (.-width wasm/canvas) - height (.-height wasm/canvas) - buffer (js/Uint8ClampedArray. (* width height 4)) - _ (.readPixels ^js context 0 0 width height (.-RGBA ^js context) (.-UNSIGNED_BYTE ^js context) buffer) - image-data (js/ImageData. buffer width height)] - (set! wasm/canvas-pixels image-data)))) + (if-let [^js canvas wasm/canvas] + (p/create + (fn [resolve _reject] + ;; Revoke previous snapshot to avoid leaking blob URLs. + (when-let [prev wasm/canvas-snapshot-url] + (when (and (string? prev) (.startsWith ^js prev "blob:")) + (js/URL.revokeObjectURL prev))) + (set! wasm/canvas-snapshot-url nil) + (.toBlob canvas + (fn [^js blob] + (if blob + (let [url (js/URL.createObjectURL blob)] + (set! wasm/canvas-snapshot-url url) + (resolve url)) + (resolve nil))) + "image/png"))) + (p/resolved nil))) (defn draw-thumbnail-to-canvas "Loads an image from `uri` and draws it stretched to fill the WebGL canvas. diff --git a/frontend/src/app/render_wasm/wasm.cljs b/frontend/src/app/render_wasm/wasm.cljs index c54091d5e2..5c43ba4899 100644 --- a/frontend/src/app/render_wasm/wasm.cljs +++ b/frontend/src/app/render_wasm/wasm.cljs @@ -12,8 +12,9 @@ ;; Reference to the HTML canvas element. (defonce canvas nil) -;; Reference to the captured pixels of the canvas (for page switching effect) -(defonce canvas-pixels nil) +;; Snapshot of the current canvas suitable for `` overlays. +;; This is typically a `blob:` URL created via `canvas.toBlob`. +(defonce canvas-snapshot-url nil) ;; Reference to the Emscripten GL context wrapper. (defonce gl-context-handle nil) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index e2c91a250a..f8624d90aa 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -43,6 +43,13 @@ const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3; const MAX_BLOCKING_TIME_MS: i32 = 32; const NODE_BATCH_THRESHOLD: i32 = 3; + +/// Dispatches `penpot:wasm:tiles-complete` on `document` so the UI can react when a full +/// tile pass has finished (e.g. remove page-transition blur). +fn notify_tiles_render_complete() { + #[cfg(target_arch = "wasm32")] + crate::run_script!("document.dispatchEvent(new CustomEvent('penpot:wasm:tiles-complete'))"); +} const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0; type ClipStack = Vec<(Rect, Option, Matrix)>; @@ -1750,6 +1757,7 @@ impl RenderState { self.cancel_animation_frame(); self.render_request_id = Some(wapi::request_animation_frame!()); } else { + notify_tiles_render_complete(); performance::end_measure!("render"); } } @@ -1767,6 +1775,7 @@ impl RenderState { self.render_shape_tree_partial(base_object, tree, timestamp, false)?; } self.flush_and_submit(); + notify_tiles_render_complete(); Ok(()) }