diff --git a/crates/chattyness-admin-ui/src/api.rs b/crates/chattyness-admin-ui/src/api.rs index 1c6c1db..b4f70d7 100644 --- a/crates/chattyness-admin-ui/src/api.rs +++ b/crates/chattyness-admin-ui/src/api.rs @@ -9,6 +9,8 @@ pub mod config; #[cfg(feature = "ssr")] pub mod dashboard; #[cfg(feature = "ssr")] +pub mod loose_props; +#[cfg(feature = "ssr")] pub mod props; #[cfg(feature = "ssr")] pub mod realms; diff --git a/crates/chattyness-admin-ui/src/api/loose_props.rs b/crates/chattyness-admin-ui/src/api/loose_props.rs new file mode 100644 index 0000000..5231a2c --- /dev/null +++ b/crates/chattyness-admin-ui/src/api/loose_props.rs @@ -0,0 +1,75 @@ +//! Loose props management API handlers for admin UI. + +use axum::Json; +use axum::extract::Path; +use chattyness_db::{models::LooseProp, queries::loose_props}; +use chattyness_error::AppError; +use serde::Deserialize; +use uuid::Uuid; + +use crate::auth::AdminConn; + +// ============================================================================= +// API Types +// ============================================================================= + +/// Request to update loose prop scale. +#[derive(Debug, Deserialize)] +pub struct UpdateLoosePropScaleRequest { + /// Scale factor (0.1 - 10.0). + pub scale: f32, +} + +// ============================================================================= +// API Handlers +// ============================================================================= + +/// Get a loose prop by ID. +pub async fn get_loose_prop( + admin_conn: AdminConn, + Path(loose_prop_id): Path, +) -> Result, AppError> { + let conn = admin_conn.0; + let mut guard = conn.acquire().await; + + let prop = loose_props::get_loose_prop_by_id(&mut *guard, loose_prop_id) + .await? + .ok_or_else(|| AppError::NotFound("Loose prop not found or has expired".to_string()))?; + + Ok(Json(prop)) +} + +/// Update loose prop scale. +/// +/// Server admins can update any loose prop scale. +pub async fn update_loose_prop_scale( + admin_conn: AdminConn, + Path(loose_prop_id): Path, + Json(req): Json, +) -> Result, AppError> { + let conn = admin_conn.0; + let mut guard = conn.acquire().await; + + let prop = loose_props::update_loose_prop_scale(&mut *guard, loose_prop_id, req.scale).await?; + + tracing::info!( + "Updated loose prop {} scale to {}", + loose_prop_id, + req.scale + ); + + Ok(Json(prop)) +} + +/// List loose props in a scene/channel. +pub async fn list_loose_props( + admin_conn: AdminConn, + Path(scene_id): Path, +) -> Result>, AppError> { + let conn = admin_conn.0; + let mut guard = conn.acquire().await; + + let props = loose_props::list_channel_loose_props(&mut *guard, scene_id).await?; + + Ok(Json(props)) +} diff --git a/crates/chattyness-admin-ui/src/api/routes.rs b/crates/chattyness-admin-ui/src/api/routes.rs index 3d85a34..a305074 100644 --- a/crates/chattyness-admin-ui/src/api/routes.rs +++ b/crates/chattyness-admin-ui/src/api/routes.rs @@ -5,7 +5,7 @@ use axum::{ routing::{delete, get, post, put}, }; -use super::{auth, avatars, config, dashboard, props, realms, scenes, spots, staff, users}; +use super::{auth, avatars, config, dashboard, loose_props, props, realms, scenes, spots, staff, users}; use crate::app::AdminAppState; /// Create the admin API router. @@ -85,6 +85,19 @@ pub fn admin_api_router() -> Router { "/props/{prop_id}", get(props::get_prop).delete(props::delete_prop), ) + // API - Loose Props (scene props) + .route( + "/scenes/{scene_id}/loose_props", + get(loose_props::list_loose_props), + ) + .route( + "/loose_props/{loose_prop_id}", + get(loose_props::get_loose_prop), + ) + .route( + "/loose_props/{loose_prop_id}/scale", + put(loose_props::update_loose_prop_scale), + ) // API - Server Avatars .route( "/avatars", diff --git a/crates/chattyness-admin-ui/src/models.rs b/crates/chattyness-admin-ui/src/models.rs index 2e9dc7b..c16ed05 100644 --- a/crates/chattyness-admin-ui/src/models.rs +++ b/crates/chattyness-admin-ui/src/models.rs @@ -239,6 +239,8 @@ pub struct PropDetail { pub default_layer: Option, /// Grid position (0-8): top row 0,1,2 / middle 3,4,5 / bottom 6,7,8 pub default_position: Option, + /// Default scale factor (0.1 - 10.0) applied when prop is dropped to canvas. + pub default_scale: f32, pub is_unique: bool, pub is_transferable: bool, pub is_portable: bool, diff --git a/crates/chattyness-admin-ui/src/pages/props_detail.rs b/crates/chattyness-admin-ui/src/pages/props_detail.rs index 2ae1874..f21e878 100644 --- a/crates/chattyness-admin-ui/src/pages/props_detail.rs +++ b/crates/chattyness-admin-ui/src/pages/props_detail.rs @@ -95,6 +95,9 @@ fn PropDetailView(prop: PropDetail) -> impl IntoView { None => "Not set".to_string(), }} + + {format!("{}%", (prop.default_scale * 100.0) as i32)} + {if prop.is_active { view! { "Active" }.into_any() diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs index a1d11c5..faf0258 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -889,6 +889,8 @@ pub struct LooseProp { pub realm_prop_id: Option, pub position_x: f64, pub position_y: f64, + /// Scale factor (0.1 - 10.0) inherited from prop definition at drop time. + pub scale: f32, pub dropped_by: Option, pub expires_at: Option>, pub created_at: DateTime, @@ -915,6 +917,8 @@ pub struct ServerProp { pub default_emotion: Option, /// Grid position (0-8): top row 0,1,2 / middle 3,4,5 / bottom 6,7,8 pub default_position: Option, + /// Default scale factor (0.1 - 10.0) applied when prop is dropped to canvas. + pub default_scale: f32, pub is_unique: bool, pub is_transferable: bool, pub is_portable: bool, @@ -966,6 +970,9 @@ pub struct CreateServerPropRequest { /// Whether prop appears in the public Server inventory tab. #[serde(default)] pub public: Option, + /// Default scale factor (0.1 - 10.0) applied when prop is dropped to canvas. + #[serde(default)] + pub default_scale: Option, } #[cfg(feature = "ssr")] @@ -999,6 +1006,14 @@ impl CreateServerPropRequest { .to_string(), )); } + // Validate scale range (0.1 - 10.0) + if let Some(scale) = self.default_scale { + if !(0.1..=10.0).contains(&scale) { + return Err(AppError::Validation( + "default_scale must be between 0.1 and 10.0".to_string(), + )); + } + } Ok(()) } diff --git a/crates/chattyness-db/src/queries/loose_props.rs b/crates/chattyness-db/src/queries/loose_props.rs index 415d907..c807a4c 100644 --- a/crates/chattyness-db/src/queries/loose_props.rs +++ b/crates/chattyness-db/src/queries/loose_props.rs @@ -46,6 +46,7 @@ pub async fn list_channel_loose_props<'e>( lp.realm_prop_id, ST_X(lp.position) as position_x, ST_Y(lp.position) as position_y, + lp.scale, lp.dropped_by, lp.expires_at, lp.created_at, @@ -81,6 +82,7 @@ pub async fn drop_prop_to_canvas<'e>( ) -> Result { // Single CTE that checks existence/droppability and performs the operation atomically. // Returns status flags plus the LooseProp data (if successful). + // Includes scale inherited from the source prop's default_scale. let result: Option<( bool, bool, @@ -91,6 +93,7 @@ pub async fn drop_prop_to_canvas<'e>( Option, Option, Option, + Option, Option, Option>, Option>, @@ -99,9 +102,18 @@ pub async fn drop_prop_to_canvas<'e>( )> = sqlx::query_as( r#" WITH item_info AS ( - SELECT id, is_droppable, server_prop_id, realm_prop_id, prop_name, prop_asset_path - FROM auth.inventory - WHERE id = $1 AND user_id = $2 + SELECT + inv.id, + inv.is_droppable, + inv.server_prop_id, + inv.realm_prop_id, + inv.prop_name, + inv.prop_asset_path, + COALESCE(sp.default_scale, rp.default_scale, 1.0) as default_scale + FROM auth.inventory inv + LEFT JOIN server.props sp ON inv.server_prop_id = sp.id + LEFT JOIN realm.props rp ON inv.realm_prop_id = rp.id + WHERE inv.id = $1 AND inv.user_id = $2 ), deleted_item AS ( DELETE FROM auth.inventory @@ -114,6 +126,7 @@ pub async fn drop_prop_to_canvas<'e>( server_prop_id, realm_prop_id, position, + scale, dropped_by, expires_at ) @@ -122,6 +135,7 @@ pub async fn drop_prop_to_canvas<'e>( di.server_prop_id, di.realm_prop_id, public.make_virtual_point($4::real, $5::real), + (SELECT default_scale FROM item_info), $2, now() + interval '30 minutes' FROM deleted_item di @@ -132,6 +146,7 @@ pub async fn drop_prop_to_canvas<'e>( realm_prop_id, ST_X(position)::real as position_x, ST_Y(position)::real as position_y, + scale, dropped_by, expires_at, created_at @@ -146,6 +161,7 @@ pub async fn drop_prop_to_canvas<'e>( ip.realm_prop_id, ip.position_x, ip.position_y, + ip.scale, ip.dropped_by, ip.expires_at, ip.created_at, @@ -171,19 +187,19 @@ pub async fn drop_prop_to_canvas<'e>( "Unexpected error dropping prop to canvas".to_string(), )) } - Some((false, _, _, _, _, _, _, _, _, _, _, _, _, _)) => { + Some((false, _, _, _, _, _, _, _, _, _, _, _, _, _, _)) => { // Item didn't exist Err(AppError::NotFound( "Inventory item not found or not owned by user".to_string(), )) } - Some((true, false, _, _, _, _, _, _, _, _, _, _, _, _)) => { + Some((true, false, _, _, _, _, _, _, _, _, _, _, _, _, _)) => { // Item existed but is not droppable Err(AppError::Forbidden( "This prop cannot be dropped - it is an essential prop".to_string(), )) } - Some((true, true, false, _, _, _, _, _, _, _, _, _, _, _)) => { + Some((true, true, false, _, _, _, _, _, _, _, _, _, _, _, _)) => { // Item was droppable but delete failed (shouldn't happen) Err(AppError::Internal( "Unexpected error dropping prop to canvas".to_string(), @@ -199,6 +215,7 @@ pub async fn drop_prop_to_canvas<'e>( realm_prop_id, Some(position_x), Some(position_y), + Some(scale), dropped_by, Some(expires_at), Some(created_at), @@ -213,6 +230,7 @@ pub async fn drop_prop_to_canvas<'e>( realm_prop_id, position_x: position_x.into(), position_y: position_y.into(), + scale, dropped_by, expires_at: Some(expires_at), created_at, @@ -313,6 +331,102 @@ pub async fn pick_up_loose_prop<'e>( Ok(item) } +/// Update the scale of a loose prop. +/// +/// Server admins can update any loose prop. +/// Realm admins can update loose props in their realm. +pub async fn update_loose_prop_scale<'e>( + executor: impl PgExecutor<'e>, + loose_prop_id: Uuid, + scale: f32, +) -> Result { + // Validate scale range + if !(0.1..=10.0).contains(&scale) { + return Err(AppError::Validation( + "Scale must be between 0.1 and 10.0".to_string(), + )); + } + + let prop = sqlx::query_as::<_, LooseProp>( + r#" + WITH updated AS ( + UPDATE scene.loose_props + SET scale = $2 + WHERE id = $1 + AND (expires_at IS NULL OR expires_at > now()) + RETURNING + id, + instance_id as channel_id, + server_prop_id, + realm_prop_id, + ST_X(position) as position_x, + ST_Y(position) as position_y, + scale, + dropped_by, + expires_at, + created_at + ) + SELECT + u.id, + u.channel_id, + u.server_prop_id, + u.realm_prop_id, + u.position_x, + u.position_y, + u.scale, + u.dropped_by, + u.expires_at, + u.created_at, + COALESCE(sp.name, rp.name) as prop_name, + COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path + FROM updated u + LEFT JOIN server.props sp ON u.server_prop_id = sp.id + LEFT JOIN realm.props rp ON u.realm_prop_id = rp.id + "#, + ) + .bind(loose_prop_id) + .bind(scale) + .fetch_optional(executor) + .await? + .ok_or_else(|| AppError::NotFound("Loose prop not found or has expired".to_string()))?; + + Ok(prop) +} + +/// Get a loose prop by ID. +pub async fn get_loose_prop_by_id<'e>( + executor: impl PgExecutor<'e>, + loose_prop_id: Uuid, +) -> Result, AppError> { + let prop = sqlx::query_as::<_, LooseProp>( + r#" + SELECT + lp.id, + lp.instance_id as channel_id, + lp.server_prop_id, + lp.realm_prop_id, + ST_X(lp.position) as position_x, + ST_Y(lp.position) as position_y, + lp.scale, + lp.dropped_by, + lp.expires_at, + lp.created_at, + COALESCE(sp.name, rp.name) as prop_name, + COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path + FROM scene.loose_props lp + LEFT JOIN server.props sp ON lp.server_prop_id = sp.id + LEFT JOIN realm.props rp ON lp.realm_prop_id = rp.id + WHERE lp.id = $1 + AND (lp.expires_at IS NULL OR lp.expires_at > now()) + "#, + ) + .bind(loose_prop_id) + .fetch_optional(executor) + .await?; + + Ok(prop) +} + /// Delete expired loose props. /// /// Returns the number of props deleted. diff --git a/crates/chattyness-db/src/queries/props.rs b/crates/chattyness-db/src/queries/props.rs index e4b7f88..076dc2b 100644 --- a/crates/chattyness-db/src/queries/props.rs +++ b/crates/chattyness-db/src/queries/props.rs @@ -48,6 +48,7 @@ pub async fn get_server_prop_by_id<'e>( default_layer, default_emotion, default_position, + default_scale, is_unique, is_transferable, is_portable, @@ -116,20 +117,23 @@ pub async fn create_server_prop<'e>( let is_droppable = req.droppable.unwrap_or(true); let is_public = req.public.unwrap_or(false); + let default_scale = req.default_scale.unwrap_or(1.0); let prop = sqlx::query_as::<_, ServerProp>( r#" INSERT INTO server.props ( name, slug, description, tags, asset_path, default_layer, default_emotion, default_position, + default_scale, is_droppable, is_public, created_by ) VALUES ( $1, $2, $3, $4, $5, $6::server.avatar_layer, $7::server.emotion_state, $8, - $9, $10, - $11 + $9, + $10, $11, + $12 ) RETURNING id, @@ -142,6 +146,7 @@ pub async fn create_server_prop<'e>( default_layer, default_emotion, default_position, + default_scale, is_unique, is_transferable, is_portable, @@ -163,6 +168,7 @@ pub async fn create_server_prop<'e>( .bind(&default_layer) .bind(&default_emotion) .bind(default_position) + .bind(default_scale) .bind(is_droppable) .bind(is_public) .bind(created_by) @@ -207,20 +213,23 @@ pub async fn upsert_server_prop<'e>( let is_droppable = req.droppable.unwrap_or(true); let is_public = req.public.unwrap_or(false); + let default_scale = req.default_scale.unwrap_or(1.0); let prop = sqlx::query_as::<_, ServerProp>( r#" INSERT INTO server.props ( name, slug, description, tags, asset_path, default_layer, default_emotion, default_position, + default_scale, is_droppable, is_public, created_by ) VALUES ( $1, $2, $3, $4, $5, $6::server.avatar_layer, $7::server.emotion_state, $8, - $9, $10, - $11 + $9, + $10, $11, + $12 ) ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, @@ -230,6 +239,7 @@ pub async fn upsert_server_prop<'e>( default_layer = EXCLUDED.default_layer, default_emotion = EXCLUDED.default_emotion, default_position = EXCLUDED.default_position, + default_scale = EXCLUDED.default_scale, is_droppable = EXCLUDED.is_droppable, is_public = EXCLUDED.is_public, updated_at = now() @@ -244,6 +254,7 @@ pub async fn upsert_server_prop<'e>( default_layer, default_emotion, default_position, + default_scale, is_unique, is_transferable, is_portable, @@ -265,6 +276,7 @@ pub async fn upsert_server_prop<'e>( .bind(&default_layer) .bind(&default_emotion) .bind(default_position) + .bind(default_scale) .bind(is_droppable) .bind(is_public) .bind(created_by) diff --git a/crates/chattyness-db/src/ws_messages.rs b/crates/chattyness-db/src/ws_messages.rs index 2951fba..77a8fbc 100644 --- a/crates/chattyness-db/src/ws_messages.rs +++ b/crates/chattyness-db/src/ws_messages.rs @@ -104,6 +104,14 @@ pub enum ClientMessage { /// Request to refresh identity after registration (guest → user conversion). /// Server will fetch updated user data and broadcast to all members. RefreshIdentity, + + /// Update a loose prop's scale (moderator only). + UpdateProp { + /// The loose prop ID to update. + loose_prop_id: Uuid, + /// New scale factor (0.1 - 10.0). + scale: f32, + }, } /// Server-to-client WebSocket messages. @@ -221,6 +229,12 @@ pub enum ServerMessage { prop_id: Uuid, }, + /// A prop was updated (scale changed) - clients should update their local copy. + PropRefresh { + /// The updated prop with all current values. + prop: LooseProp, + }, + /// A member updated their avatar appearance. AvatarUpdated { /// User ID of the member. diff --git a/crates/chattyness-user-ui/src/api/websocket.rs b/crates/chattyness-user-ui/src/api/websocket.rs index 108034b..93217e3 100644 --- a/crates/chattyness-user-ui/src/api/websocket.rs +++ b/crates/chattyness-user-ui/src/api/websocket.rs @@ -1419,6 +1419,50 @@ async fn handle_socket( } } } + ClientMessage::UpdateProp { loose_prop_id, scale } => { + // Check if user is a moderator + let is_mod = match memberships::is_moderator(&pool, user_id, realm_id).await { + Ok(result) => result, + Err(e) => { + tracing::error!("[WS] Failed to check moderator status: {:?}", e); + false + } + }; + + if !is_mod { + let _ = direct_tx.send(ServerMessage::Error { + code: "NOT_MODERATOR".to_string(), + message: "You do not have permission to update props".to_string(), + }).await; + continue; + } + + // Update the prop scale + match loose_props::update_loose_prop_scale( + &mut *recv_conn, + loose_prop_id, + scale, + ).await { + Ok(updated_prop) => { + #[cfg(debug_assertions)] + tracing::debug!( + "[WS] User {} updated prop {} scale to {}", + user_id, + loose_prop_id, + scale + ); + // Broadcast the updated prop to all users in the channel + let _ = tx.send(ServerMessage::PropRefresh { prop: updated_prop }); + } + Err(e) => { + tracing::error!("[WS] Update prop failed: {:?}", e); + let _ = direct_tx.send(ServerMessage::Error { + code: "UPDATE_PROP_FAILED".to_string(), + message: format!("{:?}", e), + }).await; + } + } + } } } Message::Close(close_frame) => { diff --git a/crates/chattyness-user-ui/src/components.rs b/crates/chattyness-user-ui/src/components.rs index 057c5ce..4fd3459 100644 --- a/crates/chattyness-user-ui/src/components.rs +++ b/crates/chattyness-user-ui/src/components.rs @@ -2,6 +2,7 @@ pub mod avatar_canvas; pub mod avatar_editor; +pub mod canvas_utils; pub mod avatar_store; pub mod avatar_thumbnail; pub mod chat; @@ -17,6 +18,7 @@ pub mod keybindings; pub mod keybindings_popup; pub mod layout; pub mod log_popup; +pub mod loose_prop_canvas; pub mod modals; pub mod notifications; pub mod register_modal; @@ -31,6 +33,7 @@ pub mod ws_client; pub use avatar_canvas::*; pub use avatar_editor::*; pub use avatar_store::*; +pub use canvas_utils::*; pub use avatar_thumbnail::*; pub use chat::*; pub use chat_types::*; @@ -45,6 +48,7 @@ pub use keybindings::*; pub use keybindings_popup::*; pub use layout::*; pub use log_popup::*; +pub use loose_prop_canvas::*; pub use modals::*; pub use notifications::*; pub use register_modal::*; diff --git a/crates/chattyness-user-ui/src/components/avatar_canvas.rs b/crates/chattyness-user-ui/src/components/avatar_canvas.rs index 1ff8b87..d35f114 100644 --- a/crates/chattyness-user-ui/src/components/avatar_canvas.rs +++ b/crates/chattyness-user-ui/src/components/avatar_canvas.rs @@ -9,6 +9,10 @@ use uuid::Uuid; use chattyness_db::models::{ChannelMemberWithAvatar, EmotionState}; +#[cfg(feature = "hydrate")] +pub use super::canvas_utils::hit_test_canvas; +#[cfg(feature = "hydrate")] +use super::canvas_utils::normalize_asset_path; use super::chat_types::{ActiveBubble, emotion_bubble_colors}; /// Base text size multiplier. Text at 100% slider = base_sizes * 1.4 @@ -802,15 +806,6 @@ pub fn AvatarCanvas( } } -/// Normalize an asset path to be absolute, prefixing with /static/ if needed. -#[cfg(feature = "hydrate")] -fn normalize_asset_path(path: &str) -> String { - if path.starts_with('/') { - path.to_string() - } else { - format!("/static/{}", path) - } -} /// Draw a speech bubble using the unified CanvasLayout. /// @@ -972,68 +967,6 @@ 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")] diff --git a/crates/chattyness-user-ui/src/components/canvas_utils.rs b/crates/chattyness-user-ui/src/components/canvas_utils.rs new file mode 100644 index 0000000..dbfcd8d --- /dev/null +++ b/crates/chattyness-user-ui/src/components/canvas_utils.rs @@ -0,0 +1,77 @@ +//! Shared canvas utilities for avatar and prop rendering. +//! +//! Common functions used by both AvatarCanvas and LoosePropCanvas components. + +/// Normalize an asset path to be absolute, prefixing with /static/ if needed. +#[cfg(feature = "hydrate")] +pub fn normalize_asset_path(path: &str) -> String { + if path.starts_with('/') { + path.to_string() + } else { + format!("/static/{}", path) + } +} + +/// 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 canvas elements. +#[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; + let pixel_y = relative_y * scale_y; + + // Get the 2D context and read the pixel data + if let Ok(Some(ctx)) = canvas.get_context("2d") { + let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into().unwrap(); + + match ctx.get_image_data(pixel_x, pixel_y, 1.0, 1.0) { + Ok(image_data) => { + let data = image_data.data(); + // Alpha channel is the 4th value (index 3) + if data.len() >= 4 { + return data[3] > 0; + } + } + Err(_) => { + return false; + } + } + } + + false +} diff --git a/crates/chattyness-user-ui/src/components/loose_prop_canvas.rs b/crates/chattyness-user-ui/src/components/loose_prop_canvas.rs new file mode 100644 index 0000000..536b00b --- /dev/null +++ b/crates/chattyness-user-ui/src/components/loose_prop_canvas.rs @@ -0,0 +1,188 @@ +//! Individual loose prop canvas component for per-prop rendering. +//! +//! Each loose prop gets its own canvas element positioned via CSS transforms. +//! This enables pixel-perfect hit detection using getImageData(). + +use leptos::prelude::*; +use uuid::Uuid; + +use chattyness_db::models::LooseProp; + +#[cfg(feature = "hydrate")] +pub use super::canvas_utils::hit_test_canvas; +#[cfg(feature = "hydrate")] +use super::canvas_utils::normalize_asset_path; +use super::settings::{BASE_AVATAR_SCALE, BASE_PROP_SCALE, BASE_PROP_SIZE}; + +/// Get a unique key for a loose prop (for Leptos For keying). +pub fn loose_prop_key(p: &LooseProp) -> Uuid { + p.id +} + +/// Individual loose prop canvas component. +/// +/// Renders a single prop with: +/// - CSS transform for position (GPU-accelerated, no redraw on move) +/// - Canvas for prop sprite (redraws only on appearance change) +/// - Pixel-perfect hit detection via getImageData() +#[component] +pub fn LoosePropCanvas( + /// The prop data (as a signal for reactive updates). + prop: Signal, + /// X scale factor for coordinate conversion. + scale_x: Signal, + /// Y scale factor for coordinate conversion. + scale_y: Signal, + /// X offset for coordinate conversion. + offset_x: Signal, + /// Y offset for coordinate conversion. + offset_y: Signal, + /// Base prop size in screen pixels (already includes viewport scaling). + base_prop_size: Signal, + /// Z-index for stacking order. + z_index: i32, +) -> impl IntoView { + let canvas_ref = NodeRef::::new(); + + // Reactive style for CSS positioning (GPU-accelerated transforms) + let style = move || { + let p = prop.get(); + let sx = scale_x.get(); + let sy = scale_y.get(); + let ox = offset_x.get(); + let oy = offset_y.get(); + let base_size = base_prop_size.get(); + + // Calculate rendered prop size + let prop_scale_ratio = BASE_PROP_SCALE / BASE_AVATAR_SCALE; + let prop_size = base_size * prop_scale_ratio * p.scale as f64; + + // Screen position (center of prop) + let screen_x = p.position_x * sx + ox; + let screen_y = p.position_y * sy + oy; + + // Canvas positioned at top-left corner + let canvas_x = screen_x - prop_size / 2.0; + let canvas_y = screen_y - prop_size / 2.0; + + format!( + "position: absolute; \ + left: 0; top: 0; \ + transform: translate({}px, {}px); \ + z-index: {}; \ + pointer-events: auto; \ + width: {}px; \ + height: {}px;", + canvas_x, canvas_y, z_index, prop_size, prop_size + ) + }; + + #[cfg(feature = "hydrate")] + { + use std::cell::RefCell; + use std::rc::Rc; + use wasm_bindgen::closure::Closure; + use wasm_bindgen::JsCast; + + // Image cache for this prop + let image_cache: Rc>> = + Rc::new(RefCell::new(None)); + + // Redraw trigger - incremented when image loads + let (redraw_trigger, set_redraw_trigger) = signal(0u32); + + // Effect to draw the prop when canvas is ready or appearance changes + Effect::new(move |_| { + // Subscribe to redraw trigger + let _ = redraw_trigger.get(); + + let p = prop.get(); + let base_size = base_prop_size.get(); + + let Some(canvas) = canvas_ref.get() else { + return; + }; + + // Calculate rendered prop size + let prop_scale_ratio = BASE_PROP_SCALE / BASE_AVATAR_SCALE; + let prop_size = base_size * prop_scale_ratio * p.scale as f64; + + let canvas_el: &web_sys::HtmlCanvasElement = &canvas; + + // Set canvas resolution + canvas_el.set_width(prop_size as u32); + canvas_el.set_height(prop_size as u32); + + let Ok(Some(ctx)) = canvas_el.get_context("2d") else { + return; + }; + let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into().unwrap(); + + // Clear canvas + ctx.clear_rect(0.0, 0.0, prop_size, prop_size); + + // Draw prop sprite if asset path available + if !p.prop_asset_path.is_empty() { + let normalized_path = normalize_asset_path(&p.prop_asset_path); + let mut cache = image_cache.borrow_mut(); + + if let Some(ref img) = *cache { + // Image in cache - draw if loaded + if img.complete() && img.natural_width() > 0 { + let _ = ctx.draw_image_with_html_image_element_and_dw_and_dh( + img, 0.0, 0.0, prop_size, prop_size, + ); + } + } else { + // Not in cache - create and load + let img = web_sys::HtmlImageElement::new().unwrap(); + + let trigger = set_redraw_trigger; + let onload = Closure::once(Box::new(move || { + trigger.update(|v| *v += 1); + }) as Box); + img.set_onload(Some(onload.as_ref().unchecked_ref())); + onload.forget(); + + img.set_src(&normalized_path); + *cache = Some(img); + } + } else { + // Fallback: draw placeholder circle with prop name + ctx.begin_path(); + let _ = ctx.arc( + prop_size / 2.0, + prop_size / 2.0, + prop_size / 2.0 - 2.0, + 0.0, + std::f64::consts::PI * 2.0, + ); + ctx.set_fill_style_str("#f59e0b"); + ctx.fill(); + ctx.set_stroke_style_str("#d97706"); + ctx.set_line_width(2.0); + ctx.stroke(); + + // Draw prop name + let text_scale = prop_size / (BASE_PROP_SIZE * BASE_PROP_SCALE); + ctx.set_fill_style_str("#fff"); + ctx.set_font(&format!("{}px sans-serif", 10.0 * text_scale)); + ctx.set_text_align("center"); + ctx.set_text_baseline("middle"); + let _ = ctx.fill_text(&p.prop_name, prop_size / 2.0, prop_size / 2.0); + } + }); + } + + // Compute data-prop-id reactively + let data_prop_id = move || prop.get().id.to_string(); + + view! { + + } +} + diff --git a/crates/chattyness-user-ui/src/components/scene_viewer.rs b/crates/chattyness-user-ui/src/components/scene_viewer.rs index c7e13d2..2e348b7 100644 --- a/crates/chattyness-user-ui/src/components/scene_viewer.rs +++ b/crates/chattyness-user-ui/src/components/scene_viewer.rs @@ -15,13 +15,15 @@ use uuid::Uuid; use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene}; -#[cfg(feature = "hydrate")] -use super::avatar_canvas::hit_test_canvas; use super::avatar_canvas::{AvatarCanvas, member_key}; +#[cfg(feature = "hydrate")] +use super::canvas_utils::hit_test_canvas; use super::chat_types::ActiveBubble; use super::context_menu::{ContextMenu, ContextMenuItem}; +use super::loose_prop_canvas::LoosePropCanvas; use super::settings::{ - BASE_PROP_SIZE, REFERENCE_HEIGHT, REFERENCE_WIDTH, ViewerSettings, calculate_min_zoom, + BASE_AVATAR_SCALE, BASE_PROP_SCALE, BASE_PROP_SIZE, REFERENCE_HEIGHT, REFERENCE_WIDTH, + ViewerSettings, calculate_min_zoom, }; use super::ws_client::FadingMember; use crate::utils::parse_bounds_dimensions; @@ -60,6 +62,12 @@ pub fn RealmSceneViewer( /// Callback when whisper is requested on a member. #[prop(optional, into)] on_whisper_request: Option>, + /// Whether the current user is a moderator (can edit prop scales). + #[prop(optional, into)] + is_moderator: Option>, + /// Callback when prop scale is updated (moderator only). + #[prop(optional, into)] + on_prop_scale_update: Option>, ) -> impl IntoView { let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false)); // Use default settings if none provided @@ -103,10 +111,9 @@ pub fn RealmSceneViewer( let has_background_image = scene.background_image_path.is_some(); let image_path = scene.background_image_path.clone().unwrap_or_default(); - // Canvas refs for background and props layers - // Avatar layer now uses individual canvas elements per user + // Canvas ref for background layer + // Avatar and prop layers use individual canvas elements per user/prop let bg_canvas_ref = NodeRef::::new(); - let props_canvas_ref = NodeRef::::new(); // Outer container ref for middle-mouse drag scrolling let outer_container_ref = NodeRef::::new(); @@ -121,70 +128,153 @@ pub fn RealmSceneViewer( // Signal to track when scale factors have been properly calculated let (scales_ready, set_scales_ready) = signal(false); - // Context menu state + // Context menu state (for avatar whisper) 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); + // Prop context menu state (for moderator scale editing) + let (prop_context_menu_open, set_prop_context_menu_open) = signal(false); + let (prop_context_menu_position, set_prop_context_menu_position) = signal((0.0_f64, 0.0_f64)); + let (prop_context_menu_target, set_prop_context_menu_target) = signal(Option::::None); + + // Scale mode state (when dragging to resize prop) + let (scale_mode_active, set_scale_mode_active) = signal(false); + let (scale_mode_prop_id, set_scale_mode_prop_id) = signal(Option::::None); + let (scale_mode_initial_scale, set_scale_mode_initial_scale) = signal(1.0_f32); + let (scale_mode_preview_scale, set_scale_mode_preview_scale) = signal(1.0_f32); + // Prop center in canvas coordinates (for scale calculation) + let (scale_mode_prop_center, set_scale_mode_prop_center) = signal((0.0_f64, 0.0_f64)); + // Handle overlay click for movement or prop pickup - // TODO: Add hit-testing for avatar clicks + // Uses pixel-perfect hit testing on prop canvases #[cfg(feature = "hydrate")] let on_overlay_click = { let on_move = on_move.clone(); let on_prop_click = on_prop_click.clone(); move |ev: web_sys::MouseEvent| { - // Get click position relative to the target element - let target = ev.current_target().unwrap(); - let element: web_sys::HtmlElement = target.dyn_into().unwrap(); - let rect = element.get_bounding_client_rect(); + use wasm_bindgen::JsCast; - let click_x = ev.client_x() as f64 - rect.left(); - let click_y = ev.client_y() as f64 - rect.top(); + let client_x = ev.client_x() as f64; + let client_y = ev.client_y() as f64; - let sx = scale_x.get(); - let sy = scale_y.get(); - let ox = offset_x.get(); - let oy = offset_y.get(); + // First check for pixel-perfect prop hits + let document = web_sys::window().unwrap().document().unwrap(); + let mut clicked_prop: Option = None; - if sx > 0.0 && sy > 0.0 { - let scene_x = (click_x - ox) / sx; - let scene_y = (click_y - oy) / sy; + // Query prop canvases in the props container + if let Some(container) = document.query_selector(".props-container").ok().flatten() { + let canvases = container.get_elements_by_tag_name("canvas"); + let canvas_count = canvases.length(); - let scene_x = scene_x.max(0.0).min(scene_width as f64); - let scene_y = scene_y.max(0.0).min(scene_height as f64); - - // Check if click is within 40px of any loose prop - let current_props = loose_props.get(); - let prop_click_radius = 40.0; - let mut clicked_prop: Option = None; - - for prop in ¤t_props { - let dx = scene_x - prop.position_x; - let dy = scene_y - prop.position_y; - let distance = (dx * dx + dy * dy).sqrt(); - if distance <= prop_click_radius { - clicked_prop = Some(prop.id); - break; + for i in 0..canvas_count { + if let Some(element) = canvases.item(i) { + if let Ok(canvas) = element.dyn_into::() { + if let Some(prop_id_str) = canvas.get_attribute("data-prop-id") { + // Pixel-perfect hit test + if hit_test_canvas(&canvas, client_x, client_y) { + if let Ok(prop_id) = prop_id_str.parse::() { + clicked_prop = Some(prop_id); + break; + } + } + } + } } } + } + + if let Some(prop_id) = clicked_prop { + on_prop_click.run(prop_id); + } else { + // No prop hit - handle as movement + let target = ev.current_target().unwrap(); + let element: web_sys::HtmlElement = target.dyn_into().unwrap(); + let rect = element.get_bounding_client_rect(); + + let click_x = client_x - rect.left(); + let click_y = client_y - rect.top(); + + let sx = scale_x.get(); + let sy = scale_y.get(); + let ox = offset_x.get(); + let oy = offset_y.get(); + + if sx > 0.0 && sy > 0.0 { + let scene_x = (click_x - ox) / sx; + let scene_y = (click_y - oy) / sy; + + let scene_x = scene_x.max(0.0).min(scene_width as f64); + let scene_y = scene_y.max(0.0).min(scene_height as f64); - if let Some(prop_id) = clicked_prop { - on_prop_click.run(prop_id); - } else { on_move.run((scene_x, scene_y)); } } } }; - // Handle right-click for context menu on avatars + // Handle right-click for context menu on avatars or props (moderators only for props) #[cfg(feature = "hydrate")] let on_overlay_contextmenu = { let current_user_id = current_user_id.clone(); move |ev: web_sys::MouseEvent| { use wasm_bindgen::JsCast; - // Guests cannot message other users - don't show context menu + // Get click position + let client_x = ev.client_x() as f64; + let client_y = ev.client_y() as f64; + + // Check if moderator and if click is on a prop (for scale editing) + let is_mod = is_moderator.map(|s| s.get()).unwrap_or(false); + if is_mod { + let document = web_sys::window().unwrap().document().unwrap(); + + // Query prop canvases for pixel-perfect hit testing + if let Some(container) = document.query_selector(".props-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::() { + if let Some(prop_id_str) = canvas.get_attribute("data-prop-id") { + // Pixel-perfect hit test + if hit_test_canvas(&canvas, client_x, client_y) { + if let Ok(prop_id) = prop_id_str.parse::() { + // Found a prop - show prop context menu + ev.prevent_default(); + set_prop_context_menu_position.set((client_x, client_y)); + set_prop_context_menu_target.set(Some(prop_id)); + set_prop_context_menu_open.set(true); + + // Find the prop data for scale mode + if let Some(prop) = loose_props + .get() + .iter() + .find(|p| p.id == prop_id) + { + set_scale_mode_initial_scale.set(prop.scale); + // Get prop center from canvas bounding rect + let rect = canvas.get_bounding_client_rect(); + let prop_canvas_x = + rect.left() + rect.width() / 2.0; + let prop_canvas_y = + rect.top() + rect.height() / 2.0; + set_scale_mode_prop_center + .set((prop_canvas_x, prop_canvas_y)); + } + return; + } + } + } + } + } + } + } + } + + // Guests cannot message other users - don't show avatar context menu if is_guest.get() { return; } @@ -192,10 +282,6 @@ pub fn RealmSceneViewer( // Get current user identity for filtering let my_user_id = current_user_id.map(|s| s.get()).flatten(); - // Get click position - let client_x = ev.client_x() as f64; - let client_y = ev.client_y() as f64; - // Query all avatar canvases and check for hit let document = web_sys::window().unwrap().document().unwrap(); @@ -485,117 +571,9 @@ pub fn RealmSceneViewer( draw_bg.forget(); }); - // ========================================================= - // Props Effect - runs when loose_props or settings change - // ========================================================= - Effect::new(move |_| { - // Track signals - let current_props = loose_props.get(); - let current_pan_mode = is_pan_mode.get(); - let current_zoom = zoom_level.get(); - let current_enlarge = enlarge_props.get(); - - // Skip drawing if scale factors haven't been calculated yet - if !scales_ready.get() { - return; - } - - // Read scale factors inside the Effect (reactive context) before the closure - let sx = scale_x.get(); - let sy = scale_y.get(); - let ox = offset_x.get(); - let oy = offset_y.get(); - - let Some(canvas) = props_canvas_ref.get() else { - return; - }; - - let canvas_el: &web_sys::HtmlCanvasElement = &canvas; - let canvas_el = canvas_el.clone(); - - let draw_props_closure = Closure::once(Box::new(move || { - let canvas_width = canvas_el.width(); - let canvas_height = canvas_el.height(); - - if canvas_width == 0 || canvas_height == 0 { - return; - } - - if let Ok(Some(ctx)) = canvas_el.get_context("2d") { - let ctx: web_sys::CanvasRenderingContext2d = - ctx.dyn_into::().unwrap(); - - // Clear with transparency - ctx.clear_rect(0.0, 0.0, canvas_width as f64, canvas_height as f64); - - // Calculate prop size based on mode - let prop_size = calculate_prop_size( - current_pan_mode, - current_zoom, - current_enlarge, - sx, - sy, - scene_width_f, - scene_height_f, - ); - - // Draw loose props - draw_loose_props(&ctx, ¤t_props, sx, sy, ox, oy, prop_size); - } - }) as Box); - - let window = web_sys::window().unwrap(); - let _ = window.request_animation_frame(draw_props_closure.as_ref().unchecked_ref()); - draw_props_closure.forget(); - }); - - // ========================================================= - // Sync canvas sizes when mode or zoom changes - // ========================================================= - Effect::new(move |_| { - let current_pan_mode = is_pan_mode.get(); - let current_zoom = zoom_level.get(); - - // Wait for scales to be ready (background drawn) - if !scales_ready.get() { - return; - } - - if current_pan_mode { - // Pan mode: resize props and avatar canvases to match background - let canvas_width = (scene_width_f * current_zoom) as u32; - let canvas_height = (scene_height_f * current_zoom) as u32; - - if let Some(canvas) = props_canvas_ref.get() { - let canvas_el: &web_sys::HtmlCanvasElement = &canvas; - if canvas_el.width() != canvas_width || canvas_el.height() != canvas_height { - canvas_el.set_width(canvas_width); - canvas_el.set_height(canvas_height); - } - } - // Note: Avatar canvases are now individual elements that manage their own sizes - } else { - // Fit mode: sync props and avatar canvases to background canvas size - if let Some(bg_canvas) = bg_canvas_ref.get() { - let bg_el: &web_sys::HtmlCanvasElement = &bg_canvas; - let canvas_width = bg_el.width(); - let canvas_height = bg_el.height(); - - if canvas_width > 0 && canvas_height > 0 { - if let Some(canvas) = props_canvas_ref.get() { - let canvas_el: &web_sys::HtmlCanvasElement = &canvas; - if canvas_el.width() != canvas_width - || canvas_el.height() != canvas_height - { - canvas_el.set_width(canvas_width); - canvas_el.set_height(canvas_height); - } - } - // Note: Avatar canvases are now individual elements that manage their own sizes - } - } - } - }); + // Note: Props are now rendered as individual LoosePropCanvas components + // that manage their own positioning and sizing via CSS transforms. + // No shared props canvas or effect needed. // ========================================================= // Middle mouse button drag-to-pan (only in pan mode) @@ -865,7 +843,7 @@ pub fn RealmSceneViewer( m }); - // Calculate prop size based on current settings + // Calculate prop size based on current settings (for avatars, uses BASE_AVATAR_SCALE) let prop_size = Signal::derive(move || { let current_pan_mode = is_pan_mode.get(); let current_zoom = zoom_level.get(); @@ -876,16 +854,17 @@ pub fn RealmSceneViewer( // Reference scale factor for "enlarge props" mode let ref_scale = (scene_width_f / REFERENCE_WIDTH).max(scene_height_f / REFERENCE_HEIGHT); + // Avatar size uses BASE_AVATAR_SCALE (60px cells at native size) if current_pan_mode { if current_enlarge { - BASE_PROP_SIZE * ref_scale * current_zoom + BASE_PROP_SIZE * BASE_AVATAR_SCALE * ref_scale * current_zoom } else { - BASE_PROP_SIZE * current_zoom + BASE_PROP_SIZE * BASE_AVATAR_SCALE * current_zoom } } else if current_enlarge { - BASE_PROP_SIZE * ref_scale * sx.min(sy) + BASE_PROP_SIZE * BASE_AVATAR_SCALE * ref_scale * sx.min(sy) } else { - BASE_PROP_SIZE * sx.min(sy) + BASE_PROP_SIZE * BASE_AVATAR_SCALE * sx.min(sy) } }); @@ -938,13 +917,41 @@ pub fn RealmSceneViewer( style=move || canvas_style(0) aria-hidden="true" /> - // Props layer - loose props, redrawn on drop/pickup -