🎉 Render guides in wasm (#10014)

*  Remove guides from svg overlay

* 🎉 Draw guides in wasm

* 🎉 Serialize guides to wasm

*  Store separate and sorted horizontal and vertical guides

* 🎉 Implement collision detection with guides

* 🎉 Right click on guides to change color or remove

*  Implement dragging guides

* 🎉 Edit wasm guides by double clicking them

* 🎉 Implement changing mouse cursor on hovering a guide

*  Show guide pill on hover

* 🎉 Implement removing guide on hovering + Del

* 🔧 Fix lint + fmt errors

* 🎉 Clip out outer board guide lines

* ♻️ Extract common code into guide-pill* component

* 🎉 Draw dotted lines on hovering board guides

* 🐛 Fix board rotation when it has guides

* 🎉 Make foreign guides not visible in focus mode
This commit is contained in:
Belén Albeza 2026-06-16 09:19:58 +02:00 committed by GitHub
parent b06942c668
commit d1dd5d9016
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1287 additions and 134 deletions

View File

@ -23,6 +23,8 @@
[app.main.ui.css-cursors :as cur]
[app.main.ui.formats :as fmt]
[app.main.ui.workspace.viewport.rulers :as rulers]
[app.main.ui.workspace.viewport.viewport-ref :as uwvv]
[app.render-wasm.api :as wasm.api]
[app.util.dom :as dom]
[app.util.keyboard :as kbd]
[cuerdas.core :as str]
@ -38,11 +40,51 @@
(def ^:const guide-pill-corner-radius 4)
(def ^:const guide-active-area 16)
;; Manhattan distance (in screen pixels) the pointer must travel after
;; pointerdown before we consider the interaction a drag. Below this we keep
;; the hover state so a click — including the clicks that make up a
;; double-click — doesn't flicker the overlay pill.
(def ^:const guide-drag-threshold 3)
(def ^:const guide-creation-margin-left 8)
(def ^:const guide-creation-margin-top 28)
(def ^:const guide-creation-width 16)
(def ^:const guide-creation-height 24)
(defn compute-guide-drag-position
"Computes the guide axis position from pointer drag delta."
[{:keys [axis position start-pos start-pt current-pt zoom snap-pixel?]}]
(let [delta (/ (- (get current-pt axis) (get start-pt axis)) zoom)
new-position (if (some? position)
(+ position delta)
(+ start-pos delta))]
(if snap-pixel?
(mth/round new-position)
new-position)))
(defn guide-visible-in-focus?
"When focus mode is active, only free guides and guides bound to a focused
board are visible and interactive."
[focus frame-id]
(or (nil? frame-id)
(empty? focus)
(contains? focus frame-id)))
(defn wasm-visible-guides
"Guide map sent to the WASM renderer. Must be the same map used to resolve
`find-guide-at` indices (`guide-by-serialized-index`). Filters by
rulers/grids visibility, focus mode, and excludes the guide currently being
dragged (the SVG overlay draws it instead)."
[{:keys [guides visible? focused dragging-id]}]
(let [guides (if visible? (or guides {}) {})
guides (if (seq focused)
(into {} (filter (fn [[_ guide]]
(guide-visible-in-focus? focused (:frame-id guide)))
guides))
guides)]
(cond-> guides
dragging-id (dissoc dragging-id))))
(defn use-guide
"Hooks to support drag/drop for existing guides and new guides"
[on-guide-change get-hover-frame zoom {:keys [id position axis frame-id]}]
@ -120,21 +162,21 @@
on-pointer-move
(mf/use-fn
(mf/deps position zoom snap-pixel? read-only? get-hover-frame)
(mf/deps position zoom snap-pixel? read-only? get-hover-frame axis)
(fn [event]
(when-not read-only?
(when (mf/ref-val dragging-ref)
(let [start-pt (mf/ref-val start-ref)
start-pos (mf/ref-val start-pos-ref)
current-pt (dom/get-client-position event)
delta (/ (- (get current-pt axis) (get start-pt axis)) zoom)
new-position (if (some? position)
(+ position delta)
(+ start-pos delta))
new-position (if snap-pixel?
(mth/round new-position)
new-position)
new-position (compute-guide-drag-position
{:axis axis
:position position
:start-pos start-pos
:start-pt start-pt
:current-pt current-pt
:zoom zoom
:snap-pixel? snap-pixel?})
new-frame-id (-> (get-hover-frame)
(get :id))]
@ -283,6 +325,125 @@
(and (>= (:position guide) (:y frame))
(<= (:position guide) (+ (:y frame) (:height frame))))))
(mf/defc guide-pill*
"Presentational pill shown next to a guide line: a colored rounded rect with
either the guide position as text or, when `editing`, an inline number input.
Shared by the SVG (`guide*`) and WASM overlay (`guide-overlay*`) renderers;
each owns its own interaction model and passes the relevant handlers in."
{::mf/wrap [mf/memo]}
[{:keys [pos vbox zoom axis color frame-offset editing
input-ref on-input-key-down on-input-blur on-double-click]}]
(let [{:keys [rect-x rect-y rect-width rect-height text-x text-y]}
(guide-pill-axis pos vbox zoom axis)
corner-radius (/ guide-pill-corner-radius zoom)
display-value (fmt/format-number (- pos frame-offset))
input-w (/ guide-pill-width zoom)
input-h (/ guide-pill-height zoom)]
[:g.guide-pill
[:rect {:x rect-x
:y rect-y
:width rect-width
:height rect-height
:rx corner-radius
:ry corner-radius
:style {:fill color}
:on-double-click on-double-click}]
(if editing
[:foreignObject {:x (- text-x (/ input-w 2))
:y (- text-y (/ input-h 2))
:width input-w
:height input-h
:transform (when (= axis :y)
(str "rotate(-90 " text-x "," text-y ")"))}
[:input {:ref input-ref
:type "number"
:step "any"
:default-value display-value
:auto-focus true
:on-key-down on-input-key-down
:on-blur on-input-blur
:on-pointer-down dom/stop-propagation
:style {:width "100%"
:height "100%"
:border "none"
:outline "none"
:padding 0
:margin 0
:background "transparent"
:color colors/white
:font-family rulers/font-family
:font-size (str (/ rulers/font-size zoom) "px")
:text-align "center"
:-moz-appearance "textfield"}}]]
[:text {:x text-x
:y text-y
:text-anchor "middle"
:dominant-baseline "middle"
:transform (when (= axis :y) (str "rotate(-90 " text-x "," text-y ")"))
:style {:font-size (/ rulers/font-size zoom)
:font-family rulers/font-family
:fill colors/white
:pointer-events "none"}}
display-value])]))
(mf/defc guide-line*
"Presentational guide line. With a `frame`, draws the solid in-frame segment
and, on `hover?`, the dotted out-of-frame extensions; without a frame, a
single solid line. `show-main?` (default true) lets callers suppress the solid
segment when the render engine already draws it — e.g. the WASM hover overlay,
which only needs the dotted extensions on top of the WASM-rendered line."
{::mf/wrap [mf/memo]}
[{:keys [pos vbox zoom axis color frame hover? show-main?]
:or {show-main? true}}]
(let [width (/ guide-width zoom)
main-opacity (if hover? guide-opacity-hover guide-opacity)
dash (str "0, " (/ 6 zoom))]
(if (some? frame)
(let [{:keys [l1-x1 l1-y1 l1-x2 l1-y2
l2-x1 l2-y1 l2-x2 l2-y2
l3-x1 l3-y1 l3-x2 l3-y2]}
(guide-line-axis pos vbox frame axis)]
[:g
(when hover?
[:line {:x1 l1-x1
:y1 l1-y1
:x2 l1-x2
:y2 l1-y2
:style {:stroke color
:stroke-opacity guide-opacity-hover
:stroke-dasharray dash
:stroke-linecap "round"
:stroke-width width}}])
(when show-main?
[:line {:x1 l2-x1
:y1 l2-y1
:x2 l2-x2
:y2 l2-y2
:style {:stroke color
:stroke-width width
:stroke-opacity main-opacity}}])
(when hover?
[:line {:x1 l3-x1
:y1 l3-y1
:x2 l3-x2
:y2 l3-y2
:style {:stroke color
:stroke-opacity guide-opacity-hover
:stroke-width width
:stroke-dasharray dash
:stroke-linecap "round"}}])])
(when show-main?
(let [{:keys [x1 y1 x2 y2]} (guide-line-axis pos vbox axis)]
[:line {:x1 x1
:y1 y1
:x2 x2
:y2 y2
:style {:stroke color
:stroke-width width
:stroke-opacity main-opacity}}])))))
(mf/defc guide*
{::mf/wrap [mf/memo]}
[{:keys [guide is-hover on-guide-change get-hover-frame vbox zoom
@ -341,12 +502,6 @@
pos
(+ (or (:new-position @state) (:position guide)) (get move-vec axis))
guide-width
(/ guide-width zoom)
guide-pill-corner-radius
(/ guide-pill-corner-radius zoom)
frame-guide-outside?
(and (some? frame)
(not (is-guide-inside-frame? (assoc guide :position pos) frame)))
@ -427,106 +582,28 @@
:on-context-menu on-context-menu
:on-double-click on-double-click}]))
(if (some? frame)
(let [{:keys [l1-x1 l1-y1 l1-x2 l1-y2
l2-x1 l2-y1 l2-x2 l2-y2
l3-x1 l3-y1 l3-x2 l3-y2]}
(guide-line-axis pos vbox frame axis)]
[:g
(when (or is-hover (:hover @state))
[:line {:x1 l1-x1
:y1 l1-y1
:x2 l1-x2
:y2 l1-y2
:style {:stroke guide-color
:stroke-opacity guide-opacity-hover
:stroke-dasharray (str "0, " (/ 6 zoom))
:stroke-linecap "round"
:stroke-width guide-width}}])
[:line {:x1 l2-x1
:y1 l2-y1
:x2 l2-x2
:y2 l2-y2
:style {:stroke guide-color
:stroke-width guide-width
:stroke-opacity (if (or is-hover (:hover @state))
guide-opacity-hover
guide-opacity)}}]
(when (or is-hover (:hover @state))
[:line {:x1 l3-x1
:y1 l3-y1
:x2 l3-x2
:y2 l3-y2
:style {:stroke guide-color
:stroke-opacity guide-opacity-hover
:stroke-width guide-width
:stroke-dasharray (str "0, " (/ 6 zoom))
:stroke-linecap "round"}}])])
(let [{:keys [x1 y1 x2 y2]} (guide-line-axis pos vbox axis)]
[:line {:x1 x1
:y1 y1
:x2 x2
:y2 y2
:style {:stroke guide-color
:stroke-width guide-width
:stroke-opacity (if (or is-hover (:hover @state))
guide-opacity-hover
guide-opacity)}}]))
[:> guide-line* {:pos pos
:vbox vbox
:zoom zoom
:axis axis
:color guide-color
:frame frame
:hover? (or is-hover (:hover @state))}]
;; If the guide is associated to a frame we show the position relative
;; to the frame (handled via `frame-offset` inside `guide-pill*`).
(when (or is-hover (:hover @state) is-editing)
(let [{:keys [rect-x rect-y rect-width rect-height text-x text-y]}
(guide-pill-axis pos vbox zoom axis)
display-value (fmt/format-number (- pos frame-offset))
input-w (/ guide-pill-width zoom)
input-h (/ guide-pill-height zoom)]
[:g.guide-pill
[:rect {:x rect-x
:y rect-y
:width rect-width
:height rect-height
:rx guide-pill-corner-radius
:ry guide-pill-corner-radius
:style {:fill guide-color}
:on-double-click on-double-click}]
(if is-editing
[:foreignObject {:x (- text-x (/ input-w 2))
:y (- text-y (/ input-h 2))
:width input-w
:height input-h
:transform (when (= axis :y)
(str "rotate(-90 " text-x "," text-y ")"))}
[:input {:ref input-ref
:type "number"
:step "any"
:default-value display-value
:auto-focus true
:on-key-down on-input-key-down
:on-blur accept-editing
:on-pointer-down dom/stop-propagation
:style {:width "100%"
:height "100%"
:border "none"
:outline "none"
:padding 0
:margin 0
:background "transparent"
:color colors/white
:font-family rulers/font-family
:font-size (str (/ rulers/font-size zoom) "px")
:text-align "center"
:-moz-appearance "textfield"}}]]
[:text {:x text-x
:y text-y
:text-anchor "middle"
:dominant-baseline "middle"
:transform (when (= axis :y) (str "rotate(-90 " text-x "," text-y ")"))
:style {:font-size (/ rulers/font-size zoom)
:font-family rulers/font-family
:fill colors/white}}
;; If the guide is associated to a frame we show the position relative to the frame
display-value])]))])))
[:> guide-pill* {:pos pos
:vbox vbox
:zoom zoom
:axis axis
:color guide-color
:frame-offset frame-offset
:editing is-editing
:input-ref input-ref
:on-input-key-down on-input-key-down
:on-input-blur accept-editing
:on-double-click on-double-click}])])))
(mf/defc new-guide-area*
[{:keys [vbox zoom axis get-hover-frame disabled-guides]}]
@ -581,10 +658,407 @@
:is-hover true
:hover-frame frame}])]))
(defn- guide-by-serialized-index
"Maps a WASM guide index back to the guide map entry. `guides` must be the
same map passed to `set-guides` (typically `wasm-visible-guides`); index
order matches `write-guides` / `(vec (vals guides))`."
[guides index]
(when (>= index 0)
(nth (vec (vals guides)) index nil)))
(mf/defc guide-overlay*
"Temporary SVG rendering of a guide that's being interacted with (drag, hover
or inline edit). In :hover mode the WASM engine still draws the (in-frame)
line, so we only overlay the position pill plus, for frame-anchored guides,
the dotted out-of-frame extensions. Drag and edit hide the WASM line and draw
the full SVG line. In :edit mode the pill is an editable input."
[{:keys [guide position vbox zoom mode frame frame-offset
on-input-commit on-input-cancel]}]
(let [axis (:axis guide)
guide-color (or (:color guide) default-guide-color)
input-ref (mf/use-ref nil)
;; In :hover mode the WASM engine still renders the guide line, so we
;; only overlay the dotted extensions. Drag and edit hide the WASM line
;; and require the full SVG line.
show-line? (not= mode :hover)
show-pill? (or (= mode :edit) (= mode :hover))
editing? (= mode :edit)
on-key-down
(mf/use-fn
(mf/deps on-input-commit on-input-cancel)
(fn [event]
(cond
(kbd/enter? event)
(do (dom/prevent-default event)
(dom/stop-propagation event)
(on-input-commit (-> (mf/ref-val input-ref) dom/get-value)))
(kbd/esc? event)
(do (dom/prevent-default event)
(dom/stop-propagation event)
(on-input-cancel)))))
on-blur
(mf/use-fn
(mf/deps on-input-commit)
(fn []
(on-input-commit (-> (mf/ref-val input-ref) dom/get-value))))]
(mf/with-effect [mode]
(when editing?
(some-> (mf/ref-val input-ref) dom/select-text!)))
[:g.guide-overlay
;; Drag/edit: WASM hides its line, so draw the full SVG guide line.
(when show-line?
[:> guide-line* {:pos position
:vbox vbox
:zoom zoom
:axis axis
:color guide-color
:hover? true}])
;; Hover: WASM still draws the in-frame segment; only add the dotted
;; out-of-frame extensions for frame-anchored guides.
(when (and (= mode :hover) (some? frame))
[:> guide-line* {:pos position
:vbox vbox
:zoom zoom
:axis axis
:color guide-color
:frame frame
:hover? true
:show-main? false}])
(when show-pill?
[:> guide-pill* {:pos position
:vbox vbox
:zoom zoom
:axis axis
:color guide-color
:frame-offset frame-offset
:editing editing?
:input-ref input-ref
:on-input-key-down on-key-down
:on-input-blur on-blur}])]))
(defn use-wasm-guide-interaction
"Owns both drag and inline-edit lifecycles for WASM-rendered guides.
Returns a map with the live overlay `state` (`{:guide ... :new-position ...
:mode :drag|:edit ...}` or nil) plus callbacks the overlay needs in edit
mode: `commit-edit` (commits the parsed input value) and `cancel-edit`
(drops the edit without committing)."
[{:keys [wasm-guides zoom wasm-guides? disabled-guides? on-guide-change
on-guide-drag on-guide-hover get-hover-frame focus]}]
(let [dragging-ref (mf/use-ref false)
moved-ref (mf/use-ref false)
start-ref (mf/use-ref nil)
guide-ref (mf/use-ref nil)
pending-ref (mf/use-ref nil)
drag-listeners-ref (mf/use-ref nil)
hover-axis-ref (mf/use-ref nil)
hover-guide-id-ref (mf/use-ref nil)
state (mf/use-state nil)
snap-pixel?
(mf/deref refs/snap-pixel?)
read-only?
(mf/use-ctx ctx/workspace-read-only?)
;; The handlers are defined here so they close directly over the refs and
;; the current render's props (guides, zoom, ...). The pointerdown /
;; dblclick listeners are re-registered by the effect below whenever those
;; props change, so they always see fresh values.
remove-drag-listeners
(fn []
(when-let [{:keys [on-move on-up]} (mf/ref-val drag-listeners-ref)]
(when-let [viewport @uwvv/viewport-ref]
(.removeEventListener viewport "pointermove" on-move true)
(.removeEventListener viewport "pointerup" on-up true)
(.removeEventListener viewport "pointercancel" on-up true))
(mf/set-ref-val! drag-listeners-ref nil)))
emit-hover-axis
(fn [axis]
(when (not= axis (mf/ref-val hover-axis-ref))
(mf/set-ref-val! hover-axis-ref axis)
(when (some? on-guide-hover)
(on-guide-hover axis))))
;; Mirrors what the SVG renderer does on pointer-enter / -leave:
;; populates `[:workspace-guides :hover]` so the Del / Backspace
;; shortcut (`dw/delete-selected`) can remove the hovered guide.
emit-hover-guide-id
(fn [id]
(let [prev (mf/ref-val hover-guide-id-ref)]
(when (not= id prev)
(mf/set-ref-val! hover-guide-id-ref id)
(when prev
(st/emit! (dw/set-hover-guide prev false)))
(when id
(st/emit! (dw/set-hover-guide id true))))))
clear-drag-refs
(fn []
(remove-drag-listeners)
(mf/set-ref-val! dragging-ref false)
(mf/set-ref-val! moved-ref false)
(mf/set-ref-val! start-ref nil)
(mf/set-ref-val! guide-ref nil)
(mf/set-ref-val! pending-ref nil))
reset-state
(fn []
(clear-drag-refs)
(when (some? on-guide-drag)
(on-guide-drag nil))
(emit-hover-axis nil)
(emit-hover-guide-id nil)
(reset! state nil))
finish-drag
(fn [event]
(when (mf/ref-val dragging-ref)
(let [moved? (mf/ref-val moved-ref)]
(when (and moved? (some? on-guide-change))
(when-let [{:keys [guide new-position new-frame-id]}
(mf/ref-val pending-ref)]
(when (and (some? guide) (some? new-position))
(on-guide-change (assoc guide
:position new-position
:frame-id new-frame-id)))))
(when-let [viewport @uwvv/viewport-ref]
(when (.-pointerId event)
(.releasePointerCapture viewport (.-pointerId event))))
;; A click without movement (no drag): leave the hover state
;; alone so a follow-up double-click can transition straight
;; from :hover to :edit without flickering through nil.
(if moved?
(reset-state)
(clear-drag-refs)))))
drag-move
(fn [move-event]
(when (mf/ref-val dragging-ref)
(when-let [guide (mf/ref-val guide-ref)]
(let [start-pt (mf/ref-val start-ref)
current-pt (dom/get-client-position move-event)
already-moved? (mf/ref-val moved-ref)
past-threshold?
(or already-moved?
(> (+ (mth/abs (- (:x current-pt) (:x start-pt)))
(mth/abs (- (:y current-pt) (:y start-pt))))
guide-drag-threshold))]
(when past-threshold?
(let [axis (:axis guide)
new-position (compute-guide-drag-position
{:axis axis
:position (:position guide)
:start-pt start-pt
:current-pt current-pt
:zoom zoom
:snap-pixel? snap-pixel?})
new-frame-id (-> (get-hover-frame) (get :id))
pending {:guide guide
:new-position new-position
:new-frame-id new-frame-id
:mode :drag}]
(when-not already-moved?
(mf/set-ref-val! moved-ref true)
(when (some? on-guide-drag)
(on-guide-drag (:id guide))))
(mf/set-ref-val! pending-ref pending)
(reset! state pending)))))))
editing?
(fn [] (= :edit (:mode @state)))
guide-at-event
(fn [event]
(when-let [pt (uwvv/point->viewport (dom/get-client-position event))]
(guide-by-serialized-index wasm-guides (wasm.api/find-guide-at pt zoom))))
visible-guide-at-event
(fn [event]
(when-let [guide (guide-at-event event)]
(when (guide-visible-in-focus? focus (:frame-id guide))
guide)))
guide-frame-offset
(fn [guide]
(let [frame (some-> (:frame-id guide) refs/object-by-id deref)]
(if frame
(if (= :x (:axis guide)) (:x frame) (:y frame))
0)))
pointer-move-hover
(fn [event]
;; Only update hover cursor / pill when we are not in the middle of
;; a drag or edit. During drag the cursor is already set to the
;; dragged guide's axis; during edit the input owns the cursor.
(when (and (not read-only?)
(not (editing?))
(not (mf/ref-val dragging-ref)))
(let [guide (visible-guide-at-event event)
current-state @state
current-hover-id (when (= :hover (:mode current-state))
(-> current-state :guide :id))]
(emit-hover-axis (:axis guide))
(emit-hover-guide-id (:id guide))
(cond
(and (some? guide)
(not= (:id guide) current-hover-id))
(let [frame (some-> (:frame-id guide) refs/object-by-id deref)]
(reset! state {:guide guide
:new-position (:position guide)
:frame-offset (guide-frame-offset guide)
;; Only root, non-rotated frames get the
;; segmented line (matching the SVG renderer),
;; so we only overlay dotted extensions there.
:frame (when (and frame
(cfh/root-frame? frame)
(not (ctst/rotated-frame? frame)))
frame)
:mode :hover}))
(and (nil? guide) (some? current-hover-id))
(reset! state nil)))))
pointer-down
(fn [event]
(when (and (not read-only?) (not (editing?)))
;; While editing, any click outside the input commits the edit
;; via the input's blur handler. Don't initiate a drag on the
;; same pointerdown.
(when (= 0 (.-button event))
(let [client-pos (dom/get-client-position event)
guide (visible-guide-at-event event)
{:keys [id axis position frame-id]} guide]
(when guide
(when-let [viewport @uwvv/viewport-ref]
(.setPointerCapture viewport (.-pointerId event)))
(dom/stop-propagation event)
(emit-hover-axis axis)
(emit-hover-guide-id id)
(mf/set-ref-val! dragging-ref true)
(mf/set-ref-val! moved-ref false)
(mf/set-ref-val! start-ref client-pos)
(mf/set-ref-val! guide-ref guide)
(mf/set-ref-val! pending-ref
{:guide guide
:new-position position
:new-frame-id frame-id
:mode :drag})
;; Pointer capture (above) routes all subsequent pointer
;; events to the viewport, so we listen on the viewport
;; itself rather than window. This keeps events flowing
;; even outside the browser window.
(when-let [viewport @uwvv/viewport-ref]
(let [on-move #(drag-move %)
on-up #(finish-drag %)]
(mf/set-ref-val! drag-listeners-ref
{:on-move on-move :on-up on-up})
(.addEventListener viewport "pointermove" on-move true)
(.addEventListener viewport "pointerup" on-up true)
(.addEventListener viewport "pointercancel" on-up true))))))))
double-click
(fn [event]
(when (and (not read-only?) (not (editing?)))
(let [guide (visible-guide-at-event event)
{:keys [id axis position frame-id]} guide]
(when guide
(dom/prevent-default event)
(dom/stop-propagation event)
(when (some? on-guide-drag)
(on-guide-drag id))
(mf/set-ref-val! guide-ref guide)
(let [frame (some-> frame-id refs/object-by-id deref)
offset (if frame
(if (= :x axis) (:x frame) (:y frame))
0)]
(reset! state {:guide guide
:new-position position
:new-frame-id frame-id
:frame-offset offset
:mode :edit}))))))
commit-edit
(fn [raw-value]
(when (editing?)
(let [{:keys [guide new-frame-id frame-offset]} @state
parsed (some-> raw-value str/trim d/parse-double)]
(when (and (some? parsed) (some? on-guide-change))
(on-guide-change (assoc guide
:position (+ parsed frame-offset)
:frame-id new-frame-id)))
(reset-state))))
cancel-edit
(fn []
(when (editing?)
(reset-state)))]
(mf/with-effect [wasm-guides? disabled-guides? read-only?
wasm-guides zoom focus snap-pixel?
on-guide-change on-guide-drag on-guide-hover get-hover-frame]
(when (and wasm-guides? (not disabled-guides?) (not read-only?))
(when-let [viewport @uwvv/viewport-ref]
(.addEventListener viewport "pointerdown" pointer-down true)
(.addEventListener viewport "pointermove" pointer-move-hover true)
(.addEventListener viewport "dblclick" double-click true)
(fn []
(.removeEventListener viewport "pointerdown" pointer-down true)
(.removeEventListener viewport "pointermove" pointer-move-hover true)
(.removeEventListener viewport "dblclick" double-click true)
;; Only tear down state on real teardown. If this cleanup is
;; triggered by a dependency change mid-interaction, leave the
;; active drag/edit (and its listeners) untouched so it can
;; finish.
(when-not (or (mf/ref-val dragging-ref) (editing?))
(reset-state))))))
{:state state
:commit-edit commit-edit
:cancel-edit cancel-edit}))
(mf/defc wasm-guide-overlay-layer*
"Owns WASM guide drag/edit state and overlay rendering so updates are not
blocked by memoization on `viewport-guides*`."
[{:keys [wasm-guides zoom wasm-guides? disabled-guides? on-guide-change
on-guide-drag on-guide-hover get-hover-frame focus vbox]}]
(let [{:keys [state commit-edit cancel-edit]}
(use-wasm-guide-interaction {:wasm-guides wasm-guides
:zoom zoom
:wasm-guides? wasm-guides?
:disabled-guides? disabled-guides?
:on-guide-change on-guide-change
:on-guide-drag on-guide-drag
:on-guide-hover on-guide-hover
:get-hover-frame get-hover-frame
:focus focus})
{:keys [guide new-position mode frame frame-offset]} @state]
(when (some? guide)
[:> guide-overlay* {:guide guide
:position new-position
:vbox vbox
:zoom zoom
:mode mode
:frame frame
:frame-offset (or frame-offset 0)
:on-input-commit commit-edit
:on-input-cancel cancel-edit}])))
(mf/defc viewport-guides*
{::mf/wrap [mf/memo]}
[{:keys [zoom vbox hover-frame disabled-guides modifiers guides]}]
(let [guides
[{:keys [zoom vbox hover-frame disabled-guides modifiers guides wasm-guides
wasm-guides? on-guide-drag on-guide-hover]}]
(let [visible-guides
(mf/with-memo [guides vbox]
(->> (vals guides)
(filter (partial guide-inside-vbox? zoom vbox))))
@ -616,6 +1090,23 @@
(st/emit! (dw/show-guide-context-menu {:position position
:guide guide})))))
;; When guides are WASM-rendered, right-click hit testing is delegated to
;; the render engine instead of per-guide SVG areas.
on-wasm-context-menu
(mf/use-fn
(mf/deps wasm-guides zoom disabled-guides)
(fn [event]
(when-not disabled-guides
(let [position (dom/get-client-position event)
pt (uwvv/point->viewport position)
index (when pt (wasm.api/find-guide-at pt zoom))
guide (guide-by-serialized-index wasm-guides index)]
(when guide
(dom/prevent-default event)
(dom/stop-propagation event)
(st/emit! (dw/show-guide-context-menu {:position position
:guide guide})))))))
frame-modifiers
(-> (group-by :id modifiers)
(update-vals (comp :transform first)))]
@ -623,6 +1114,12 @@
(mf/with-effect [hover-frame]
(mf/set-ref-val! hover-frame-ref hover-frame))
(mf/with-effect [wasm-guides? disabled-guides on-wasm-context-menu]
(when (and wasm-guides? (not disabled-guides))
(when-let [viewport @uwvv/viewport-ref]
(.addEventListener viewport "contextmenu" on-wasm-context-menu true)
#(.removeEventListener viewport "contextmenu" on-wasm-context-menu true))))
[:g.guides {:pointer-events "none"}
[:> new-guide-area* {:vbox vbox
:zoom zoom
@ -636,16 +1133,27 @@
:get-hover-frame get-hover-frame
:disabled-guides disabled-guides}]
(for [{:keys [id frame-id] :as guide} guides]
(when (or (nil? frame-id)
(empty? focus)
(contains? focus frame-id))
[:> guide* {:key (dm/str "guide-" id)
:guide guide
:vbox vbox
:zoom zoom
:frame-transform (get frame-modifiers frame-id)
:get-hover-frame get-hover-frame
:on-guide-change on-guide-change
:on-guide-context-menu on-guide-context-menu
:disabled-guides disabled-guides}]))]))
(when wasm-guides?
[:> wasm-guide-overlay-layer* {:wasm-guides wasm-guides
:zoom zoom
:wasm-guides? wasm-guides?
:disabled-guides? disabled-guides
:on-guide-change on-guide-change
:on-guide-drag on-guide-drag
:on-guide-hover on-guide-hover
:get-hover-frame get-hover-frame
:focus focus
:vbox vbox}])
(when-not wasm-guides?
(for [{:keys [id frame-id] :as guide} visible-guides]
(when (guide-visible-in-focus? focus frame-id)
[:> guide* {:key (dm/str "guide-" id)
:guide guide
:vbox vbox
:zoom zoom
:frame-transform (get frame-modifiers frame-id)
:get-hover-frame get-hover-frame
:on-guide-change on-guide-change
:on-guide-context-menu on-guide-context-menu
:disabled-guides disabled-guides}])))]))

