feat: prop moving.
This commit is contained in:
parent
a2841c413d
commit
6e637a29cd
7 changed files with 688 additions and 56 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,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::<web_sys::HtmlCanvasElement>() {
|
||||
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::<Uuid>() {
|
||||
// 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::<web_sys::HtmlCanvasElement>() {
|
||||
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::<Uuid>() {
|
||||
// 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
|
||||
<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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue