306 lines
15 KiB
Rust
306 lines
15 KiB
Rust
//! Overlay components for prop editing in scene viewer.
|
|
//!
|
|
//! Contains ScaleOverlay and MoveOverlay components used when
|
|
//! moderators edit prop scale or position.
|
|
|
|
use leptos::prelude::*;
|
|
use uuid::Uuid;
|
|
|
|
use chattyness_db::models::LooseProp;
|
|
|
|
use super::super::settings::{BASE_AVATAR_SCALE, BASE_PROP_SCALE};
|
|
#[cfg(feature = "hydrate")]
|
|
use super::super::canvas_utils::normalize_asset_path;
|
|
|
|
/// Overlay shown when editing a prop's scale.
|
|
///
|
|
/// Allows dragging from the prop center to adjust scale.
|
|
#[component]
|
|
pub fn ScaleOverlay(
|
|
#[prop(into)] active: Signal<bool>,
|
|
#[prop(into)] prop_id: Signal<Option<Uuid>>,
|
|
#[prop(into)] preview_scale: RwSignal<f32>,
|
|
#[prop(into)] prop_center: Signal<(f64, f64)>,
|
|
#[prop(into)] loose_props: Signal<Vec<LooseProp>>,
|
|
#[prop(into)] prop_size: Signal<f64>,
|
|
#[prop(optional)] on_apply: Option<Callback<(Uuid, f32)>>,
|
|
#[prop(optional)] on_cancel: Option<Callback<()>>,
|
|
) -> impl IntoView {
|
|
let (_center_x, _center_y) = prop_center.get_untracked();
|
|
|
|
view! {
|
|
<Show when=move || active.get()>
|
|
{move || {
|
|
let current_prop_id = prop_id.get();
|
|
let current_preview_scale = preview_scale.get();
|
|
let (center_x, center_y) = prop_center.get();
|
|
|
|
// Find the prop to get its dimensions
|
|
let prop_data = current_prop_id.and_then(|id| {
|
|
loose_props.get().iter().find(|p| p.id == id).cloned()
|
|
});
|
|
|
|
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;
|
|
let (cx, cy) = prop_center.get();
|
|
let dx = mouse_x - cx;
|
|
let dy = mouse_y - cy;
|
|
let distance = (dx * dx + dy * dy).sqrt();
|
|
// Scale formula: distance / 40 gives 1x at 40px
|
|
let new_scale = (distance / 40.0).clamp(0.1, 10.0) as f32;
|
|
preview_scale.set(new_scale);
|
|
}
|
|
#[cfg(not(feature = "hydrate"))]
|
|
let _ = ev;
|
|
}
|
|
on:mouseup=move |ev| {
|
|
#[cfg(feature = "hydrate")]
|
|
{
|
|
if let (Some(pid), Some(ref callback)) = (prop_id.get(), on_apply.as_ref()) {
|
|
let final_scale = preview_scale.get();
|
|
callback.run((pid, final_scale));
|
|
}
|
|
if let Some(ref callback) = on_cancel {
|
|
callback.run(());
|
|
}
|
|
}
|
|
#[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" {
|
|
ev.prevent_default();
|
|
if let Some(ref callback) = on_cancel {
|
|
callback.run(());
|
|
}
|
|
}
|
|
}
|
|
#[cfg(not(feature = "hydrate"))]
|
|
let _ = ev;
|
|
}
|
|
tabindex="0"
|
|
>
|
|
// Visual feedback: dashed border around prop
|
|
{move || {
|
|
if let Some(ref _prop) = prop_data {
|
|
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 * current_preview_scale as f64;
|
|
let half_size = preview_prop_size / 2.0;
|
|
view! {
|
|
<div
|
|
class="absolute pointer-events-none"
|
|
style=format!(
|
|
"left: {}px; top: {}px; width: {}px; height: {}px; \
|
|
border: 2px dashed #fbbf24; \
|
|
transform: translate(-50%, -50%); \
|
|
box-sizing: border-box;",
|
|
center_x, center_y, preview_prop_size, preview_prop_size
|
|
)
|
|
/>
|
|
// Scale indicator
|
|
<div
|
|
class="absolute bg-gray-900/90 text-yellow-400 px-2 py-1 rounded text-sm font-mono pointer-events-none"
|
|
style=format!(
|
|
"left: {}px; top: {}px; transform: translate(-50%, 8px);",
|
|
center_x, center_y + half_size
|
|
)
|
|
>
|
|
{format!("{:.2}x", current_preview_scale)}
|
|
</div>
|
|
}.into_any()
|
|
} else {
|
|
().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">
|
|
"Drag to resize • Release to apply • Escape to cancel"
|
|
</div>
|
|
</div>
|
|
}
|
|
}}
|
|
</Show>
|
|
}
|
|
}
|
|
|
|
/// Overlay shown when moving a prop to a new position.
|
|
#[component]
|
|
pub fn MoveOverlay(
|
|
#[prop(into)] active: Signal<bool>,
|
|
#[prop(into)] prop_id: Signal<Option<Uuid>>,
|
|
#[prop(into)] preview_position: RwSignal<(f64, f64)>,
|
|
#[prop(into)] prop_scale: Signal<f32>,
|
|
#[prop(into)] loose_props: Signal<Vec<LooseProp>>,
|
|
#[prop(into)] prop_size: Signal<f64>,
|
|
#[prop(into)] scale_x: Signal<f64>,
|
|
#[prop(into)] scale_y: Signal<f64>,
|
|
#[prop(into)] offset_x: Signal<f64>,
|
|
#[prop(into)] offset_y: Signal<f64>,
|
|
#[prop(optional)] on_apply: Option<Callback<(Uuid, f64, f64)>>,
|
|
#[prop(optional)] on_cancel: Option<Callback<()>>,
|
|
) -> impl IntoView {
|
|
view! {
|
|
<Show when=move || active.get()>
|
|
{move || {
|
|
let current_prop_id = prop_id.get();
|
|
let (_preview_x, _preview_y) = preview_position.get();
|
|
let current_prop_scale = prop_scale.get();
|
|
|
|
// Find the prop to get its asset path
|
|
let prop_data = current_prop_id.and_then(|id| {
|
|
loose_props.get().iter().find(|p| p.id == id).cloned()
|
|
});
|
|
|
|
// Calculate ghost size
|
|
let base_size = prop_size.get();
|
|
let prop_scale_ratio = BASE_PROP_SCALE / BASE_AVATAR_SCALE;
|
|
let ghost_size = base_size * prop_scale_ratio * current_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
|
|
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();
|
|
|
|
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;
|
|
preview_position.set((scene_x, scene_y));
|
|
}
|
|
}
|
|
}
|
|
#[cfg(not(feature = "hydrate"))]
|
|
let _ = ev;
|
|
}
|
|
on:click=move |ev| {
|
|
#[cfg(feature = "hydrate")]
|
|
{
|
|
if let (Some(pid), Some(ref callback)) = (prop_id.get(), on_apply.as_ref()) {
|
|
let (final_x, final_y) = preview_position.get();
|
|
callback.run((pid, final_x, final_y));
|
|
}
|
|
if let Some(ref callback) = on_cancel {
|
|
callback.run(());
|
|
}
|
|
}
|
|
#[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" {
|
|
ev.prevent_default();
|
|
if let Some(ref callback) = on_cancel {
|
|
callback.run(());
|
|
}
|
|
}
|
|
}
|
|
#[cfg(not(feature = "hydrate"))]
|
|
let _ = ev;
|
|
}
|
|
tabindex="0"
|
|
>
|
|
// Ghost prop at cursor position
|
|
{move || {
|
|
#[cfg(feature = "hydrate")]
|
|
{
|
|
if let Some(ref prop) = prop_data {
|
|
let (preview_x, preview_y) = 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 viewport coords
|
|
let viewer_x = preview_x * sx + ox;
|
|
let viewer_y = preview_y * sy + oy;
|
|
let viewport_x = viewer_x + viewer_offset.0;
|
|
let viewport_y = viewer_y + viewer_offset.1;
|
|
|
|
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
|
|
)
|
|
>
|
|
<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>
|
|
}
|
|
}
|