From 04920436258037d1942ce46ea4c64c34b00a566cc9004eda0bd5a8824f373760 Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Sun, 18 Jan 2026 10:05:38 -0600 Subject: [PATCH] Add the right-click ability on avatars --- crates/chattyness-user-ui/src/components.rs | 2 + .../src/components/avatar_canvas.rs | 63 +++++++ .../chattyness-user-ui/src/components/chat.rs | 39 +++++ .../src/components/context_menu.rs | 165 ++++++++++++++++++ .../src/components/scene_viewer.rs | 121 +++++++++++++ .../src/components/ws_client.rs | 25 ++- crates/chattyness-user-ui/src/pages/realm.rs | 25 ++- 7 files changed, 438 insertions(+), 2 deletions(-) create mode 100644 crates/chattyness-user-ui/src/components/context_menu.rs diff --git a/crates/chattyness-user-ui/src/components.rs b/crates/chattyness-user-ui/src/components.rs index eaedec0..093f10d 100644 --- a/crates/chattyness-user-ui/src/components.rs +++ b/crates/chattyness-user-ui/src/components.rs @@ -4,6 +4,7 @@ pub mod avatar_canvas; pub mod avatar_editor; pub mod chat; pub mod chat_types; +pub mod context_menu; pub mod editor; pub mod emotion_picker; pub mod forms; @@ -22,6 +23,7 @@ pub use avatar_canvas::*; pub use avatar_editor::*; pub use chat::*; pub use chat_types::*; +pub use context_menu::*; pub use editor::*; pub use emotion_picker::*; pub use forms::*; diff --git a/crates/chattyness-user-ui/src/components/avatar_canvas.rs b/crates/chattyness-user-ui/src/components/avatar_canvas.rs index 8fb66f2..27e6b70 100644 --- a/crates/chattyness-user-ui/src/components/avatar_canvas.rs +++ b/crates/chattyness-user-ui/src/components/avatar_canvas.rs @@ -528,6 +528,69 @@ fn wrap_text(ctx: &web_sys::CanvasRenderingContext2d, text: &str, max_width: f64 lines } +/// Test if a click at the given client coordinates hits a non-transparent pixel. +/// +/// Returns true if the alpha channel at the clicked pixel is > 0. +/// This enables pixel-perfect hit detection on avatar canvases. +#[cfg(feature = "hydrate")] +pub fn hit_test_canvas( + canvas: &web_sys::HtmlCanvasElement, + client_x: f64, + client_y: f64, +) -> bool { + use wasm_bindgen::JsCast; + + // Get the canvas bounding rect to transform client coords to canvas coords + let rect = canvas.get_bounding_client_rect(); + + // Calculate click position relative to the canvas element + let relative_x = client_x - rect.left(); + let relative_y = client_y - rect.top(); + + // Check if click is within canvas bounds + if relative_x < 0.0 || relative_y < 0.0 || relative_x >= rect.width() || relative_y >= rect.height() { + return false; + } + + // Transform to canvas pixel coordinates (accounting for CSS scaling) + let canvas_width = canvas.width() as f64; + let canvas_height = canvas.height() as f64; + + // Avoid division by zero + if rect.width() == 0.0 || rect.height() == 0.0 { + return false; + } + + let scale_x = canvas_width / rect.width(); + let scale_y = canvas_height / rect.height(); + + let pixel_x = (relative_x * scale_x) as f64; + let pixel_y = (relative_y * scale_y) as f64; + + // Get the 2D context and read the pixel data using JavaScript interop + if let Ok(Some(ctx)) = canvas.get_context("2d") { + let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into().unwrap(); + + // Use web_sys::CanvasRenderingContext2d::get_image_data with proper error handling + match ctx.get_image_data(pixel_x, pixel_y, 1.0, 1.0) { + Ok(image_data) => { + // Get the pixel data as Clamped> + let data = image_data.data(); + // Alpha channel is the 4th value (index 3) + if data.len() >= 4 { + return data[3] > 0; + } + } + Err(_) => { + // Security error or other issue with getImageData - assume no hit + return false; + } + } + } + + false +} + /// Draw a rounded rectangle path. #[cfg(feature = "hydrate")] fn draw_rounded_rect( diff --git a/crates/chattyness-user-ui/src/components/chat.rs b/crates/chattyness-user-ui/src/components/chat.rs index 4084e98..a5a5be6 100644 --- a/crates/chattyness-user-ui/src/components/chat.rs +++ b/crates/chattyness-user-ui/src/components/chat.rs @@ -55,6 +55,7 @@ fn parse_emote_command(cmd: &str) -> Option { /// - `on_focus_change`: Callback when focus state changes /// - `on_open_settings`: Callback to open settings popup /// - `on_open_inventory`: Callback to open inventory popup +/// - `whisper_target`: Signal containing the display name to whisper to (triggers pre-fill) #[component] pub fn ChatInput( ws_sender: WsSenderStorage, @@ -68,6 +69,9 @@ pub fn ChatInput( on_open_settings: Option>, #[prop(optional)] on_open_inventory: Option>, + /// Signal containing the display name to whisper to. When set, pre-fills the input. + #[prop(optional, into)] + whisper_target: Option>>, ) -> impl IntoView { let (message, set_message) = signal(String::new()); let (command_mode, set_command_mode) = signal(CommandMode::None); @@ -121,6 +125,41 @@ pub fn ChatInput( }); } + // Handle whisper target pre-fill + #[cfg(feature = "hydrate")] + { + Effect::new(move |_| { + let Some(whisper_signal) = whisper_target else { + return; + }; + + let Some(target_name) = whisper_signal.get() else { + return; + }; + + // Pre-fill with /whisper command + let placeholder = "your message here"; + let whisper_text = format!("/whisper {} {}", target_name, placeholder); + + if let Some(input) = input_ref.get() { + // Set the message + set_message.set(whisper_text.clone()); + set_command_mode.set(CommandMode::None); + + // Update input value + input.set_value(&whisper_text); + + // Focus the input + let _ = input.focus(); + + // Select the placeholder text so it gets replaced when typing + let prefix_len = format!("/whisper {} ", target_name).len() as u32; + let total_len = whisper_text.len() as u32; + let _ = input.set_selection_range(prefix_len, total_len); + } + }); + } + // Apply emotion via WebSocket let apply_emotion = { move |emotion: String| { diff --git a/crates/chattyness-user-ui/src/components/context_menu.rs b/crates/chattyness-user-ui/src/components/context_menu.rs new file mode 100644 index 0000000..e8d432e --- /dev/null +++ b/crates/chattyness-user-ui/src/components/context_menu.rs @@ -0,0 +1,165 @@ +//! Generic context menu component for right-click menus. +//! +//! Provides a reusable popup menu with: +//! - Fixed positioning at mouse coordinates +//! - Viewport edge detection to prevent off-screen rendering +//! - Click-outside-to-close behavior +//! - Escape key to close +//! - Dark theme styling +//! - ARIA attributes for accessibility + +use leptos::prelude::*; + +/// A menu item in the context menu. +#[derive(Clone)] +pub struct ContextMenuItem { + /// The label displayed for this menu item. + pub label: String, + /// The action identifier passed to the on_select callback. + pub action: String, +} + +/// Context menu component that displays at a specific position. +/// +/// Props: +/// - `open`: Whether the menu is currently visible +/// - `position`: The (x, y) position in client coordinates where the menu should appear +/// - `items`: The menu items to display +/// - `on_select`: Callback when a menu item is selected, receives the action string +/// - `on_close`: Callback when the menu should close (click outside, escape, etc.) +#[component] +pub fn ContextMenu( + /// Whether the menu is visible. + #[prop(into)] + open: Signal, + /// Position (x, y) in client coordinates. + #[prop(into)] + position: Signal<(f64, f64)>, + /// Menu items to display. + #[prop(into)] + items: Signal>, + /// Called when an item is selected with the action string. + #[prop(into)] + on_select: Callback, + /// Called when the menu should close. + #[prop(into)] + on_close: Callback<()>, +) -> impl IntoView { + let menu_ref = NodeRef::::new(); + + // Calculate adjusted position to stay within viewport + let adjusted_style = move || { + let (x, y) = position.get(); + let menu_width = 150.0; // min-w-[150px] + let menu_height = 100.0; // approximate + + #[cfg(feature = "hydrate")] + let (viewport_width, viewport_height) = { + let window = web_sys::window().unwrap(); + ( + window.inner_width().unwrap().as_f64().unwrap_or(800.0), + window.inner_height().unwrap().as_f64().unwrap_or(600.0), + ) + }; + #[cfg(not(feature = "hydrate"))] + let (viewport_width, viewport_height) = (800.0, 600.0); + + // Adjust position to keep menu within viewport + let adjusted_x = if x + menu_width > viewport_width { + (x - menu_width).max(0.0) + } else { + x + }; + let adjusted_y = if y + menu_height > viewport_height { + (y - menu_height).max(0.0) + } else { + y + }; + + format!( + "position: fixed; left: {}px; top: {}px; z-index: 40;", + adjusted_x, adjusted_y + ) + }; + + // Click outside handler + #[cfg(feature = "hydrate")] + { + use wasm_bindgen::{closure::Closure, JsCast}; + + Effect::new(move |_| { + if !open.get() { + return; + } + + let Some(menu_el) = menu_ref.get() else { + return; + }; + + let on_close = on_close.clone(); + let menu_el: web_sys::HtmlElement = menu_el.into(); + let menu_el_clone = menu_el.clone(); + + let handler = Closure::::new(move |ev: web_sys::MouseEvent| { + if let Some(target) = ev.target() { + if let Ok(target_el) = target.dyn_into::() { + if !menu_el_clone.contains(Some(&target_el)) { + on_close.run(()); + } + } + } + }); + + let window = web_sys::window().unwrap(); + let _ = window.add_event_listener_with_callback("mousedown", handler.as_ref().unchecked_ref()); + + // Escape key handler + let on_close_esc = on_close.clone(); + let keydown_handler = Closure::::new(move |ev: web_sys::KeyboardEvent| { + if ev.key() == "Escape" { + on_close_esc.run(()); + ev.prevent_default(); + } + }); + let _ = window.add_event_listener_with_callback("keydown", keydown_handler.as_ref().unchecked_ref()); + + // Store handlers to clean up (they get cleaned up when Effect reruns) + handler.forget(); + keydown_handler.forget(); + }); + } + + view! { + + + + } +} diff --git a/crates/chattyness-user-ui/src/components/scene_viewer.rs b/crates/chattyness-user-ui/src/components/scene_viewer.rs index 112aca4..7f0d338 100644 --- a/crates/chattyness-user-ui/src/components/scene_viewer.rs +++ b/crates/chattyness-user-ui/src/components/scene_viewer.rs @@ -15,8 +15,11 @@ use uuid::Uuid; use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene}; +#[cfg(feature = "hydrate")] +use super::avatar_canvas::hit_test_canvas; use super::avatar_canvas::{member_key, AvatarCanvas}; use super::chat_types::ActiveBubble; +use super::context_menu::{ContextMenu, ContextMenuItem}; use super::settings::{ calculate_min_zoom, ViewerSettings, BASE_PROP_SIZE, REFERENCE_HEIGHT, REFERENCE_WIDTH, }; @@ -53,6 +56,15 @@ pub fn RealmSceneViewer( /// Members that are fading out after timeout disconnect. #[prop(optional, into)] fading_members: Option>>, + /// Current user's user_id (for context menu filtering). + #[prop(optional, into)] + current_user_id: Option>>, + /// Current user's guest_session_id (for context menu filtering). + #[prop(optional, into)] + current_guest_session_id: Option>>, + /// Callback when whisper is requested on a member. + #[prop(optional, into)] + on_whisper_request: Option>, ) -> impl IntoView { // Use default settings if none provided let settings = settings.unwrap_or_else(|| Signal::derive(ViewerSettings::default)); @@ -115,6 +127,11 @@ pub fn RealmSceneViewer( // Signal to track when scale factors have been properly calculated let (scales_ready, set_scales_ready) = signal(false); + // Context menu state + let (context_menu_open, set_context_menu_open) = signal(false); + let (context_menu_position, set_context_menu_position) = signal((0.0_f64, 0.0_f64)); + let (context_menu_target, set_context_menu_target) = signal(Option::::None); + // Handle overlay click for movement or prop pickup // TODO: Add hit-testing for avatar clicks #[cfg(feature = "hydrate")] @@ -166,6 +183,75 @@ pub fn RealmSceneViewer( } }; + // Handle right-click for context menu on avatars + #[cfg(feature = "hydrate")] + let on_overlay_contextmenu = { + let current_user_id = current_user_id.clone(); + let current_guest_session_id = current_guest_session_id.clone(); + move |ev: web_sys::MouseEvent| { + use wasm_bindgen::JsCast; + + // Get click position + let client_x = ev.client_x() as f64; + let client_y = ev.client_y() as f64; + + // Get current user identity for filtering + let my_user_id = current_user_id.map(|s| s.get()).flatten(); + let my_guest_session_id = current_guest_session_id.map(|s| s.get()).flatten(); + + // Query all avatar canvases and check for hit + let document = web_sys::window().unwrap().document().unwrap(); + + // Get the avatars container and find all canvas elements within it + if let Some(container) = document.query_selector(".avatars-container").ok().flatten() { + let canvases = container.get_elements_by_tag_name("canvas"); + let canvas_count = canvases.length(); + + for i in 0..canvas_count { + if let Some(element) = canvases.item(i) { + if let Ok(canvas) = element.dyn_into::() { + // Check for data-member-id attribute + if let Some(member_id_str) = canvas.get_attribute("data-member-id") { + // Check if click hits a non-transparent pixel + if hit_test_canvas(&canvas, client_x, client_y) { + // Parse the member ID to determine if it's a user_id or guest_session_id + if let Ok(member_id) = member_id_str.parse::() { + // Check if this is the current user's avatar + let is_current_user = my_user_id == Some(member_id) || + my_guest_session_id == Some(member_id); + + if !is_current_user { + // Find the display name for this member + let display_name = members.get().iter() + .find(|m| { + m.member.user_id == Some(member_id) || + m.member.guest_session_id == Some(member_id) + }) + .map(|m| m.member.display_name.clone()); + + if let Some(name) = display_name { + // Prevent default browser context menu + ev.prevent_default(); + + // Show context menu at click position + set_context_menu_position.set((client_x, client_y)); + set_context_menu_target.set(Some(name)); + set_context_menu_open.set(true); + return; + } + } + } + } + } + } + } + } + } + + // No avatar hit - allow default context menu + } + }; + #[cfg(feature = "hydrate")] { use std::cell::RefCell; @@ -918,6 +1004,41 @@ pub fn RealmSceneViewer( #[cfg(not(feature = "hydrate"))] let _ = ev; } + on:contextmenu=move |ev| { + #[cfg(feature = "hydrate")] + on_overlay_contextmenu(ev); + #[cfg(not(feature = "hydrate"))] + let _ = ev; + } + /> + // Context menu for avatar interactions + diff --git a/crates/chattyness-user-ui/src/components/ws_client.rs b/crates/chattyness-user-ui/src/components/ws_client.rs index 711ac9d..ddfed3d 100644 --- a/crates/chattyness-user-ui/src/components/ws_client.rs +++ b/crates/chattyness-user-ui/src/components/ws_client.rs @@ -51,6 +51,17 @@ pub type WsSender = Box; /// Local stored value type for the sender (non-Send, WASM-compatible). pub type WsSenderStorage = StoredValue, LocalStorage>; +/// Information about the current channel member (received on Welcome). +#[derive(Clone, Debug)] +pub struct ChannelMemberInfo { + /// The user's user_id (if authenticated user). + pub user_id: Option, + /// The user's guest_session_id (if guest). + pub guest_session_id: Option, + /// The user's display name. + pub display_name: String, +} + /// Hook to manage WebSocket connection for a channel. /// /// Returns a tuple of: @@ -66,6 +77,7 @@ pub fn use_channel_websocket( on_prop_dropped: Callback, on_prop_picked_up: Callback, on_member_fading: Callback, + on_welcome: Option>, ) -> (Signal, WsSenderStorage) { use std::cell::RefCell; use std::rc::Rc; @@ -162,6 +174,7 @@ pub fn use_channel_websocket( let on_prop_dropped_clone = on_prop_dropped.clone(); let on_prop_picked_up_clone = on_prop_picked_up.clone(); let on_member_fading_clone = on_member_fading.clone(); + let on_welcome_clone = on_welcome.clone(); // For starting heartbeat on Welcome let ws_ref_for_heartbeat = ws_ref.clone(); let heartbeat_started: Rc> = Rc::new(RefCell::new(false)); @@ -174,7 +187,7 @@ pub fn use_channel_websocket( if let Ok(msg) = serde_json::from_str::(&text) { // Check for Welcome message to start heartbeat with server-provided config - if let ServerMessage::Welcome { ref config, .. } = msg { + if let ServerMessage::Welcome { ref config, ref member, .. } = msg { if !*heartbeat_started_clone.borrow() { *heartbeat_started_clone.borrow_mut() = true; let ping_interval_ms = config.ping_interval_secs * 1000; @@ -194,6 +207,15 @@ pub fn use_channel_websocket( }); std::mem::forget(heartbeat); } + // Call on_welcome callback with current user info + if let Some(ref callback) = on_welcome_clone { + let info = ChannelMemberInfo { + user_id: member.user_id, + guest_session_id: member.guest_session_id, + display_name: member.display_name.clone(), + }; + callback.run(info); + } } handle_server_message( msg, @@ -406,6 +428,7 @@ pub fn use_channel_websocket( _on_prop_dropped: Callback, _on_prop_picked_up: Callback, _on_member_fading: Callback, + _on_welcome: Option>, ) -> (Signal, WsSenderStorage) { let (ws_state, _) = signal(WsState::Disconnected); let sender: WsSenderStorage = StoredValue::new_local(None); diff --git a/crates/chattyness-user-ui/src/pages/realm.rs b/crates/chattyness-user-ui/src/pages/realm.rs index 4df3712..6656c58 100644 --- a/crates/chattyness-user-ui/src/pages/realm.rs +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -17,7 +17,7 @@ use crate::components::{ ViewerSettings, }; #[cfg(feature = "hydrate")] -use crate::components::{use_channel_websocket, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS}; +use crate::components::{use_channel_websocket, ChannelMemberInfo, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS}; use crate::utils::LocalStoragePersist; #[cfg(feature = "hydrate")] use crate::utils::parse_bounds_dimensions; @@ -90,6 +90,13 @@ pub fn RealmPage() -> impl IntoView { // Track user's current position for saving on beforeunload let (current_position, set_current_position) = signal((400.0_f64, 300.0_f64)); + // Current user identity (received from WebSocket Welcome message) + let (current_user_id, set_current_user_id) = signal(Option::::None); + let (current_guest_session_id, set_current_guest_session_id) = signal(Option::::None); + + // Whisper target - when set, triggers pre-fill in ChatInput + let (whisper_target, set_whisper_target) = signal(Option::::None); + let realm_data = LocalResource::new(move || { let slug = slug.get(); async move { @@ -244,6 +251,13 @@ pub fn RealmPage() -> impl IntoView { }); }); + // Callback to capture current user identity from Welcome message + #[cfg(feature = "hydrate")] + let on_welcome = Callback::new(move |info: ChannelMemberInfo| { + set_current_user_id.set(info.user_id); + set_current_guest_session_id.set(info.guest_session_id); + }); + #[cfg(feature = "hydrate")] let (_ws_state, ws_sender) = use_channel_websocket( slug, @@ -254,6 +268,7 @@ pub fn RealmPage() -> impl IntoView { on_prop_dropped, on_prop_picked_up, on_member_fading, + Some(on_welcome), ); // Set channel ID and scene dimensions when scene loads @@ -654,6 +669,10 @@ pub fn RealmPage() -> impl IntoView { let on_open_inventory_cb = Callback::new(move |_: ()| { set_inventory_open.set(true); }); + let whisper_target_signal = Signal::derive(move || whisper_target.get()); + let on_whisper_request_cb = Callback::new(move |target: String| { + set_whisper_target.set(Some(target)); + }); view! {
impl IntoView { }); }) fading_members=Signal::derive(move || fading_members.get()) + current_user_id=Signal::derive(move || current_user_id.get()) + current_guest_session_id=Signal::derive(move || current_guest_session_id.get()) + on_whisper_request=on_whisper_request_cb />
impl IntoView { on_focus_change=on_chat_focus_change.clone() on_open_settings=on_open_settings_cb on_open_inventory=on_open_inventory_cb + whisper_target=whisper_target_signal />