🎉 Right click on guides to change color or remove

This commit is contained in:
Belén Albeza 2026-06-05 14:46:32 +02:00
parent 3916bfb334
commit 7c57bc56f1
9 changed files with 132 additions and 28 deletions

View File

@ -23,6 +23,8 @@
[app.main.ui.css-cursors :as cur]
[app.main.ui.formats :as fmt]
[app.main.ui.workspace.viewport.rulers :as rulers]
[app.main.ui.workspace.viewport.viewport-ref :as uwvv]
[app.render-wasm.api :as wasm.api]
[app.util.dom :as dom]
[app.util.keyboard :as kbd]
[cuerdas.core :as str]
@ -581,10 +583,17 @@
:is-hover true
:hover-frame frame}])]))
(defn- guide-by-serialized-index
"Maps a WASM guide index back to the guide map entry. The index matches the
serialization order used by `set-guides` / `write-guides`."
[guides index]
(when (>= index 0)
(nth (vec (vals guides)) index nil)))
(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?]}]
(let [visible-guides
(mf/with-memo [guides vbox]
(->> (vals guides)
(filter (partial guide-inside-vbox? zoom vbox))))
@ -616,6 +625,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 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 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 +649,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 +668,17 @@
: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-not wasm-guides?
(for [{:keys [id frame-id] :as guide} visible-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}])))]))

View File

@ -542,7 +542,7 @@
;; change or their visibility toggles. When hidden we send an empty set.
(mf/with-effect [@canvas-init? guides show-rulers? show-grids?]
(when @canvas-init?
(wasm.api/set-guides (if (and show-rulers? show-grids?) guides {}))))
(wasm.api/set-guides (if (and show-rulers? show-grids?) (or guides {}) {}))))
(hooks/setup-dom-events zoom disable-paste-ref in-viewport-ref read-only? drawing-tool path-drawing?)
(hooks/setup-viewport-size vport viewport-ref)
@ -837,8 +837,8 @@
[:> guides/viewport-guides*
{:zoom zoom
:vbox vbox
;; :guides guides
:guides #{}
:guides guides
:wasm-guides? true
:hover-frame guide-frame
:disabled-guides disabled-guides?
:modifiers wasm-modifiers}])

View File

@ -2254,6 +2254,20 @@
(h/call wasm/internal-module "_set_guides")
(request-render "set-guides")))
;; Screen-space hit tolerance for ruler guides. Must match
;; `guide-active-area` in `app.main.ui.workspace.viewport.guides`.
(def ^:private guide-active-area 16)
(defn find-guide-at
"Returns the serialized guide index at `position` (viewport coordinates),
or -1 when no guide is within the hit tolerance."
[position zoom]
(h/call wasm/internal-module "_find_guide_at"
(:x position)
(:y position)
zoom
guide-active-area))
(defn get-grid-coords
[position]
(let [offset (h/call wasm/internal-module

View File

@ -309,14 +309,14 @@
"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 guides) guide-entry-size)))
(+ 4 (* (count (or guides {})) guide-entry-size)))
(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` per guide."
[guides heapu32 heapf32 offset]
(let [guides (vec (vals guides))
(let [guides (vec (vals (or guides {})))
total (count guides)]
(aset heapu32 offset total)
(loop [i 0]

View File

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

View File

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

View File

@ -24,8 +24,10 @@ pub fn render_guide(canvas: &skia::Canvas, zoom: 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_anti_alias(true);
paint.set_stroke_width(1.0 / zoom);
// we disable antialias and increase the stroke thickness so the guides
// do not appear faint or blurry.
paint.set_anti_alias(false);
paint.set_stroke_width(2.0 / zoom);
let (x1, y1, x2, y2) = match guide.kind {
GuideKind::Vertical(x) => (x, area.top, x, area.bottom),

View File

@ -15,6 +15,9 @@ impl GuidePool {
}
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),
@ -56,6 +59,7 @@ impl GuidePool {
return None;
}
// FIXME: do binary search instead
let idx = guides.partition_point(|guide| guide.position() < coord);
let mut closest: Option<&Guide> = None;
let mut closest_dist = world_tolerance;
@ -96,8 +100,7 @@ impl UIState {
self.guides.set(guides);
}
#[allow(dead_code)]
fn find_guide_at(&self, x: f32, y: f32, zoom: f32, tolerance: f32) -> Option<&Guide> {
pub fn find_guide_at(&self, x: f32, y: f32, zoom: f32, tolerance: f32) -> Option<&Guide> {
self.guides.find_at(x, y, zoom, tolerance)
}
}
@ -121,6 +124,31 @@ mod tests {
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();

View File

@ -1,9 +1,10 @@
use crate::mem;
use crate::{
error::{Error, Result},
globals::get_ui_state,
globals::{get_render_state, get_ui_state},
ui::{Guide, GuideKind},
};
use crate::with_state;
use macros::{wasm_error, ToJs};
const RAW_GUIDE_SIZE: usize = std::mem::size_of::<RawGuide>();
@ -93,5 +94,21 @@ pub extern "C" fn set_guides() -> Result<()> {
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))
}