clean up scene_viewer

This commit is contained in:
Evan Carroll 2026-01-23 19:41:33 -06:00
parent 2afe43547d
commit fe40fd32ab
4 changed files with 958 additions and 958 deletions

View file

@ -0,0 +1,306 @@
//! 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>
}
}