From 6e637a29cd74fbbc9402b4684172378c877921d65b1b33f7f4ec6ef92f0c671f Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Fri, 23 Jan 2026 17:11:12 -0600 Subject: [PATCH] feat: prop moving. --- crates/chattyness-db/src/models.rs | 6 + .../chattyness-db/src/queries/loose_props.rs | 186 +++++++++- crates/chattyness-db/src/ws_messages.rs | 22 ++ .../chattyness-user-ui/src/api/websocket.rs | 164 +++++++++ .../src/components/loose_prop_canvas.rs | 11 +- .../src/components/scene_viewer.rs | 329 +++++++++++++++--- crates/chattyness-user-ui/src/pages/realm.rs | 26 ++ 7 files changed, 688 insertions(+), 56 deletions(-) diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs index faf0258..3c0f37c 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -898,6 +898,12 @@ pub struct LooseProp { pub prop_name: String, /// Asset path for rendering (JOINed from source prop). pub prop_asset_path: String, + /// If true, only moderators can move/scale/pickup this prop. + #[serde(default)] + pub is_locked: bool, + /// User ID of the moderator who locked this prop. + #[serde(default)] + pub locked_by: Option, } /// A server-wide prop (global library). diff --git a/crates/chattyness-db/src/queries/loose_props.rs b/crates/chattyness-db/src/queries/loose_props.rs index c807a4c..b1de987 100644 --- a/crates/chattyness-db/src/queries/loose_props.rs +++ b/crates/chattyness-db/src/queries/loose_props.rs @@ -51,7 +51,9 @@ pub async fn list_channel_loose_props<'e>( 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 + COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path, + lp.is_locked, + lp.locked_by 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 @@ -236,6 +238,8 @@ pub async fn drop_prop_to_canvas<'e>( created_at, prop_name, prop_asset_path, + is_locked: false, + locked_by: None, }) } _ => { @@ -364,7 +368,9 @@ pub async fn update_loose_prop_scale<'e>( scale, dropped_by, expires_at, - created_at + created_at, + is_locked, + locked_by ) SELECT u.id, @@ -378,7 +384,9 @@ pub async fn update_loose_prop_scale<'e>( 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 + COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path, + u.is_locked, + u.locked_by 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 @@ -412,7 +420,9 @@ pub async fn get_loose_prop_by_id<'e>( 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 + COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path, + lp.is_locked, + lp.locked_by 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 @@ -427,6 +437,174 @@ pub async fn get_loose_prop_by_id<'e>( Ok(prop) } +/// Move a loose prop to a new position. +pub async fn move_loose_prop<'e>( + executor: impl PgExecutor<'e>, + loose_prop_id: Uuid, + x: f64, + y: f64, +) -> Result { + let prop = sqlx::query_as::<_, LooseProp>( + r#" + WITH updated AS ( + UPDATE scene.loose_props + SET position = public.make_virtual_point($2::real, $3::real) + 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, + is_locked, + locked_by + ) + 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, + u.is_locked, + u.locked_by + 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(x as f32) + .bind(y as f32) + .fetch_optional(executor) + .await? + .ok_or_else(|| AppError::NotFound("Loose prop not found or has expired".to_string()))?; + + Ok(prop) +} + +/// Lock a loose prop (moderator only). +pub async fn lock_loose_prop<'e>( + executor: impl PgExecutor<'e>, + loose_prop_id: Uuid, + locked_by: Uuid, +) -> Result { + let prop = sqlx::query_as::<_, LooseProp>( + r#" + WITH updated AS ( + UPDATE scene.loose_props + SET is_locked = true, locked_by = $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, + is_locked, + locked_by + ) + 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, + u.is_locked, + u.locked_by + 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(locked_by) + .fetch_optional(executor) + .await? + .ok_or_else(|| AppError::NotFound("Loose prop not found or has expired".to_string()))?; + + Ok(prop) +} + +/// Unlock a loose prop (moderator only). +pub async fn unlock_loose_prop<'e>( + executor: impl PgExecutor<'e>, + loose_prop_id: Uuid, +) -> Result { + let prop = sqlx::query_as::<_, LooseProp>( + r#" + WITH updated AS ( + UPDATE scene.loose_props + SET is_locked = false, locked_by = NULL + 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, + is_locked, + locked_by + ) + 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, + u.is_locked, + u.locked_by + 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) + .fetch_optional(executor) + .await? + .ok_or_else(|| AppError::NotFound("Loose prop not found or has expired".to_string()))?; + + Ok(prop) +} + /// Delete expired loose props. /// /// Returns the number of props deleted. diff --git a/crates/chattyness-db/src/ws_messages.rs b/crates/chattyness-db/src/ws_messages.rs index 77a8fbc..00ed3db 100644 --- a/crates/chattyness-db/src/ws_messages.rs +++ b/crates/chattyness-db/src/ws_messages.rs @@ -112,6 +112,28 @@ pub enum ClientMessage { /// New scale factor (0.1 - 10.0). scale: f32, }, + + /// Move a loose prop to a new position. + MoveProp { + /// The loose prop ID to move. + loose_prop_id: Uuid, + /// New X coordinate in scene space. + x: f64, + /// New Y coordinate in scene space. + y: f64, + }, + + /// Lock a loose prop (moderator only). + LockProp { + /// The loose prop ID to lock. + loose_prop_id: Uuid, + }, + + /// Unlock a loose prop (moderator only). + UnlockProp { + /// The loose prop ID to unlock. + loose_prop_id: Uuid, + }, } /// Server-to-client WebSocket messages. diff --git a/crates/chattyness-user-ui/src/api/websocket.rs b/crates/chattyness-user-ui/src/api/websocket.rs index 93217e3..6636fcf 100644 --- a/crates/chattyness-user-ui/src/api/websocket.rs +++ b/crates/chattyness-user-ui/src/api/websocket.rs @@ -712,6 +712,25 @@ async fn handle_socket( } } ClientMessage::PickUpProp { loose_prop_id } => { + // Check if prop is locked + 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 let Ok(Some(prop)) = loose_props::get_loose_prop_by_id(&mut *recv_conn, loose_prop_id).await { + if prop.is_locked && !is_mod { + let _ = direct_tx.send(ServerMessage::Error { + code: "PROP_LOCKED".to_string(), + message: "This prop is locked and cannot be picked up".to_string(), + }).await; + continue; + } + } + match loose_props::pick_up_loose_prop( &mut *recv_conn, loose_prop_id, @@ -1437,6 +1456,17 @@ async fn handle_socket( continue; } + // Check if prop is locked (for non-mods) + if let Ok(Some(prop)) = loose_props::get_loose_prop_by_id(&mut *recv_conn, loose_prop_id).await { + if prop.is_locked && !is_mod { + let _ = direct_tx.send(ServerMessage::Error { + code: "PROP_LOCKED".to_string(), + message: "This prop is locked and cannot be modified".to_string(), + }).await; + continue; + } + } + // Update the prop scale match loose_props::update_loose_prop_scale( &mut *recv_conn, @@ -1463,6 +1493,140 @@ async fn handle_socket( } } } + ClientMessage::MoveProp { loose_prop_id, x, y } => { + // Check if user is a moderator (needed for locked props) + 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 + } + }; + + // Check if prop is locked + if let Ok(Some(prop)) = loose_props::get_loose_prop_by_id(&mut *recv_conn, loose_prop_id).await { + if prop.is_locked && !is_mod { + let _ = direct_tx.send(ServerMessage::Error { + code: "PROP_LOCKED".to_string(), + message: "This prop is locked and cannot be moved".to_string(), + }).await; + continue; + } + } + + // Move the prop + match loose_props::move_loose_prop( + &mut *recv_conn, + loose_prop_id, + x, + y, + ).await { + Ok(updated_prop) => { + #[cfg(debug_assertions)] + tracing::debug!( + "[WS] User {} moved prop {} to ({}, {})", + user_id, + loose_prop_id, + x, + y + ); + // Broadcast the updated prop to all users in the channel + let _ = tx.send(ServerMessage::PropRefresh { prop: updated_prop }); + } + Err(e) => { + tracing::error!("[WS] Move prop failed: {:?}", e); + let _ = direct_tx.send(ServerMessage::Error { + code: "MOVE_PROP_FAILED".to_string(), + message: format!("{:?}", e), + }).await; + } + } + } + ClientMessage::LockProp { loose_prop_id } => { + // 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 lock props".to_string(), + }).await; + continue; + } + + // Lock the prop + match loose_props::lock_loose_prop( + &mut *recv_conn, + loose_prop_id, + user_id, + ).await { + Ok(updated_prop) => { + #[cfg(debug_assertions)] + tracing::debug!( + "[WS] User {} locked prop {}", + user_id, + loose_prop_id + ); + // Broadcast the updated prop to all users in the channel + let _ = tx.send(ServerMessage::PropRefresh { prop: updated_prop }); + } + Err(e) => { + tracing::error!("[WS] Lock prop failed: {:?}", e); + let _ = direct_tx.send(ServerMessage::Error { + code: "LOCK_PROP_FAILED".to_string(), + message: format!("{:?}", e), + }).await; + } + } + } + ClientMessage::UnlockProp { loose_prop_id } => { + // 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 unlock props".to_string(), + }).await; + continue; + } + + // Unlock the prop + match loose_props::unlock_loose_prop( + &mut *recv_conn, + loose_prop_id, + ).await { + Ok(updated_prop) => { + #[cfg(debug_assertions)] + tracing::debug!( + "[WS] User {} unlocked prop {}", + user_id, + loose_prop_id + ); + // Broadcast the updated prop to all users in the channel + let _ = tx.send(ServerMessage::PropRefresh { prop: updated_prop }); + } + Err(e) => { + tracing::error!("[WS] Unlock prop failed: {:?}", e); + let _ = direct_tx.send(ServerMessage::Error { + code: "UNLOCK_PROP_FAILED".to_string(), + message: format!("{:?}", e), + }).await; + } + } + } } } Message::Close(close_frame) => { diff --git a/crates/chattyness-user-ui/src/components/loose_prop_canvas.rs b/crates/chattyness-user-ui/src/components/loose_prop_canvas.rs index 536b00b..f93c00b 100644 --- a/crates/chattyness-user-ui/src/components/loose_prop_canvas.rs +++ b/crates/chattyness-user-ui/src/components/loose_prop_canvas.rs @@ -65,6 +65,13 @@ pub fn LoosePropCanvas( let canvas_x = screen_x - prop_size / 2.0; let canvas_y = screen_y - prop_size / 2.0; + // Add amber dashed border for locked props + let border_style = if p.is_locked { + "border: 2px dashed #f59e0b; box-sizing: border-box;" + } else { + "" + }; + format!( "position: absolute; \ left: 0; top: 0; \ @@ -72,8 +79,8 @@ pub fn LoosePropCanvas( z-index: {}; \ pointer-events: auto; \ width: {}px; \ - height: {}px;", - canvas_x, canvas_y, z_index, prop_size, prop_size + height: {}px; {}", + canvas_x, canvas_y, z_index, prop_size, prop_size, border_style ) }; diff --git a/crates/chattyness-user-ui/src/components/scene_viewer.rs b/crates/chattyness-user-ui/src/components/scene_viewer.rs index 2e348b7..d69bf27 100644 --- a/crates/chattyness-user-ui/src/components/scene_viewer.rs +++ b/crates/chattyness-user-ui/src/components/scene_viewer.rs @@ -68,6 +68,12 @@ pub fn RealmSceneViewer( /// Callback when prop scale is updated (moderator only). #[prop(optional, into)] on_prop_scale_update: Option>, + /// Callback when prop is moved to new position. + #[prop(optional, into)] + on_prop_move: Option>, + /// Callback when prop lock is toggled (moderator only). + #[prop(optional, into)] + on_prop_lock_toggle: Option>, ) -> impl IntoView { let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false)); // Use default settings if none provided @@ -146,6 +152,16 @@ pub fn RealmSceneViewer( // 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)); + // Move mode state (when moving prop to new position) + let (move_mode_active, set_move_mode_active) = signal(false); + let (move_mode_prop_id, set_move_mode_prop_id) = signal(Option::::None); + // Preview position in scene coordinates + let (move_mode_preview_position, set_move_mode_preview_position) = signal((0.0_f64, 0.0_f64)); + // Store the target prop's locked state and scale for move mode + let (move_mode_prop_scale, set_move_mode_prop_scale) = signal(1.0_f32); + // Store target prop is_locked for context menu + let (prop_context_is_locked, set_prop_context_is_locked) = signal(false); + // Handle overlay click for movement or prop pickup // Uses pixel-perfect hit testing on prop canvases #[cfg(feature = "hydrate")] @@ -213,7 +229,7 @@ pub fn RealmSceneViewer( } }; - // Handle right-click for context menu on avatars or props (moderators only for props) + // Handle right-click for context menu on avatars or props #[cfg(feature = "hydrate")] let on_overlay_contextmenu = { let current_user_id = current_user_id.clone(); @@ -224,48 +240,48 @@ pub fn RealmSceneViewer( 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(); + // Check if click is on a prop - any user can access prop context menu + // (menu items are filtered based on lock status and mod status) + 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(); + // 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); + 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; + // Find the prop data for scale mode and lock state + if let Some(prop) = loose_props + .get() + .iter() + .find(|p| p.id == prop_id) + { + set_scale_mode_initial_scale.set(prop.scale); + set_prop_context_is_locked.set(prop.is_locked); + set_move_mode_prop_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; } } } @@ -778,12 +794,12 @@ pub fn RealmSceneViewer( // Center canvas if smaller than viewport in both dimensions if canvas_w <= vp_w && canvas_h <= vp_h { - "scene-container w-full overflow-auto flex justify-center items-center" + "scene-container scene-viewer-container w-full overflow-auto flex justify-center items-center" } else { - "scene-container w-full overflow-auto" + "scene-container scene-viewer-container w-full overflow-auto" } } else { - "scene-container w-full h-full flex justify-center items-center" + "scene-container scene-viewer-container w-full h-full flex justify-center items-center" } }; @@ -1103,20 +1119,48 @@ pub fn RealmSceneViewer( }) /> - // Context menu for prop interactions (moderators only) + // Context menu for prop interactions // Scale indicator @@ -1242,6 +1311,166 @@ pub fn RealmSceneViewer( } }} + + // Move mode overlay (shown when moving prop) + + {move || { + let prop_id = move_mode_prop_id.get(); + let (preview_x, preview_y) = move_mode_preview_position.get(); + let prop_scale = move_mode_prop_scale.get(); + + // Find the prop to get its asset path + let prop_data = prop_id.and_then(|id| { + loose_props.get().iter().find(|p| p.id == id).cloned() + }); + + // Convert scene coordinates to screen coordinates for preview + // Match LoosePropCanvas size calculation + let base_size = prop_size.get(); + let prop_scale_ratio = BASE_PROP_SCALE / BASE_AVATAR_SCALE; + let ghost_size = base_size * prop_scale_ratio * prop_scale as f64; + + view! { +
0.0 && sy > 0.0 { + let scene_x = (viewer_x - ox) / sx; + let scene_y = (viewer_y - oy) / sy; + set_move_mode_preview_position.set((scene_x, scene_y)); + } + } + } + #[cfg(not(feature = "hydrate"))] + let _ = ev; + } + on:click=move |ev| { + #[cfg(feature = "hydrate")] + { + // Apply the move + if let (Some(prop_id), Some(ref callback)) = (move_mode_prop_id.get(), on_prop_move.as_ref()) { + let (final_x, final_y) = move_mode_preview_position.get(); + callback.run((prop_id, final_x, final_y)); + } + // Exit move mode + set_move_mode_active.set(false); + set_move_mode_prop_id.set(None); + } + #[cfg(not(feature = "hydrate"))] + let _ = ev; + } + on:keydown=move |ev| { + #[cfg(feature = "hydrate")] + { + use wasm_bindgen::JsCast; + let ev: web_sys::KeyboardEvent = ev.dyn_into().unwrap(); + if ev.key() == "Escape" { + // Cancel move mode + ev.prevent_default(); + set_move_mode_active.set(false); + set_move_mode_prop_id.set(None); + } + } + #[cfg(not(feature = "hydrate"))] + let _ = ev; + } + tabindex="0" + > + // Ghost prop at cursor position (follows mouse via scene coords converted back) + {move || { + #[cfg(feature = "hydrate")] + { + use super::canvas_utils::normalize_asset_path; + + if let Some(ref prop) = prop_data { + // Convert scene coordinates back to viewport coordinates + let (preview_x, preview_y) = move_mode_preview_position.get(); + let sx = scale_x.get(); + let sy = scale_y.get(); + let ox = offset_x.get(); + let oy = offset_y.get(); + + // Get scene viewer position in viewport + let document = web_sys::window().unwrap().document().unwrap(); + let viewer_offset = document + .query_selector(".scene-viewer-container") + .ok() + .flatten() + .map(|v| { + let rect = v.get_bounding_client_rect(); + (rect.left(), rect.top()) + }) + .unwrap_or((0.0, 0.0)); + + // Convert scene coords to viewer-relative coords + let viewer_x = preview_x * sx + ox; + let viewer_y = preview_y * sy + oy; + + // Convert to viewport coords for absolute positioning in fixed overlay + let viewport_x = viewer_x + viewer_offset.0; + let viewport_y = viewer_y + viewer_offset.1; + + // Normalize asset path for proper loading + let normalized_path = normalize_asset_path(&prop.prop_asset_path); + + view! { +
+ // Show prop image as ghost + +
+ }.into_any() + } else { + ().into_any() + } + } + #[cfg(not(feature = "hydrate"))] + { + ().into_any() + } + }} + // Instructions +
+ "Click to place • Escape to cancel" +
+
+ } + }} +
} diff --git a/crates/chattyness-user-ui/src/pages/realm.rs b/crates/chattyness-user-ui/src/pages/realm.rs index 22f478a..5686a4f 100644 --- a/crates/chattyness-user-ui/src/pages/realm.rs +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -1220,6 +1220,30 @@ pub fn RealmPage() -> impl IntoView { } }); }); + #[cfg(feature = "hydrate")] + let ws_for_prop_move = ws_sender_clone.clone(); + let on_prop_move_cb = Callback::new(move |(prop_id, x, y): (Uuid, f64, f64)| { + #[cfg(feature = "hydrate")] + ws_for_prop_move.with_value(|sender| { + if let Some(send_fn) = sender { + send_fn(ClientMessage::MoveProp { loose_prop_id: prop_id, x, y }); + } + }); + }); + #[cfg(feature = "hydrate")] + let ws_for_prop_lock = ws_sender_clone.clone(); + let on_prop_lock_toggle_cb = Callback::new(move |(prop_id, lock): (Uuid, bool)| { + #[cfg(feature = "hydrate")] + ws_for_prop_lock.with_value(|sender| { + if let Some(send_fn) = sender { + if lock { + send_fn(ClientMessage::LockProp { loose_prop_id: prop_id }); + } else { + send_fn(ClientMessage::UnlockProp { loose_prop_id: prop_id }); + } + } + }); + }); view! {
impl IntoView { on_whisper_request=on_whisper_request_cb is_moderator=is_moderator_signal on_prop_scale_update=on_prop_scale_update_cb + on_prop_move=on_prop_move_cb + on_prop_lock_toggle=on_prop_lock_toggle_cb />