From d1dd5d9016de589f83701ed5291f03ddf454ccd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Tue, 16 Jun 2026 09:19:58 +0200 Subject: [PATCH] :tada: Render guides in wasm (#10014) * :sparkles: Remove guides from svg overlay * :tada: Draw guides in wasm * :tada: Serialize guides to wasm * :sparkles: Store separate and sorted horizontal and vertical guides * :tada: Implement collision detection with guides * :tada: Right click on guides to change color or remove * :sparkles: Implement dragging guides * :tada: Edit wasm guides by double clicking them * :tada: Implement changing mouse cursor on hovering a guide * :sparkles: Show guide pill on hover * :tada: Implement removing guide on hovering + Del * :wrench: Fix lint + fmt errors * :tada: Clip out outer board guide lines * :recycle: Extract common code into guide-pill* component * :tada: Draw dotted lines on hovering board guides * :bug: Fix board rotation when it has guides * :tada: Make foreign guides not visible in focus mode --- .../main/ui/workspace/viewport/guides.cljs | 764 +++++++++++++++--- .../app/main/ui/workspace/viewport_wasm.cljs | 41 +- frontend/src/app/render_wasm/api.cljs | 29 + frontend/src/app/render_wasm/serializers.cljs | 77 ++ frontend/src/app/render_wasm/wasm.cljs | 1 + render-wasm/src/globals.rs | 22 +- render-wasm/src/main.rs | 1 + render-wasm/src/render.rs | 4 +- render-wasm/src/render/surfaces.rs | 11 +- render-wasm/src/render/ui.rs | 12 + render-wasm/src/render/ui/guides.rs | 65 ++ render-wasm/src/state.rs | 2 + render-wasm/src/state/ui.rs | 210 +++++ render-wasm/src/ui.rs | 57 ++ render-wasm/src/wasm.rs | 1 + render-wasm/src/wasm/ui.rs | 124 +++ 16 files changed, 1287 insertions(+), 134 deletions(-) create mode 100644 render-wasm/src/render/ui/guides.rs create mode 100644 render-wasm/src/state/ui.rs create mode 100644 render-wasm/src/ui.rs create mode 100644 render-wasm/src/wasm/ui.rs diff --git a/frontend/src/app/main/ui/workspace/viewport/guides.cljs b/frontend/src/app/main/ui/workspace/viewport/guides.cljs index 134f610c1c..d62ca3cf6b 100644 --- a/frontend/src/app/main/ui/workspace/viewport/guides.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/guides.cljs @@ -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}])))])) diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 2972b58ead..3a75783a48 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -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) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 03b399c02f..d804dc0b85 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -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 diff --git a/frontend/src/app/render_wasm/serializers.cljs b/frontend/src/app/render_wasm/serializers.cljs index 4ce4c77400..f30f12d81c 100644 --- a/frontend/src/app/render_wasm/serializers.cljs +++ b/frontend/src/app/render_wasm/serializers.cljs @@ -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)))))) diff --git a/frontend/src/app/render_wasm/wasm.cljs b/frontend/src/app/render_wasm/wasm.cljs index caf3d3a15a..2864c5f7fb 100644 --- a/frontend/src/app/render_wasm/wasm.cljs +++ b/frontend/src/app/render_wasm/wasm.cljs @@ -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 diff --git a/render-wasm/src/globals.rs b/render-wasm/src/globals.rs index 0c9008c005..64e7d8c94f 100644 --- a/render-wasm/src/globals.rs +++ b/render-wasm/src/globals.rs @@ -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(()) } diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index ddd30fe356..2f7503d181 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -9,6 +9,7 @@ mod render; mod shapes; mod state; mod tiles; +mod ui; mod utils; mod uuid; mod view; diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 8d59339b65..152de04143 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -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); } diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 867b657c7b..be7448bdf1 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -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()), diff --git a/render-wasm/src/render/ui.rs b/render-wasm/src/render/ui.rs index 2d48cdda6d..3b69955a82 100644 --- a/render-wasm/src/render/ui.rs +++ b/render-wasm/src/render/ui.rs @@ -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(); diff --git a/render-wasm/src/render/ui/guides.rs b/render-wasm/src/render/ui/guides.rs new file mode 100644 index 0000000000..07bc5a6f8a --- /dev/null +++ b/render-wasm/src/render/ui/guides.rs @@ -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::::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); +} diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 9ca681841d..aa2fbd8d78 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -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; diff --git a/render-wasm/src/state/ui.rs b/render-wasm/src/state/ui.rs new file mode 100644 index 0000000000..607dc3117b --- /dev/null +++ b/render-wasm/src/state/ui.rs @@ -0,0 +1,210 @@ +use crate::ui::{Guide, GuideKind}; + +pub struct GuidePool { + horizontal: Vec, + vertical: Vec, +} + +impl GuidePool { + pub fn new() -> Self { + Self { + horizontal: Vec::new(), + vertical: Vec::new(), + } + } + + pub fn set(&mut self, guides: Vec) { + 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, &Vec) { + (&self.guides.horizontal, &self.guides.vertical) + } + + pub fn set_guides(&mut self, guides: Vec) { + 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) -> 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()); + } +} diff --git a/render-wasm/src/ui.rs b/render-wasm/src/ui.rs new file mode 100644 index 0000000000..156b4a8748 --- /dev/null +++ b/render-wasm/src/ui.rs @@ -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 { + 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, + frame_range: Option<(f32, f32)>, + ) -> Self { + Self { + kind, + color, + index: index.unwrap_or_default(), + frame_range, + } + } + + pub fn position(&self) -> f32 { + self.kind.value() + } +} diff --git a/render-wasm/src/wasm.rs b/render-wasm/src/wasm.rs index 2a3e641c1f..86f3101a83 100644 --- a/render-wasm/src/wasm.rs +++ b/render-wasm/src/wasm.rs @@ -13,3 +13,4 @@ pub mod svg_attrs; pub mod text; pub mod text_editor; pub mod transforms; +pub mod ui; diff --git a/render-wasm/src/wasm/ui.rs b/render-wasm/src/wasm/ui.rs new file mode 100644 index 0000000000..e71fd20388 --- /dev/null +++ b/render-wasm/src/wasm/ui.rs @@ -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::(); + +#[repr(u8)] +#[derive(Debug, Clone, PartialEq, Copy, ToJs)] +pub enum RawGuideKind { + Vertical = 0, + Horizontal = 1, +} + +impl From 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 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 { + 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> { + 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::>>() +} + +#[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 { + Ok(get_ui_state() + .find_guide_at(x, y, zoom, tolerance) + .map(|guide| guide.index as i32) + .unwrap_or(-1)) +}