🎉 Basic viewer with wasm

This commit is contained in:
Alejandro Alonso 2026-06-02 09:56:51 +02:00
parent f9f4d7e2cd
commit 9f5e89d5f8
14 changed files with 811 additions and 73 deletions

View File

@ -169,6 +169,7 @@
:mcp
:background-blur
:available-viewer-wasm
:stroke-path})
(def all-flags

View File

@ -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

View File

@ -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)

View File

@ -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))

View File

@ -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]))

View File

@ -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]}

View File

@ -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

View File

@ -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))

View File

@ -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 %))}]])]]]))

View File

@ -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

View File

@ -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<Uuid> = bytes
.chunks(size_of::<<Uuid as SerializableResult>::BytesType>())
.map(|data| Uuid::try_from(data).map_err(|e| Error::RecoverableError(e.to_string())))
.collect::<Result<Vec<Uuid>>>()?;
with_state!(state, {
state.set_include_filter(entries);
});
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn render_sync() -> Result<()> {

View File

@ -372,6 +372,10 @@ pub(crate) struct RenderState {
pub show_grid: Option<Uuid>,
pub rulers: RulerState,
pub focus_mode: FocusMode,
/// Viewer-only whitelist for fixed-scroll layer passes.
pub include_filter: Option<HashSet<Uuid>>,
/// Frame id passed as `base_object` for viewer renders; always traversed.
pub viewer_render_root: Option<Uuid>,
pub touched_ids: HashSet<Uuid>,
/// 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<Uuid>) {
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<Vec<skia_safe::Paint>> {
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<FrameType> {
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

View File

@ -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);

View File

@ -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<Uuid>) {
get_render_state().set_include_filter(shapes);
}
pub fn init_shapes_pool(&mut self, capacity: usize) {
self.shapes.initialize(capacity);
}