diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 0584d0f0f7..79f94d2639 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -169,6 +169,7 @@ :mcp :background-blur + :available-viewer-wasm :stroke-path}) (def all-flags diff --git a/frontend/src/app/main/data/viewer.cljs b/frontend/src/app/main/data/viewer.cljs index 3b2ca792b8..d05df4bd13 100644 --- a/frontend/src/app/main/data/viewer.cljs +++ b/frontend/src/app/main/data/viewer.cljs @@ -17,6 +17,7 @@ [app.common.types.shape-tree :as ctt] [app.common.types.shape.interactions :as ctsi] [app.common.uuid :as uuid] + [app.config :as cf] [app.main.data.comments :as dcmt] [app.main.data.common :as dcm] [app.main.data.event :as ev] @@ -219,7 +220,8 @@ (ptk/reify ::update-page-position-data ptk/WatchEvent (watch [_ state _] - (if (features/active-feature? state "render-wasm/v1") + (if (and (features/active-feature? state "render-wasm/v1") + (contains? cf/flags :available-viewer-wasm)) (let [objects (dsh/lookup-page-objects state file-id page-id) shapes diff --git a/frontend/src/app/main/render.cljs b/frontend/src/app/main/render.cljs index 7c6ee76e1e..5bd1a63f6d 100644 --- a/frontend/src/app/main/render.cljs +++ b/frontend/src/app/main/render.cljs @@ -30,6 +30,7 @@ [app.common.types.shape.layout :as ctl] [app.config :as cfg] [app.main.fonts :as fonts] + [app.main.render-viewer-wasm :as rwv] [app.main.ui.context :as muc] [app.main.ui.shapes.bool :as bool] [app.main.ui.shapes.circle :as circle] @@ -524,32 +525,6 @@ (for [shape shapes] [:& shape-wrapper {:key (dm/str (:id shape)) :shape shape}])]]])) -(defn render-to-canvas - [objects canvas bounds scale object-id on-render] - (let [width (.-width canvas) - height (.-height canvas) - os-canvas (js/OffscreenCanvas. width height)] - (try - (when (wasm.api/init-canvas-context os-canvas) - (wasm.api/initialize-viewport - objects scale bounds - :background-opacity 0 - :on-render - (fn [] - (wasm.api/render-sync-shape object-id) - (ts/raf - (fn [] - (let [bitmap (.transferToImageBitmap os-canvas) - ctx2d (.getContext canvas "2d")] - (.clearRect ctx2d 0 0 width height) - (.drawImage ctx2d bitmap 0 0) - (dom/set-attribute! canvas "id" (dm/str "screenshot-" object-id)) - (wasm.api/clear-canvas) - (on-render))))))) - (catch :default e - (js/console.error "Error initializing canvas context:" e) - false)))) - (mf/defc object-wasm {::mf/wrap [mf/memo]} [{:keys [objects object-id skip-children scale on-render] :as props}] @@ -574,7 +549,7 @@ (p/fmap (fn [ready?] (when ready? - (render-to-canvas objects canvas bounds scale object-id on-render)))))))) + (rwv/render-to-canvas objects canvas bounds scale object-id on-render)))))))) [:canvas {:ref canvas-ref :width (* scale width) diff --git a/frontend/src/app/main/render_viewer_wasm.cljs b/frontend/src/app/main/render_viewer_wasm.cljs new file mode 100644 index 0000000000..7de082b675 --- /dev/null +++ b/frontend/src/app/main/render_viewer_wasm.cljs @@ -0,0 +1,241 @@ +;; 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.render-viewer-wasm + "WASM offscreen rendering for the shared viewer (snapshot + fixed-scroll)." + (:require + [app.common.data.macros :as dm] + [app.render-wasm.api :as wasm.api] + [app.render-wasm.wasm :as wasm] + [app.util.dom :as dom] + [app.util.timers :as ts] + [goog.events :as events] + [promesa.core :as p] + [rumext.v2 :as mf])) + +;; The WASM module is a single global instance; serialize offscreen work. +(defonce ^:private wasm-render-queue (atom (p/resolved nil))) + +(defn- enqueue-wasm-render! + [task] + (let [next-p (-> @wasm-render-queue + (p/handle (fn [_ _] (task))))] + (reset! wasm-render-queue (p/handle next-p (fn [_ _] nil))) + next-p)) + +(defonce ^:private viewer-snapshot + (atom {:os-canvas nil + :page-key nil + :canvas-w 0 + :canvas-h 0})) + +(defn- reset-viewer-snapshot! [] + (reset! viewer-snapshot + {:os-canvas nil + :page-key nil + :canvas-w 0 + :canvas-h 0})) + +(defn- draw-bitmap! + [canvas os-canvas object-id vis-w vis-h finish] + (ts/raf + (fn [] + (let [ctx2d (.getContext canvas "2d")] + (.clearRect ctx2d 0 0 vis-w vis-h) + ;; Draw directly from OffscreenCanvas so it can be reused across passes. + (.drawImage ctx2d os-canvas 0 0 vis-w vis-h) + (dom/set-attribute! canvas "id" (str "screenshot-" object-id)) + (finish))))) + +(defn- viewer-disable-wasm-ui-overlay! + "Workspace WASM UI (rulers + rounded viewport frame) is composited in + `present_frame`; the viewer must not show that chrome." + [] + (wasm.api/set-rulers-frame-visible! false) + (wasm.api/set-rulers-visible! false)) + +(defn- viewer-apply-layer-mask! + [include-ids clear-fills-ids] + (wasm.api/clear-render-include-filter!) + (when (seq include-ids) + (wasm.api/set-render-include-filter! include-ids)) + (doseq [id clear-fills-ids] + (wasm.api/use-shape id) + (wasm.api/clear-shape-fills!))) + +(defn- viewer-restore-layer-mask! + [page-objects clear-fills-ids] + (wasm.api/clear-render-include-filter!) + (doseq [id clear-fills-ids] + (wasm.api/use-shape id) + (wasm.api/set-shape-fills id (get-in page-objects [id :fills] []) false))) + +(defn- viewer-do-render! + [page-objects canvas os-canvas object-id vis-w vis-h scale size + include-ids clear-fills-ids finish] + (viewer-disable-wasm-ui-overlay!) + (viewer-apply-layer-mask! include-ids clear-fills-ids) + (wasm.api/set-viewer-viewport! scale size) + (wasm.api/render-sync-shape object-id) + (viewer-restore-layer-mask! page-objects clear-fills-ids) + (draw-bitmap! canvas os-canvas object-id vis-w vis-h finish)) + +(defn- render-to-canvas* + [objects canvas bounds scale object-id on-render] + (p/create + (fn [resolve _reject] + (let [width (.-width canvas) + height (.-height canvas) + prev-disable @wasm/disable-request-render? + finish (fn [] + (reset! wasm/disable-request-render? prev-disable) + (when (fn? on-render) (on-render)) + (resolve nil))] + (try + (reset! wasm/disable-request-render? true) + (let [os-canvas (js/OffscreenCanvas. width height)] + (if (wasm.api/init-canvas-context os-canvas) + (wasm.api/initialize-viewport + objects scale bounds + :background-opacity 0 + :force-sync true + :on-render + (fn [] + (viewer-disable-wasm-ui-overlay!) + (wasm.api/render-sync-shape object-id) + (draw-bitmap! canvas os-canvas object-id width height + (fn [] + (wasm.api/clear-canvas {:lose-browser-context? false}) + (reset-viewer-snapshot!) + (finish))))) + (finish))) + (catch :default e + (js/console.error "Error initializing canvas context:" e) + (finish))))))) + +(defn render-to-canvas + "One-shot WASM render into `canvas` (exports, thumbnails). Serialized globally." + [objects canvas bounds scale object-id on-render] + (enqueue-wasm-render! + (fn [] + (render-to-canvas* objects canvas bounds scale object-id on-render)))) + +(defn- render-viewer-frame* + [page-key page-objects canvas size scale object-id on-render + {:keys [include-ids clear-fills-ids] :or {clear-fills-ids #{}}}] + (p/create + (fn [resolve _reject] + (let [prev-disable @wasm/disable-request-render? + finish (fn [] + (reset! wasm/disable-request-render? prev-disable) + (when (fn? on-render) (on-render)) + (resolve nil)) + vis-w (.-width canvas) + vis-h (.-height canvas) + snap @viewer-snapshot + same-page? (and (some? page-key) (identical? page-key (:page-key snap))) + same-size? (and (= vis-w (:canvas-w snap)) (= vis-h (:canvas-h snap))) + os (:os-canvas snap) + do-render! (fn [os-canvas] + (viewer-do-render! page-objects canvas os-canvas object-id + vis-w vis-h scale size include-ids + clear-fills-ids finish))] + + (reset! wasm/disable-request-render? true) + + (try + (if (and same-page? (wasm.api/initialized?) os) + (do + (when-not same-size? + (wasm.api/resize-offscreen-canvas! os vis-w vis-h) + (swap! viewer-snapshot assoc :canvas-w vis-w :canvas-h vis-h)) + (do-render! os)) + (let [os-canvas (js/OffscreenCanvas. vis-w vis-h)] + (when (wasm.api/initialized?) + (wasm.api/clear-canvas {:lose-browser-context? false})) + (if (wasm.api/init-canvas-context os-canvas) + (do + (reset! viewer-snapshot + {:os-canvas os-canvas + :page-key page-key + :canvas-w vis-w + :canvas-h vis-h}) + (wasm.api/initialize-viewport + page-objects scale size + :background-opacity 0 + :force-sync true + :on-render #(do-render! os-canvas))) + (finish)))) + (catch :default e + (js/console.error "viewer-snapshot: render error" e) + (finish))))))) + +(defn- use-fixed-scroll-sync! + [enabled? layer-ref] + (mf/use-layout-effect + (mf/deps enabled?) + (fn [] + (when enabled? + (let [section (dom/get-element "viewer-section") + sync! + (fn [] + (when-let [layer (mf/ref-val layer-ref)] + (dom/set-style! layer "transform" + (dm/str "translate(" + (or (dom/get-h-scroll-pos section) 0) "px, " + (or (dom/get-scroll-pos section) 0) "px)"))))] + (when section + (sync!) + (let [key (events/listen section "scroll" (fn [_] (sync!)))] + #(events/unlistenByKey key)))))))) + +(defn- use-viewer-wasm-layers! + [page-id page-objects size scale frame-id not-fixed-ref fixed-ref + not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids] + (mf/use-layout-effect + (mf/deps page-id page-objects size scale frame-id + not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids) + (fn [] + (when (get page-objects frame-id) + (->> @wasm.api/module + (p/fmap + (fn [ready?] + (when ready? + (let [not-fixed-canvas (mf/ref-val not-fixed-ref) + fixed-canvas (mf/ref-val fixed-ref) + passes + (cond-> [] + not-fixed-canvas + (conj {:canvas not-fixed-canvas + :opts (cond-> {} + (seq not-fixed-include-ids) + (assoc :include-ids not-fixed-include-ids))}) + + (and fixed-canvas (seq fixed-include-ids)) + (conj {:canvas fixed-canvas + :opts (cond-> {:include-ids fixed-include-ids} + (seq fixed-clear-fills-ids) + (assoc :clear-fills-ids fixed-clear-fills-ids))}))] + (when (seq passes) + (enqueue-wasm-render! + (fn [] + (reduce (fn [chain {:keys [canvas opts]}] + (p/then chain + #(render-viewer-frame* page-id page-objects + canvas size scale frame-id + nil opts))) + (p/resolved nil) + passes))))))))))))) + +(defn use-viewer-wasm-viewport! + "WASM render passes and fixed-scroll DOM sync for the viewer viewport." + [page-id page-objects size scale frame-id + not-fixed-ref fixed-ref fixed-scroll-layer-ref + not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids] + (use-fixed-scroll-sync! (some? fixed-scroll-layer-ref) fixed-scroll-layer-ref) + (use-viewer-wasm-layers! page-id page-objects size scale frame-id + not-fixed-ref fixed-ref + not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids)) diff --git a/frontend/src/app/main/ui/inspect/render.cljs b/frontend/src/app/main/ui/inspect/render.cljs index 87cdb901f7..25e223f9e5 100644 --- a/frontend/src/app/main/ui/inspect/render.cljs +++ b/frontend/src/app/main/ui/inspect/render.cljs @@ -23,7 +23,7 @@ [app.main.ui.shapes.shape :refer [shape-container]] [app.main.ui.shapes.svg-raw :as svg-raw] [app.main.ui.shapes.text :as text] - [app.main.ui.viewer.interactions :refer [prepare-objects]] + [app.main.ui.viewer.viewport-common :refer [prepare-objects]] [app.util.dom :as dom] [app.util.object :as obj] [rumext.v2 :as mf])) diff --git a/frontend/src/app/main/ui/viewer/interactions.cljs b/frontend/src/app/main/ui/viewer/interactions.cljs index 35138ef829..0b1589b36b 100644 --- a/frontend/src/app/main/ui/viewer/interactions.cljs +++ b/frontend/src/app/main/ui/viewer/interactions.cljs @@ -9,53 +9,26 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] - [app.common.files.helpers :as cfh] [app.common.geom.point :as gpt] - [app.common.geom.shapes :as gsh] - [app.common.types.modifiers :as ctm] [app.common.types.page :as ctp] [app.common.uuid :as uuid] + [app.config :as cf] [app.main.data.comments :as dcm] [app.main.data.viewer :as dv] + [app.main.features :as features] [app.main.store :as st] [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.hooks :as h] [app.main.ui.icons :as deprecated-icon] [app.main.ui.viewer.shapes :as shapes] + [app.main.ui.viewer.viewport-common :as vpc] + [app.main.ui.viewer.viewport-wasm :as viewport.wasm] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] [goog.events :as events] [rumext.v2 :as mf])) -(defn prepare-objects - [frame size delta objects] - (let [frame-id (:id frame) - vector (-> (gpt/point (:x size) (:y size)) - (gpt/add delta) - (gpt/negate)) - update-fn #(d/update-when %1 %2 gsh/transform-shape (ctm/move-modifiers vector))] - (->> (cfh/get-children-ids objects frame-id) - (into [frame-id]) - (reduce update-fn objects)))) - -(defn get-fixed-ids - [objects] - (let [fixed-ids (filter cfh/fixed-scroll? (vals objects)) - - ;; we have to consider the children if the fixed element is a group - fixed-children-ids - (into #{} (mapcat #(cfh/get-children-ids objects (:id %)) fixed-ids)) - - parent-children-ids - (->> fixed-ids - (mapcat #(cons (:id %) (cfh/get-parent-ids objects (:id %)))) - (remove #(= % uuid/zero))) - - fixed-ids - (concat fixed-children-ids parent-children-ids)] - fixed-ids)) - (mf/defc viewport-svg {::mf/wrap [mf/memo] ::mf/wrap-props false} @@ -74,7 +47,7 @@ objects (:objects page) objects (cond-> objects fixed? (assoc-in [(:id frame) :fixed-scroll] true)) - fixed-ids (get-fixed-ids objects) + fixed-ids (vpc/get-fixed-ids objects) not-fixed-ids (->> (remove (set fixed-ids) (keys objects)) @@ -86,7 +59,7 @@ (map (d/getf objects)) (concat [frame]) (d/index-by :id) - (prepare-objects frame size delta))) + (vpc/prepare-objects frame size delta))) objects-fixed (mf/with-memo [fixed-ids page frame size delta] @@ -175,7 +148,10 @@ page (unchecked-get props "page") frame (unchecked-get props "frame") base (unchecked-get props "base-frame") - fixed? (unchecked-get props "fixed?")] + fixed? (unchecked-get props "fixed?") + + render-wasm? (and (features/use-feature "render-wasm/v1") + (contains? cf/flags :available-viewer-wasm))] (mf/with-effect [mode] (let [on-click @@ -210,13 +186,21 @@ (events/unlistenByKey key2) (events/unlistenByKey key3)))) - [:& viewport-svg {:page page - :frame frame - :base base - :offset offset - :size size - :delta delta - :fixed? fixed?}])) + (if ^boolean render-wasm? + [:& viewport.wasm/viewport-wasm {:page page + :frame frame + :base base + :offset offset + :size size + :delta delta + :fixed? fixed?}] + [:& viewport-svg {:page page + :frame frame + :base base + :offset offset + :size size + :delta delta + :fixed? fixed?}]))) (mf/defc flows-menu* {::mf/wrap [mf/memo]} diff --git a/frontend/src/app/main/ui/viewer/shapes.cljs b/frontend/src/app/main/ui/viewer/shapes.cljs index 880c515c49..9f407b6714 100644 --- a/frontend/src/app/main/ui/viewer/shapes.cljs +++ b/frontend/src/app/main/ui/viewer/shapes.cljs @@ -294,6 +294,80 @@ :pointer-events "none" :transform (gsh/transform-str shape)}]))) +;; --- WASM viewer hotspots --- +;; In WASM viewer mode the frame pixels come from a WASM snapshot, so we don't +;; render the SVG visuals at all. We only render the actionable areas (hotspots) +;; on top of the image: a transparent hit/highlight rect per interactive shape, +;; wired to the same interaction handlers as the regular SVG tree. + +(mf/defc hotspot* + {::mf/wrap-props false} + [props] + (let [shape (unchecked-get props "shape") + all-objects (unchecked-get props "all-objects") + base-frame (mf/use-ctx base-frame-ctx) + frame-offset (mf/use-ctx frame-offset-ctx) + show-interactions (mf/deref ref:viewer-show-interactions) + overlays (mf/deref refs/viewer-overlays) + interactions (:interactions shape) + {:keys [x y width height]} (:selrect shape) + + on-pd (mf/use-fn (mf/deps shape base-frame frame-offset all-objects overlays) + #(on-pointer-down % shape base-frame frame-offset all-objects overlays)) + on-pu (mf/use-fn (mf/deps shape base-frame frame-offset all-objects overlays) + #(on-pointer-up % shape base-frame frame-offset all-objects overlays)) + on-pe (mf/use-fn (mf/deps shape base-frame frame-offset all-objects overlays) + #(on-pointer-enter % shape base-frame frame-offset all-objects overlays)) + on-pl (mf/use-fn (mf/deps shape base-frame frame-offset all-objects overlays) + #(on-pointer-leave % shape base-frame frame-offset all-objects overlays))] + + (mf/with-effect [] + (let [sems (on-load shape base-frame frame-offset all-objects overlays)] + (partial run! tm/dispose! sems))) + + [:g {:style {:cursor (when (ctsi/actionable? interactions) "pointer")} + :on-pointer-down on-pd + :on-pointer-up on-pu + :on-pointer-enter on-pe + :on-pointer-leave on-pl} + [:rect {:x (- x 1) + :y (- y 1) + :width (+ width 2) + :height (+ height 2) + :fill "var(--color-accent-tertiary)" + :stroke "var(--color-accent-tertiary)" + :stroke-width (if show-interactions 1 0) + :fill-opacity (if show-interactions 0.2 0) + ;; This rect is the only hit target, so it must always capture + ;; pointer events even when fully transparent. + :pointer-events "all" + :transform (gsh/transform-str shape)}]])) + +(mf/defc frame-hotspots* + "Renders interaction hotspots for a frame subtree (WASM viewer mode). + `objects` must be the prepared (vbox-space) objects and `frame` the prepared + frame; only shapes with interactions produce a hotspot. + + Optional `shape-filter` is a predicate that receives the shape id and returns + true when it should be included (used to split fixed-scroll vs normal layers)." + {::mf/wrap-props false} + [props] + (let [objects (unchecked-get props "objects") + all-objects (or (unchecked-get props "all-objects") objects) + shape-filter (unchecked-get props "shape-filter") + frame (unchecked-get props "frame") + frame-id (:id frame) + ids (cond->> (cons frame-id (cfh/get-children-ids objects frame-id)) + shape-filter (filter shape-filter)) + hotspots (->> ids + (keep #(get objects %)) + (filter (fn [s] (and (not (:hidden s)) + (seq (:interactions s))))))] + [:* (for [shape hotspots] + [:& hotspot* {:key (str (:id shape)) + :shape shape + :all-objects all-objects}])])) + ;; TODO: use-memo use-fn diff --git a/frontend/src/app/main/ui/viewer/viewport_common.cljs b/frontend/src/app/main/ui/viewer/viewport_common.cljs new file mode 100644 index 0000000000..e2ebb1b881 --- /dev/null +++ b/frontend/src/app/main/ui/viewer/viewport_common.cljs @@ -0,0 +1,67 @@ +;; 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.ui.viewer.viewport-common + "Shared object preparation for viewer viewports (SVG and WASM)." + (:require + [app.common.data :as d] + [app.common.files.helpers :as cfh] + [app.common.geom.point :as gpt] + [app.common.geom.shapes :as gsh] + [app.common.types.modifiers :as ctm] + [app.common.uuid :as uuid])) + +(defn prepare-objects + [frame size delta objects] + (let [frame-id (:id frame) + vector (-> (gpt/point (:x size) (:y size)) + (gpt/add delta) + (gpt/negate)) + update-fn #(d/update-when %1 %2 gsh/transform-shape (ctm/move-modifiers vector))] + (->> (cfh/get-children-ids objects frame-id) + (into [frame-id]) + (reduce update-fn objects)))) + +(defn get-fixed-ids + [objects] + (let [fixed-ids (filter cfh/fixed-scroll? (vals objects)) + + fixed-children-ids + (into #{} (mapcat #(cfh/get-children-ids objects (:id %)) fixed-ids)) + + parent-children-ids + (->> fixed-ids + (mapcat #(cons (:id %) (cfh/get-parent-ids objects (:id %)))) + (remove #(= % uuid/zero))) + + fixed-ids + (concat fixed-children-ids parent-children-ids)] + fixed-ids)) + +(defn frame-fixed-mask-ids + "Fixed-layer shape ids inside `frame-id` (same rules as `get-fixed-ids`)." + [objects frame-id] + (when frame-id + (let [subtree (into #{} (cfh/get-children-ids-with-self objects frame-id))] + (into #{} + (filter #(contains? subtree %) + (get-fixed-ids objects)))))) + +(defn prepare-page-objects + "Transform all page objects into vbox-space (for overlay positioning)." + [objects size delta] + (let [vector (-> (gpt/point (:x size) (:y size)) + (gpt/add delta) + (gpt/negate)) + update-fn #(d/update-when %1 %2 gsh/transform-shape (ctm/move-modifiers vector)) + ids (->> (keys objects) (remove #(= % uuid/zero)))] + (reduce update-fn objects ids))) + +(defn viewer-scale + [size] + (if (and (:base-width size) (pos? (:base-width size))) + (/ (:width size) (:base-width size)) + 1)) diff --git a/frontend/src/app/main/ui/viewer/viewport_wasm.cljs b/frontend/src/app/main/ui/viewer/viewport_wasm.cljs new file mode 100644 index 0000000000..2c9b5a31e4 --- /dev/null +++ b/frontend/src/app/main/ui/viewer/viewport_wasm.cljs @@ -0,0 +1,160 @@ +;; 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.ui.viewer.viewport-wasm + (:require-macros [app.main.style :as stl]) + (:require + [app.common.files.helpers :as cfh] + [app.common.geom.point :as gpt] + [app.main.render-viewer-wasm :as rwv] + [app.main.ui.viewer.shapes :as shapes] + [app.main.ui.viewer.viewport-common :as vpc] + [app.util.object :as obj] + [rumext.v2 :as mf])) + +(defn- canvas-dimensions + [scale size] + {:width (js/Math.round (* scale (:base-width size))) + :height (js/Math.round (* scale (:base-height size)))}) + +(defn- frame-hotspots-props + "frame-hotspots* uses ::mf/wrap-props false and expects string keys." + [prepared prepared-all prepared-frame shape-filter] + (let [props #js {"objects" prepared + "all-objects" prepared-all + "frame" prepared-frame}] + (when shape-filter + (obj/set! props "shape-filter" shape-filter)) + props)) + +(mf/defc wasm-hotspots-svg + [{:keys [vbox size class prepared prepared-all prepared-frame shape-filter]}] + [:svg {:view-box vbox + :width (:width size) + :height (:height size) + :version "1.1" + :xmlnsXlink "http://www.w3.org/1999/xlink" + :xmlns "http://www.w3.org/2000/svg" + :fill "none" + :style {:position "absolute" + :top 0 + :left 0} + :class class} + [:& shapes/frame-hotspots* + (frame-hotspots-props prepared prepared-all prepared-frame shape-filter)]]) + +(mf/defc wasm-layer + [{:keys [canvas-ref scale size vbox svg-props]}] + (let [{:keys [width height]} (canvas-dimensions scale size)] + [:div {:style {:position "absolute" + :top 0 + :left 0}} + [:canvas {:ref canvas-ref :width width :height height :style {:width "100%" + :height "100%" + :background "transparent" + :pointer-events "none"}}] + [:& wasm-hotspots-svg (assoc svg-props :vbox vbox :size size)]])) + +(defn- fixed-scroll-layer-ids + [objects frame-id has-fixed?] + (let [frame-subtree-ids (into #{} (cfh/get-children-ids-with-self objects frame-id)) + fixed-mask-ids (when has-fixed? (vpc/frame-fixed-mask-ids objects frame-id)) + fixed-mask-set (or fixed-mask-ids #{}) + not-fixed-include-ids + (when has-fixed? + (into [] + (distinct + (conj (->> frame-subtree-ids + (remove #(contains? fixed-mask-set %))) + frame-id)))) + fixed-include-ids + (when has-fixed? + (vec (conj (or fixed-mask-ids #{}) frame-id))) + fixed-clear-fills-ids (when has-fixed? #{frame-id})] + {:fixed-mask-set fixed-mask-set + :not-fixed-include-ids not-fixed-include-ids + :fixed-include-ids fixed-include-ids + :fixed-clear-fills-ids fixed-clear-fills-ids})) + +(mf/defc viewport-wasm + {::mf/wrap [mf/memo] + ::mf/wrap-props false} + [props] + (let [page (unchecked-get props "page") + frame (unchecked-get props "frame") + base (unchecked-get props "base") + offset (unchecked-get props "offset") + size (unchecked-get props "size") + delta (or (unchecked-get props "delta") (gpt/point 0 0)) + vbox (:vbox size) + fixed? (true? (unchecked-get props "fixed?")) + + fixed-layer-ref (mf/use-ref nil) + not-fixed-wasm-ref (mf/use-ref nil) + fixed-wasm-ref (mf/use-ref nil) + + objects (:objects page) + frame-id (:id frame) + scale (vpc/viewer-scale size) + page-id (:id page) + + frame (cond-> frame fixed? (assoc :fixed-scroll true)) + objects (cond-> objects fixed? (assoc-in [frame-id :fixed-scroll] true)) + + has-fixed? + (and (not fixed?) + (some #(cfh/fixed-scroll? (get objects %)) + (cfh/get-children-ids objects frame-id))) + + prepared + (mf/with-memo [objects frame size delta] + (vpc/prepare-objects frame size delta objects)) + + prepared-all + (mf/with-memo [objects size delta] + (vpc/prepare-page-objects objects size delta)) + + {:keys [fixed-mask-set not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids]} + (mf/with-memo [objects frame-id has-fixed?] + (fixed-scroll-layer-ids objects frame-id has-fixed?)) + + prepared-frame (get prepared frame-id) + + svg-base {:prepared prepared + :prepared-all prepared-all + :prepared-frame prepared-frame}] + + (rwv/use-viewer-wasm-viewport! + page-id objects size scale frame-id + not-fixed-wasm-ref fixed-wasm-ref + (when has-fixed? fixed-layer-ref) + not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids) + + [:& (mf/provider shapes/base-frame-ctx) {:value (get prepared-all (:id base))} + [:& (mf/provider shapes/frame-offset-ctx) {:value offset} + [:* + [:& wasm-layer + {:canvas-ref not-fixed-wasm-ref + :scale scale + :size size + :vbox vbox + :svg-props (assoc svg-base + :class (if has-fixed? + (stl/css :not-fixed) + (when fixed? (stl/css :fixed))) + :shape-filter (when has-fixed? + #(not (contains? fixed-mask-set %))))}] + + (when has-fixed? + [:div {:ref fixed-layer-ref} + [:& wasm-layer + {:canvas-ref fixed-wasm-ref + :scale scale + :size size + :vbox vbox + :svg-props (assoc svg-base + :class (stl/css :not-fixed) + :shape-filter #(contains? fixed-mask-set %))}]])]]])) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index c4ae182fc0..71402806b4 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -931,6 +931,13 @@ [hidden] (h/call wasm/internal-module "_set_shape_hidden" hidden)) +(defn clear-shape-fills! + "Clear the fills of the currently-selected shape (call `use-shape` first). + Equivalent to `set-shape-fills` with an empty collection." + [] + (when (initialized?) + (h/call wasm/internal-module "_clear_shape_fills"))) + (defn set-shape-bool-type [bool-type] (h/call wasm/internal-module "_set_shape_bool_type" (sr/translate-bool-type bool-type))) @@ -1666,6 +1673,27 @@ (h/call wasm/internal-module "_set_focus_mode") (request-render "set-focus-mode")))) +(defn clear-render-include-filter! + "Clear the viewer include filter (render all shapes in the subtree again)." + [] + (when (initialized?) + (h/call wasm/internal-module "_clear_render_include_filter"))) + +(defn set-render-include-filter! + "Restrict the next render to `shape-ids` and descendants of whitelisted nodes. + Used for viewer fixed-scroll layers; does not change shape hidden flags." + [shape-ids] + (when (and (initialized?) (seq shape-ids)) + (let [ids (vec shape-ids) + size (mem/get-alloc-size ids UUID-U8-SIZE) + heap (mem/get-heap-u32) + offset (mem/alloc->offset-32 size)] + (reduce (fn [offset id] + (mem.h32/write-uuid offset heap id)) + offset + ids) + (h/call wasm/internal-module "_set_render_include_filter")))) + (defn set-structure-modifiers [entries] (when-not ^boolean (empty? entries) @@ -1865,6 +1893,24 @@ [width height] (h/call wasm/internal-module "_resize_viewbox" width height)) +(defn set-viewer-viewport! + "Update viewer zoom/pan and rebuild the tile index (frame hops in the viewer). + `vbox` must have at least `:x` and `:y` keys (design-space top-left corner)." + [zoom vbox] + (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) + (when (initialized?) + (h/call wasm/internal-module "_set_view_end") + (reset! view-interaction-active? false))) + +(defn resize-offscreen-canvas! + "Resize a persistent OffscreenCanvas to new physical-pixel dimensions and + update the WASM render surfaces accordingly (via `_resize_viewbox`). The + design state (shape pool) is preserved so `set-objects` is not needed again." + [canvas new-physical-w new-physical-h] + (set! (.-width canvas) new-physical-w) + (set! (.-height canvas) new-physical-h) + (resize-viewbox (/ new-physical-w dpr) (/ new-physical-h dpr))) + (defn- debug-flags [] (cond-> 0 diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index a0939cdd95..83b3ad23f8 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -151,6 +151,31 @@ pub extern "C" fn render_blurred_snapshot(blur_radius: f32) -> Result<()> { Ok(()) } +#[no_mangle] +#[wasm_error] +pub extern "C" fn clear_render_include_filter() -> Result<()> { + with_state!(state, { + state.clear_include_filter(); + }); + Ok(()) +} + +#[no_mangle] +#[wasm_error] +pub extern "C" fn set_render_include_filter() -> Result<()> { + let bytes = mem::bytes(); + + let entries: Vec = bytes + .chunks(size_of::<::BytesType>()) + .map(|data| Uuid::try_from(data).map_err(|e| Error::RecoverableError(e.to_string()))) + .collect::>>()?; + + with_state!(state, { + state.set_include_filter(entries); + }); + Ok(()) +} + #[no_mangle] #[wasm_error] pub extern "C" fn render_sync() -> Result<()> { diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 1f7d9842b2..f5712be7e6 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -372,6 +372,10 @@ pub(crate) struct RenderState { pub show_grid: Option, pub rulers: RulerState, pub focus_mode: FocusMode, + /// Viewer-only whitelist for fixed-scroll layer passes. + pub include_filter: Option>, + /// Frame id passed as `base_object` for viewer renders; always traversed. + pub viewer_render_root: Option, pub touched_ids: HashSet, /// Temporary flag used for off-screen passes (drop-shadow masks, filter surfaces, etc.) /// where we must render shapes without inheriting ancestor layer blurs. Toggle it through @@ -568,6 +572,8 @@ impl RenderState { show_grid: None, rulers: RulerState::default(), focus_mode: FocusMode::new(), + include_filter: None, + viewer_render_root: None, touched_ids: HashSet::default(), ignore_nested_blurs: false, preview_mode: false, @@ -850,7 +856,14 @@ impl RenderState { /// on top of Target, then present. Backbuffer is left clean so it can be reused /// as-is across interactive-transform frames without stale overlay pixels. pub fn present_frame(&mut self, tree: ShapesPoolRef) { - self.surfaces.copy_backbuffer_to_target(); + // Viewer masked passes render a partial scene onto a transparent backbuffer. + // SrcOver would keep pass-1 pixels wherever the backbuffer stays transparent. + if self.viewer_masked_pass() { + self.surfaces.clear_target(skia::Color::TRANSPARENT); + self.surfaces.copy_backbuffer_to_target_replace(); + } else { + self.surfaces.copy_backbuffer_to_target(); + } if self.options.is_debug_visible() { debug::render(self); } @@ -942,6 +955,20 @@ impl RenderState { return Ok(()); } + // Viewer masked passes render a partial scene. Reusing the tile texture cache would + // SrcOver-blend onto textures from the previous pass and leak pixels into the blob. + if self.viewer_masked_pass() { + // Use viewbox-aligned bounds (not grid-snapped) to match interactive-transform + // compositing and avoid a visible offset vs the DOM canvas. + let tile_rect = self.get_current_tile_bounds()?; + self.surfaces.draw_current_tile_into_backbuffer( + &tile_rect, + self.background_color, + surfaces::DrawOnCache::No, + ); + return Ok(()); + } + let fast_mode = self.options.is_fast_mode(); // Decide *now* (at the first real cache blit) whether we need to clear Cache. // This avoids clearing Cache on renders that don't actually paint tiles (e.g. hover/UI), @@ -1043,6 +1070,55 @@ impl RenderState { self.focus_mode.set_shapes(shapes); } + pub fn clear_include_filter(&mut self) { + self.include_filter = None; + } + + pub fn set_include_filter(&mut self, shapes: Vec) { + self.include_filter = Some(shapes.into_iter().collect()); + } + + fn viewer_masked_pass(&self) -> bool { + self.include_filter.is_some() + } + + fn reset_viewer_masked_surfaces(&mut self) { + self.surfaces.clear_backbuffer(self.background_color); + self.surfaces.clear_tile_atlas(); + } + + /// True when the shape or any descendant is whitelisted. + pub fn shape_visible_in_include_filter(&self, shape_id: &Uuid, tree: ShapesPoolRef) -> bool { + let Some(ref include) = self.include_filter else { + return true; + }; + if include.contains(shape_id) { + return true; + } + let Some(shape) = tree.get(shape_id) else { + return false; + }; + shape + .children_ids_iter(false) + .any(|child_id| self.shape_visible_in_include_filter(child_id, tree)) + } + + /// When an include whitelist is active, only those ids are painted. + fn shape_should_paint_for_viewer_layer(&self, shape_id: &Uuid) -> bool { + match &self.include_filter { + Some(include) => include.contains(shape_id), + None => true, + } + } + + /// Viewer layer mask: traverse whitelisted subtrees; paint only listed ids. + pub fn shape_visible_for_viewer_layer(&self, shape_id: &Uuid, tree: ShapesPoolRef) -> bool { + if self.viewer_render_root.as_ref() == Some(shape_id) { + return true; + } + self.shape_visible_in_include_filter(shape_id, tree) + } + fn get_inherited_drop_shadows(&self) -> Option> { let drop_shadows: Vec<&Shadow> = self .nested_shadows @@ -2113,6 +2189,13 @@ impl RenderState { self.interactive_target_seeded = false; } + // Viewer fixed-scroll passes reuse the same WASM context; `reset` does not + // clear Backbuffer, so pass 2 would otherwise keep pass-1 pixels in regions + // that render no shapes for the current mask. Target is cleared in present_frame. + if self.viewer_masked_pass() { + self.reset_viewer_masked_surfaces(); + } + let surface_ids = SurfaceId::Strokes as u32 | SurfaceId::Fills as u32 | SurfaceId::InnerShadows as u32 @@ -3146,6 +3229,33 @@ impl RenderState { continue; } + if !self.shape_visible_for_viewer_layer(&node_id, tree) { + continue; + } + + // Ancestors needed to reach whitelisted descendants: traverse only. + if self.include_filter.is_some() + && self.shape_visible_for_viewer_layer(&node_id, tree) + && !self.shape_should_paint_for_viewer_layer(&node_id) + { + if element.is_recursive() { + let children_ids: Vec<_> = + element.children_ids_iter(false).copied().collect(); + let children_ids = sort_z_index(tree, element, children_ids); + for child_id in children_ids.iter() { + self.pending_nodes.push(NodeRenderState { + id: *child_id, + visited_children: false, + clip_bounds: clip_bounds.clone(), + visited_mask: false, + mask: false, + flattened: false, + }); + } + } + continue; + } + // For frames and groups, we must use extrect because they can have nested content // that extends beyond their selrect. Using selrect for early exit would incorrectly // skip frames/groups that have nested content in the current tile. @@ -3428,6 +3538,7 @@ impl RenderState { allow_stop: bool, ) -> Result { let mut should_stop = false; + self.viewer_render_root = base_object.copied(); let root_ids = { if let Some(shape_id) = base_object { vec![*shape_id] @@ -3443,7 +3554,10 @@ impl RenderState { if let Some(current_tile) = self.current_tile { // NOTE: For now we don't need to cover the case where the tile // is not cached because everything will be handled from draw_atlas. - if !self.surfaces.has_cached_tile_surface(current_tile) { + // Viewer masked passes (include_filter) must not reuse cached tiles from + // a previous pass; otherwise pass-1 pixels can leak into pass 2. + if self.viewer_masked_pass() || !self.surfaces.has_cached_tile_surface(current_tile) + { performance::begin_measure!("render_shape_tree::uncached"); let (is_empty, early_return) = self .render_shape_tree_partial_uncached(tree, timestamp, allow_stop, false)?; @@ -3454,6 +3568,7 @@ impl RenderState { } if early_return { + self.viewer_render_root = None; return Ok(FrameType::Partial); } performance::end_measure!("render_shape_tree::uncached"); @@ -3504,12 +3619,15 @@ impl RenderState { // empty tile. self.current_tile_had_shapes = false; + let viewer_masked_pass = self.viewer_masked_pass(); + let Some(ids) = self.tiles.get_shapes_at(next_tile) else { // If the tile is empty we do not need to render it. continue; }; - if self.surfaces.has_cached_tile_surface(next_tile) { + // Never skip based on cached surfaces during viewer masked passes. + if !viewer_masked_pass && self.surfaces.has_cached_tile_surface(next_tile) { // If the tile is cached, then we do not need to // render it. continue; @@ -3563,6 +3681,8 @@ impl RenderState { } } + self.viewer_render_root = None; + // Mark cache as valid for render_from_cache. // Only update for full-quality renders (non-fast mode). // An async render can complete while fast mode is active diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 04d6948371..867b657c7b 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -864,6 +864,30 @@ impl Surfaces { ); } + /// Replace `Target` pixels with `Backbuffer` (Src blend). + /// + /// Used for viewer masked passes: transparent backbuffer regions must not + /// preserve prior `Target` content from an earlier pass. + pub fn copy_backbuffer_to_target_replace(&mut self) { + let sampling_options = self.sampling_options; + let mut paint = skia::Paint::default(); + paint.set_blend_mode(skia::BlendMode::Src); + self.backbuffer.draw( + self.target.canvas(), + (0.0, 0.0), + sampling_options, + Some(&paint), + ); + } + + pub fn clear_target(&mut self, color: skia::Color) { + self.target.canvas().clear(color); + } + + pub fn clear_tile_atlas(&mut self) { + self.tile_atlas.canvas().clear(skia::Color::TRANSPARENT); + } + /// Seed `Backbuffer` from `Target` (last presented frame). pub fn seed_backbuffer_from_target(&mut self) { let sampling_options = self.sampling_options; @@ -1026,6 +1050,17 @@ impl Surfaces { } } + /// Full backbuffer clear (viewer layer passes must not reuse prior pass pixels). + pub fn clear_backbuffer(&mut self, color: skia::Color) { + self.backbuffer.canvas().clear(color); + } + + pub fn clear_backbuffer_rect(&mut self, rect: skia::Rect, color: skia::Color) { + let mut paint = Paint::default(); + paint.set_color(color); + self.backbuffer.canvas().draw_rect(rect, &paint); + } + pub fn reset(&mut self, color: skia::Color) { self.canvas(SurfaceId::Fills).restore_to_count(1); self.canvas(SurfaceId::InnerShadows).restore_to_count(1); diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 970c9cef1a..3b6835fd30 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -118,6 +118,14 @@ impl State { get_render_state().set_focus_mode(shapes); } + pub fn clear_include_filter(&mut self) { + get_render_state().clear_include_filter(); + } + + pub fn set_include_filter(&mut self, shapes: Vec) { + get_render_state().set_include_filter(shapes); + } + pub fn init_shapes_pool(&mut self, capacity: usize) { self.shapes.initialize(capacity); }