feat: prop moving.

This commit is contained in:
Evan Carroll 2026-01-23 17:11:12 -06:00
parent a2841c413d
commit 6e637a29cd
7 changed files with 688 additions and 56 deletions

View file

@ -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<Uuid>,
}
/// A server-wide prop (global library).

View file

@ -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<LooseProp, AppError> {
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<LooseProp, AppError> {
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<LooseProp, AppError> {
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.

View file

@ -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.

View file

@ -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) => {

View file

@ -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
)
};

View file

@ -68,6 +68,12 @@ pub fn RealmSceneViewer(
/// Callback when prop scale is updated (moderator only).
#[prop(optional, into)]
on_prop_scale_update: Option<Callback<(Uuid, f32)>>,
/// Callback when prop is moved to new position.
#[prop(optional, into)]
on_prop_move: Option<Callback<(Uuid, f64, f64)>>,
/// Callback when prop lock is toggled (moderator only).
#[prop(optional, into)]
on_prop_lock_toggle: Option<Callback<(Uuid, bool)>>,
) -> 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::<Uuid>::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,9 +240,8 @@ 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 {
// 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
@ -248,13 +263,15 @@ pub fn RealmSceneViewer(
set_prop_context_menu_target.set(Some(prop_id));
set_prop_context_menu_open.set(true);
// Find the prop data for scale mode
// 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 =
@ -272,7 +289,6 @@ pub fn RealmSceneViewer(
}
}
}
}
// Guests cannot message other users - don't show avatar context menu
if is_guest.get() {
@ -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
<ContextMenu
open=Signal::derive(move || prop_context_menu_open.get())
position=Signal::derive(move || prop_context_menu_position.get())
header=Signal::derive(move || Some("Prop".to_string()))
items=Signal::derive(move || {
vec![
ContextMenuItem {
let is_mod = is_moderator.map(|s| s.get()).unwrap_or(false);
let is_locked = prop_context_is_locked.get();
let mut items = Vec::new();
// Move: shown for unlocked props (any user) or locked props (mods only)
if !is_locked || is_mod {
items.push(ContextMenuItem {
label: "Move".to_string(),
action: "move".to_string(),
});
}
// Moderator-only actions
if is_mod {
items.push(ContextMenuItem {
label: "Set Scale".to_string(),
action: "set_scale".to_string(),
},
]
});
if is_locked {
items.push(ContextMenuItem {
label: "Unlock".to_string(),
action: "unlock".to_string(),
});
} else {
items.push(ContextMenuItem {
label: "Lock".to_string(),
action: "lock".to_string(),
});
}
}
items
})
on_select=Callback::new({
let on_prop_lock_toggle = on_prop_lock_toggle.clone();
move |action: String| {
if action == "set_scale" {
if let Some(prop_id) = prop_context_menu_target.get() {
@ -1125,6 +1169,28 @@ pub fn RealmSceneViewer(
set_scale_mode_preview_scale.set(scale_mode_initial_scale.get());
set_scale_mode_active.set(true);
}
} else if action == "move" {
if let Some(prop_id) = prop_context_menu_target.get() {
// Enter move mode
set_move_mode_prop_id.set(Some(prop_id));
// Initialize preview position to current prop position
if let Some(prop) = loose_props.get().iter().find(|p| p.id == prop_id) {
set_move_mode_preview_position.set((prop.position_x, prop.position_y));
}
set_move_mode_active.set(true);
}
} else if action == "lock" {
if let Some(prop_id) = prop_context_menu_target.get() {
if let Some(ref callback) = on_prop_lock_toggle {
callback.run((prop_id, true)); // Lock
}
}
} else if action == "unlock" {
if let Some(prop_id) = prop_context_menu_target.get() {
if let Some(ref callback) = on_prop_lock_toggle {
callback.run((prop_id, false)); // Unlock
}
}
}
// Close the menu
set_prop_context_menu_open.set(false);
@ -1206,8 +1272,11 @@ pub fn RealmSceneViewer(
// Visual feedback: dashed border around prop
{move || {
if let Some(ref _prop) = prop_data {
let prop_size = BASE_PROP_SIZE * BASE_PROP_SCALE * preview_scale as f64;
let half_size = prop_size / 2.0;
// Match LoosePropCanvas size calculation
let base_size = prop_size.get();
let prop_scale_ratio = BASE_PROP_SCALE / BASE_AVATAR_SCALE;
let preview_prop_size = base_size * prop_scale_ratio * preview_scale as f64;
let half_size = preview_prop_size / 2.0;
view! {
<div
class="absolute pointer-events-none"
@ -1216,7 +1285,7 @@ pub fn RealmSceneViewer(
border: 2px dashed #fbbf24; \
transform: translate(-50%, -50%); \
box-sizing: border-box;",
center_x, center_y, prop_size, prop_size
center_x, center_y, preview_prop_size, preview_prop_size
)
/>
// Scale indicator
@ -1242,6 +1311,166 @@ pub fn RealmSceneViewer(
}
}}
</Show>
// Move mode overlay (shown when moving prop)
<Show when=move || move_mode_active.get()>
{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! {
<div
class="fixed inset-0 z-50 cursor-crosshair"
style="background: rgba(0,0,0,0.3);"
on:mousemove=move |ev| {
#[cfg(feature = "hydrate")]
{
use wasm_bindgen::JsCast;
let ev: web_sys::MouseEvent = ev.dyn_into().unwrap();
let mouse_x = ev.client_x() as f64;
let mouse_y = ev.client_y() as f64;
// Get scene viewer's position to convert client coords to viewer-relative coords
let document = web_sys::window().unwrap().document().unwrap();
if let Some(viewer) = document.query_selector(".scene-viewer-container").ok().flatten() {
let rect = viewer.get_bounding_client_rect();
let viewer_x = mouse_x - rect.left();
let viewer_y = mouse_y - rect.top();
// Convert viewer-relative coordinates to scene coordinates
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 = (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! {
<div
class="absolute pointer-events-none"
style=format!(
"left: {}px; top: {}px; width: {}px; height: {}px; \
transform: translate(-50%, -50%); \
border: 2px dashed #10b981; \
background: rgba(16, 185, 129, 0.2); \
box-sizing: border-box;",
viewport_x, viewport_y, ghost_size, ghost_size
)
>
// Show prop image as ghost
<img
src=normalized_path
class="w-full h-full object-contain opacity-50"
style="pointer-events: none;"
/>
</div>
}.into_any()
} else {
().into_any()
}
}
#[cfg(not(feature = "hydrate"))]
{
().into_any()
}
}}
// Instructions
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 bg-gray-900/90 text-white px-4 py-2 rounded text-sm">
"Click to place • Escape to cancel"
</div>
</div>
}
}}
</Show>
</div>
</div>
}

View file

@ -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! {
<div class="relative w-full">
<RealmSceneViewer
@ -1243,6 +1267,8 @@ pub fn RealmPage() -> 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
/>
<div class="absolute bottom-0 left-0 right-0 z-10 pb-4 px-4 pointer-events-none">
<ChatInput