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