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

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