View File

@ -181,6 +181,16 @@
active-frames (mf/use-state #{})
canvas-init? (mf/use-state false)
initialized? (mf/use-state false)
dragging-guide-id* (mf/use-state nil)
guide-hover-axis* (mf/use-state nil)
on-guide-drag
(mf/use-fn
#(reset! dragging-guide-id* %))
on-guide-hover
(mf/use-fn
#(reset! guide-hover-axis* %))
;; REFS
[viewport-ref
@ -342,6 +352,14 @@
disabled-guides? (or drawing-tool transform path-drawing? path-editing?
(contains? layout :lock-guides))
wasm-guides
(mf/with-memo [guides focus show-rulers? show-grids? @dragging-guide-id*]
(guides/wasm-visible-guides
{:guides guides
:visible? (and show-rulers? show-grids?)
:focused focus
:dragging-id @dragging-guide-id*}))
single-select? (= (count selected-shapes) 1)
first-shape (first selected-shapes)
@ -538,6 +556,13 @@
(when @canvas-init?
(wasm.api/render-ui-only)))
;; Ruler guides: push the WASM-visible guide set to the render engine.
;; `wasm-guides` is also passed to the SVG overlay for index-based hit
;; testing — it must stay in sync with what we serialize here.
(mf/with-effect [@canvas-init? wasm-guides objects]
(when @canvas-init?
(wasm.api/set-guides wasm-guides objects)))
(hooks/setup-dom-events zoom disable-paste-ref in-viewport-ref read-only? drawing-tool path-drawing?)
(hooks/setup-viewport-size vport viewport-ref)
(hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? z? read-only?)
@ -622,7 +647,12 @@
:id "viewport-controls"
:view-box (utils/format-viewbox vbox)
:ref on-viewport-ref
:class (dm/str @cursor (when drawing-tool " drawing") " " (stl/css :viewport-controls))
:class (dm/str @cursor " "
(stl/css-case
:global/drawing drawing-tool
:global/cursor-resize-ew-0 (= @guide-hover-axis* :x)
:global/cursor-resize-ns-0 (= @guide-hover-axis* :y)
:viewport-controls true))
:style {:touch-action "none"}
:fill "none"
:on-click on-click
@ -824,14 +854,21 @@
[:& presence/active-cursors
{:page-id page-id}])
;; NOTE: ruler guides are being migrated to the WASM render engine.
;; The SVG-overlay rendering is temporarily disabled while we implement
;; the new path.
(when (and show-rulers? show-grids?)
[:> guides/viewport-guides*
{:zoom zoom
:vbox vbox
:guides guides
:wasm-guides wasm-guides
:wasm-guides? true
:hover-frame guide-frame
:disabled-guides disabled-guides?
:modifiers wasm-modifiers}])
:modifiers wasm-modifiers
:on-guide-drag on-guide-drag
:on-guide-hover on-guide-hover}])
;; DEBUG LAYOUT DROP-ZONES
(when (dbg/enabled? :layout-drop-zones)

View File

@ -2242,6 +2242,35 @@
(h/call wasm/internal-module "_hide_grid")
(request-render "clear-grid"))
;; Ruler guides ----------------------------------------------------------------
(defn set-guides
"Serializes the page guides and sends them to the render engine.
`guides` is the page `:guides` map (id -> guide); `objects` is the page
objects map, used to resolve each guide's board clip range."
[guides objects]
(let [size (sr/get-guides-byte-size guides)
offset (mem/alloc->offset-32 size)
heapu32 (mem/get-heap-u32)
heapf32 (mem/get-heap-f32)]
(sr/write-guides guides objects heapu32 heapf32 offset)
(h/call wasm/internal-module "_set_guides")
(request-render "set-guides")))
;; Screen-space hit tolerance for ruler guides. Must match
;; `guide-active-area` in `app.main.ui.workspace.viewport.guides`.
(def ^:private guide-active-area 16)
(defn find-guide-at
"Returns the serialized guide index at `position` (viewport coordinates),
or -1 when no guide is within the hit tolerance."
[position zoom]
(h/call wasm/internal-module "_find_guide_at"
(:x position)
(:y position)
zoom
guide-active-area))
(defn get-grid-coords
[position]
(let [offset (h/call wasm/internal-module

View File

@ -8,7 +8,11 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.types.color :as clr]
[app.common.types.shape-tree :as ctst]
[app.common.uuid :as uuid]
[app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.wasm :as wasm]
[cuerdas.core :as str]))
@ -281,3 +285,76 @@
(let [values (unchecked-get wasm/serializers "transform-entry-kind")
default (unchecked-get values "parent")]
(d/nilv (unchecked-get values (d/name kind)) default)))
;; --- Guides
;; Each guide is serialized as 5 x 32-bit words:
;; kind (u32) | color (u32 argb) | position (f32) | frame-start (f32) | frame-end (f32)
;; `frame-start`/`frame-end` hold the board clip range (along the guide's line
;; direction); they are NaN when the guide is not bound to a board.
(def ^:private guide-entry-size 20)
;; Default guide color used when a guide has no explicit color (matches the
;; previous SVG overlay `default-guide-color`).
(def ^:private default-guide-color clr/new-danger)
;; Sentinel clip range for a guide whose board is rotated or not a root frame.
;; The engine clips each guide to `[start end]` and skips drawing when `start >
;; end`; both bounds at +Infinity collapse to an empty range, so the guide is
;; hidden (matching the SVG renderer, which drops it from the DOM).
;; We hide via the range rather than by removing the guide from the set so the
;; guide count stays stable and board rendering is unaffected.
(def ^:private guide-hidden-range [js/Infinity js/Infinity])
(defn- translate-guide-axis
"Maps a guide axis to the RawGuideKind discriminant expected by WASM.
`:x` (constant x) is a vertical guide, `:y` is a horizontal one."
[axis]
(let [values (unchecked-get wasm/serializers "guide-kind")
default (unchecked-get values "vertical")]
(case axis
:x (unchecked-get values "vertical")
:y (unchecked-get values "horizontal")
default)))
(defn get-guides-byte-size
"Total heap size (in bytes) needed to serialize `guides` (a map id -> guide),
including the 4-byte header that holds the guide count."
[guides]
(+ 4 (* (count (or guides {})) guide-entry-size)))
(defn- guide-frame-range
"Returns the `[start end]` clip range (along the guide's line direction) for a
board-bound guide: the board's y-range for vertical `:x` guides, its x-range
for horizontal `:y` guides. Returns nil for free guides (drawn full-length).
A guide bound to a rotated or non-root board returns `guide-hidden-range`, an
empty range the engine clips out, hiding it like the SVG renderer does."
[guide objects]
(when-let [frame (some->> (get guide :frame-id) (get objects))]
(if (and (cfh/root-frame? frame)
(not (ctst/rotated-frame? frame)))
(if (= :x (get guide :axis))
[(:y frame) (+ (:y frame) (:height frame))]
[(:x frame) (+ (:x frame) (:width frame))])
guide-hidden-range)))
(defn write-guides
"Writes `guides` (a map id -> guide) into the heap views starting at the
32-bit `offset`. Layout: count header (u32) followed by
`kind | color | position | frame-start | frame-end` per guide. The frame
range is resolved from `objects` and written as NaN for free guides."
[guides objects heapu32 heapf32 offset]
(let [guides (vec (vals (or guides {})))
total (count guides)]
(aset heapu32 offset total)
(loop [i 0]
(when (< i total)
(let [guide (nth guides i)
base (+ offset 1 (* i 5))
[frame-start frame-end] (guide-frame-range guide objects)]
(aset heapu32 base (translate-guide-axis (get guide :axis)))
(aset heapu32 (+ base 1) (sr-clr/hex->u32argb (or (get guide :color) default-guide-color) 1))
(aset heapf32 (+ base 2) (get guide :position))
(aset heapf32 (+ base 3) (or frame-start js/NaN))
(aset heapf32 (+ base 4) (or frame-end js/NaN)))
(recur (inc i))))))

