mirror of
https://github.com/penpot/penpot.git
synced 2026-06-17 04:42:03 +00:00
🎉 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:
parent
b06942c668
commit
d1dd5d9016
@ -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}])))]))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))))))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ mod render;
|
||||
mod shapes;
|
||||
mod state;
|
||||
mod tiles;
|
||||
mod ui;
|
||||
mod utils;
|
||||
mod uuid;
|
||||
mod view;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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()),
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
65
render-wasm/src/render/ui/guides.rs
Normal file
65
render-wasm/src/render/ui/guides.rs
Normal 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);
|
||||
}
|
||||
@ -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
210
render-wasm/src/state/ui.rs
Normal 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
57
render-wasm/src/ui.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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
124
render-wasm/src/wasm/ui.rs
Normal 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))
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user