View File

@ -62,6 +62,7 @@
:wrap-type shared/RawWrapType
:grid-track-type shared/RawGridTrackType
:shadow-style shared/RawShadowStyle
:guide-kind shared/RawGuideKind
:stroke-style shared/RawStrokeStyle
:stroke-cap shared/RawStrokeCap
:shape-type shared/RawShapeType

View File

@ -5,7 +5,7 @@ use crate::emscripten::init_gl;
use crate::mem;
use crate::render::{gpu_state::GpuState, RenderState};
use crate::state::{State, TextEditorState};
use crate::state::{State, TextEditorState, UIState};
static mut DESIGN_STATE: *mut State = std::ptr::null_mut();
@ -50,6 +50,17 @@ pub(crate) fn get_text_editor_state() -> &'static mut TextEditorState {
}
}
/// UI State
static mut UI_STATE: *mut UIState = std::ptr::null_mut();
#[inline(always)]
pub(crate) fn get_ui_state() -> &'static mut UIState {
unsafe {
debug_assert!(!UI_STATE.is_null(), "UI State is null");
&mut *UI_STATE
}
}
// FIXME: These with_state* macros should be using our CriticalError instead of expect.
// But to do that, we need to not use them at domain-level (i.e. in business logic), just
// in the context of the wasm call.
@ -118,6 +129,14 @@ fn text_editor_init() {
}
}
/// Initializes UIState.
fn ui_init() {
unsafe {
let ui_state = UIState::new();
UI_STATE = Box::into_raw(Box::new(ui_state));
}
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn init(width: i32, height: i32) -> Result<()> {
@ -127,6 +146,7 @@ pub extern "C" fn init(width: i32, height: i32) -> Result<()> {
render_init(width, height);
text_editor_init();
design_init();
ui_init();
Ok(())
}

View File

@ -9,6 +9,7 @@ mod render;
mod shapes;
mod state;
mod tiles;
mod ui;
mod utils;
mod uuid;
mod view;

View File

@ -865,8 +865,10 @@ impl RenderState {
self.surfaces.clear_target(skia::Color::TRANSPARENT);
self.surfaces.copy_backbuffer_to_target_replace();
} else {
self.surfaces.copy_backbuffer_to_target();
self.surfaces
.copy_backbuffer_to_target(self.background_color);
}
if self.options.is_debug_visible() {
debug::render(self);
}

View File

@ -854,10 +854,17 @@ impl Surfaces {
/// Copy the current `Backbuffer` contents into `Target`.
/// This is a GPU→GPU copy via Skia (no ReadPixels).
pub fn copy_backbuffer_to_target(&mut self) {
///
/// `Target` is cleared to `background` first so UI overlay pixels (guides,
/// grid) from the previous frame are fully erased. Without this, `SrcOver`
/// compositing would keep stale overlay pixels wherever the backbuffer is
/// transparent.
pub fn copy_backbuffer_to_target(&mut self, background: skia::Color) {
let sampling_options = self.sampling_options;
let canvas = self.target.canvas();
canvas.clear(background);
self.backbuffer.draw(
self.target.canvas(),
canvas,
(0.0, 0.0),
sampling_options,
Some(&skia::Paint::default()),

View File

@ -1,8 +1,10 @@
use skia_safe::{self as skia, Color4f};
use super::{RenderState, ShapesPoolRef, SurfaceId};
use crate::globals::get_ui_state;
use crate::render::{grid_layout, rulers};
use crate::shapes::{Layout, Type};
pub mod guides;
pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) {
let canvas = render_state.surfaces.canvas(SurfaceId::UI);
@ -63,6 +65,16 @@ pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) {
let viewbox = render_state.viewbox;
let ruler_state = render_state.rulers;
rulers::render(canvas, viewbox, &render_state.fonts, &ruler_state);
// TODO: pass guides data here
let (horizontal, vertical) = get_ui_state().guides();
guides::render(
canvas,
zoom,
render_state.options.dpr,
viewbox.area,
horizontal,
vertical,
);
canvas.restore();

View File

@ -0,0 +1,65 @@
use skia_safe::{self as skia};
use crate::math::Rect;
use crate::ui::{Guide, GuideKind};
/// Renders the ruler guides overlay using the guides provided by the host
/// (ClojureScript) and stored in the render state.
pub fn render(
canvas: &skia::Canvas,
zoom: f32,
dpr: f32,
area: Rect,
horizontal: &[Guide],
vertical: &[Guide],
) {
for guide in horizontal {
render_guide(canvas, zoom, dpr, area, *guide);
}
for guide in vertical {
render_guide(canvas, zoom, dpr, area, *guide);
}
}
pub fn render_guide(canvas: &skia::Canvas, zoom: f32, dpr: f32, area: Rect, guide: Guide) {
let mut paint = skia::Paint::default();
paint.set_style(skia::PaintStyle::Stroke);
paint.set_color(Into::<skia::Color>::into(guide.color));
paint.set_alpha((0.7 * 255.0) as u8);
paint.set_stroke_width(1.0 * dpr / zoom);
// we disable antialias so the guides do not appear faint or blurry.
paint.set_anti_alias(false);
// The guide line spans the whole viewport, but when it belongs to a board
// the solid part is clipped to that board's range (along the line
// direction). The trimmed-out parts are not drawn here; the hover/drag
// dashed decorations are rendered by the SVG overlay instead.
let (full_start, full_end) = match guide.kind {
GuideKind::Vertical(_) => (area.top, area.bottom),
GuideKind::Horizontal(_) => (area.left, area.right),
};
let (start, end) = match guide.frame_range {
Some((frame_start, frame_end)) => {
let (lo, hi) = if frame_start <= frame_end {
(frame_start, frame_end)
} else {
(frame_end, frame_start)
};
(lo.max(full_start), hi.min(full_end))
}
None => (full_start, full_end),
};
// The clipped range can fall entirely outside the viewport.
if start > end {
return;
}
let (x1, y1, x2, y2) = match guide.kind {
GuideKind::Vertical(x) => (x, start, x, end),
GuideKind::Horizontal(y) => (start, y, end, y),
};
canvas.draw_line((x1, y1), (x2, y2), &paint);
}

View File

@ -4,9 +4,11 @@ use std::collections::HashMap;
mod rulers;
mod shapes_pool;
mod text_editor;
mod ui;
pub use rulers::RulerState;
pub use shapes_pool::{ShapesPool, ShapesPoolMutRef, ShapesPoolRef};
pub use text_editor::*;
pub use ui::UIState;
use crate::error::{Error, Result};
use crate::render::FrameType;

210
render-wasm/src/state/ui.rs Normal file
View File

@ -0,0 +1,210 @@
use crate::ui::{Guide, GuideKind};
pub struct GuidePool {
horizontal: Vec<Guide>,
vertical: Vec<Guide>,
}
impl GuidePool {
pub fn new() -> Self {
Self {
horizontal: Vec::new(),
vertical: Vec::new(),
}
}
pub fn set(&mut self, guides: Vec<Guide>) {
self.horizontal.clear();
self.vertical.clear();
for guide in guides {
match guide.kind {
GuideKind::Vertical(_) => self.vertical.push(guide),
GuideKind::Horizontal(_) => self.horizontal.push(guide),
}
}
self.horizontal
.sort_by(|a, b| a.position().total_cmp(&b.position()));
self.vertical
.sort_by(|a, b| a.position().total_cmp(&b.position()));
}
pub fn find_at(&self, x: f32, y: f32, zoom: f32, tolerance: f32) -> Option<&Guide> {
if zoom <= 0.0 || tolerance < 0.0 {
return None;
}
let world_tolerance = tolerance / zoom;
let vertical = Self::find_closest_in_axis(&self.vertical, x, world_tolerance);
let horizontal = Self::find_closest_in_axis(&self.horizontal, y, world_tolerance);
match (vertical, horizontal) {
(Some(v), Some(h)) => {
let v_dist = (v.position() - x).abs();
let h_dist = (h.position() - y).abs();
if v_dist <= h_dist {
Some(v)
} else {
Some(h)
}
}
(v, h) => v.or(h),
}
}
fn find_closest_in_axis(guides: &[Guide], coord: f32, world_tolerance: f32) -> Option<&Guide> {
if guides.is_empty() {
return None;
}
// NOTE: `partition_point` is a binary search, so this is O(log n)
let idx = guides.partition_point(|guide| guide.position() < coord);
let mut closest: Option<&Guide> = None;
let mut closest_dist = world_tolerance;
for candidate_idx in [idx.wrapping_sub(1), idx] {
if candidate_idx < guides.len() {
let guide = &guides[candidate_idx];
let dist = (guide.position() - coord).abs();
if dist <= world_tolerance && dist <= closest_dist {
closest_dist = dist;
closest = Some(guide);
}
}
}
closest
}
}
pub struct UIState {
guides: GuidePool,
// TODO: show grid, rulers, etc.
}
impl UIState {
pub fn new() -> Self {
Self {
guides: GuidePool::new(),
}
}
pub fn guides(&self) -> (&Vec<Guide>, &Vec<Guide>) {
(&self.guides.horizontal, &self.guides.vertical)
}
pub fn set_guides(&mut self, guides: Vec<Guide>) {
self.guides.set(guides);
}
pub fn find_guide_at(&self, x: f32, y: f32, zoom: f32, tolerance: f32) -> Option<&Guide> {
self.guides.find_at(x, y, zoom, tolerance)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shapes::Color;
fn vertical_guide(position: f32, index: usize) -> Guide {
Guide::new(
GuideKind::Vertical(position),
Color::BLACK,
Some(index),
None,
)
}
fn horizontal_guide(position: f32, index: usize) -> Guide {
Guide::new(
GuideKind::Horizontal(position),
Color::BLACK,
Some(index),
None,
)
}
fn pool_with(guides: Vec<Guide>) -> GuidePool {
let mut pool = GuidePool::new();
pool.set(guides);
pool
}
#[test]
fn set_replaces_existing_guides() {
let mut pool = pool_with(vec![vertical_guide(100.0, 0)]);
pool.set(vec![vertical_guide(200.0, 0)]);
assert_eq!(pool.vertical.len(), 1);
assert_eq!(pool.vertical[0].kind, GuideKind::Vertical(200.0));
assert!(pool.horizontal.is_empty());
}
#[test]
fn set_drops_removed_guides() {
let mut pool = pool_with(vec![
vertical_guide(100.0, 0),
vertical_guide(200.0, 1),
horizontal_guide(300.0, 2),
]);
pool.set(vec![vertical_guide(100.0, 0), horizontal_guide(300.0, 1)]);
assert_eq!(pool.vertical.len(), 1);
assert_eq!(pool.horizontal.len(), 1);
assert_eq!(pool.vertical[0].kind, GuideKind::Vertical(100.0));
assert_eq!(pool.horizontal[0].kind, GuideKind::Horizontal(300.0));
}
#[test]
fn find_at_returns_none_when_no_guides() {
let pool = GuidePool::new();
assert!(pool.find_at(100.0, 100.0, 1.0, 8.0).is_none());
}
#[test]
fn find_at_finds_vertical_guide_within_tolerance() {
let pool = pool_with(vec![vertical_guide(100.0, 0)]);
let guide = pool.find_at(102.0, 50.0, 1.0, 8.0).unwrap();
assert_eq!(guide.index, 0);
assert_eq!(guide.kind, GuideKind::Vertical(100.0));
}
#[test]
fn find_at_misses_vertical_guide_outside_tolerance() {
let pool = pool_with(vec![vertical_guide(100.0, 0)]);
assert!(pool.find_at(110.0, 50.0, 1.0, 8.0).is_none());
}
#[test]
fn find_at_finds_horizontal_guide_within_tolerance() {
let pool = pool_with(vec![horizontal_guide(200.0, 1)]);
let guide = pool.find_at(50.0, 203.0, 1.0, 8.0).unwrap();
assert_eq!(guide.index, 1);
assert_eq!(guide.kind, GuideKind::Horizontal(200.0));
}
#[test]
fn find_at_picks_closest_vertical_guide() {
let pool = pool_with(vec![vertical_guide(100.0, 0), vertical_guide(105.0, 1)]);
let guide = pool.find_at(103.0, 0.0, 1.0, 8.0).unwrap();
assert_eq!(guide.index, 1);
}
#[test]
fn find_at_prefers_closer_guide_at_intersection() {
let pool = pool_with(vec![vertical_guide(102.0, 0), horizontal_guide(100.0, 1)]);
let guide = pool.find_at(100.0, 100.0, 1.0, 8.0).unwrap();
assert_eq!(guide.index, 1);
assert_eq!(guide.kind, GuideKind::Horizontal(100.0));
}
#[test]
fn find_at_scales_tolerance_with_zoom() {
let pool = pool_with(vec![vertical_guide(100.0, 0)]);
assert!(pool.find_at(104.0, 0.0, 2.0, 8.0).is_some());
assert!(pool.find_at(105.0, 0.0, 2.0, 8.0).is_none());
}
}

57
render-wasm/src/ui.rs Normal file
View File

@ -0,0 +1,57 @@
use std::cmp::Ordering;
use crate::shapes::Color;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum GuideKind {
Vertical(f32),
Horizontal(f32),
}
impl GuideKind {
fn value(self) -> f32 {
match self {
GuideKind::Vertical(x) => x,
GuideKind::Horizontal(y) => y,
}
}
}
impl PartialOrd for GuideKind {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.value().partial_cmp(&other.value())
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Guide {
pub kind: GuideKind,
pub color: Color,
/// Index of the guide in the guide list (clojure side)
pub index: usize,
/// When the guide belongs to a board, the `[start, end]` range (along the
/// guide's line direction) of that board. The guide is drawn solid only
/// within this range and trimmed outside it. `None` for free guides, which
/// span the whole viewport.
pub frame_range: Option<(f32, f32)>,
}
impl Guide {
pub fn new(
kind: GuideKind,
color: Color,
index: Option<usize>,
frame_range: Option<(f32, f32)>,
) -> Self {
Self {
kind,
color,
index: index.unwrap_or_default(),
frame_range,
}
}
pub fn position(&self) -> f32 {
self.kind.value()
}
}

View File

@ -13,3 +13,4 @@ pub mod svg_attrs;
pub mod text;
pub mod text_editor;
pub mod transforms;
pub mod ui;

124
render-wasm/src/wasm/ui.rs Normal file
View File

@ -0,0 +1,124 @@
use crate::mem;
use crate::with_state;
use crate::{
error::{Error, Result},
globals::{get_render_state, get_ui_state},
ui::{Guide, GuideKind},
};
use macros::{wasm_error, ToJs};
const RAW_GUIDE_SIZE: usize = std::mem::size_of::<RawGuide>();
#[repr(u8)]
#[derive(Debug, Clone, PartialEq, Copy, ToJs)]
pub enum RawGuideKind {
Vertical = 0,
Horizontal = 1,
}
impl From<u32> for RawGuideKind {
fn from(value: u32) -> Self {
match value {
1 => RawGuideKind::Horizontal,
_ => RawGuideKind::Vertical,
}
}
}
/// Flat, FFI-friendly representation of a guide.
///
/// The layout uses only 32-bit fields so it can be written from ClojureScript
/// straight into the `HEAPU32`/`HEAPF32` views without padding surprises.
///
/// `frame_start` / `frame_end` carry the board clip range (along the guide's
/// line direction). When the guide is not bound to a board they are `NaN`.
#[repr(C)]
#[repr(align(4))]
#[derive(Debug, Clone, PartialEq, Copy)]
pub struct RawGuide {
kind: u32,
color: u32,
position: f32,
frame_start: f32,
frame_end: f32,
}
impl From<RawGuide> for Guide {
fn from(value: RawGuide) -> Self {
let kind = match RawGuideKind::from(value.kind) {
RawGuideKind::Vertical => GuideKind::Vertical(value.position),
RawGuideKind::Horizontal => GuideKind::Horizontal(value.position),
};
let frame_range = if value.frame_start.is_nan() || value.frame_end.is_nan() {
None
} else {
Some((value.frame_start, value.frame_end))
};
Guide::new(kind, value.color.into(), None, frame_range)
}
}
impl From<[u8; RAW_GUIDE_SIZE]> for RawGuide {
fn from(bytes: [u8; RAW_GUIDE_SIZE]) -> Self {
unsafe { std::mem::transmute(bytes) }
}
}
impl TryFrom<&[u8]> for RawGuide {
type Error = Error;
fn try_from(bytes: &[u8]) -> Result<Self> {
let bytes: [u8; RAW_GUIDE_SIZE] = bytes
.try_into()
.map_err(|_| Error::CriticalError("Invalid guide data".to_string()))?;
Ok(RawGuide::from(bytes))
}
}
fn read_guides_from_bytes(buffer: &[u8], count: usize) -> Result<Vec<Guide>> {
buffer
.chunks_exact(RAW_GUIDE_SIZE)
.take(count)
.enumerate()
.map(|(i, bytes)| {
RawGuide::try_from(bytes).map(|raw_guide| {
let mut guide: Guide = raw_guide.into();
guide.index = i;
guide
})
})
.collect::<Result<Vec<Guide>>>()
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_guides() -> Result<()> {
let bytes = mem::bytes();
// The first 4 bytes are a header holding the number of guides.
let count = u32::from_le_bytes(
bytes
.get(0..4)
.and_then(|slice| slice.try_into().ok())
.unwrap_or([0; 4]),
) as usize;
let guides = read_guides_from_bytes(&bytes[4..], count)?;
get_ui_state().set_guides(guides);
mem::free_bytes()?;
// Guides are drawn on the UI overlay composited onto `Target`. Refresh the
// presented frame immediately so removed guides do not linger as stale pixels.
with_state!(state, {
get_render_state().present_frame(&state.shapes);
});
Ok(())
}
#[wasm_error]
#[no_mangle]
pub extern "C" fn find_guide_at(x: f32, y: f32, zoom: f32, tolerance: f32) -> Result<i32> {
Ok(get_ui_state()
.find_guide_at(x, y, zoom, tolerance)
.map(|guide| guide.index as i32)
.unwrap_or(-1))
}