chattyness/crates/chattyness-user-ui/src/components/scene_viewer.rs

746 lines
34 KiB
Rust

//! Scene viewer component for displaying realm scenes with avatars.
//!
//! Uses layered canvases for efficient rendering:
//! - Background canvas: Static, drawn once when scene loads
//! - Avatar canvas: Dynamic, redrawn when members change
//!
//! Supports two rendering modes:
//! - **Fit mode** (default): Background scales to fit viewport with letterboxing
//! - **Pan mode**: Canvas at native resolution with optional zoom, user can scroll
mod coordinates;
mod effects;
mod overlays;
pub use coordinates::*;
pub use overlays::*;
use std::collections::HashMap;
use leptos::prelude::*;
use uuid::Uuid;
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene};
use super::avatar::{Avatar, ScreenBounds, SceneTransform, member_key};
use super::constants::{Z_AVATAR_BASE, Z_AVATARS_CONTAINER, Z_CLICK_OVERLAY, Z_FADING_AVATAR, Z_LOOSE_PROP, Z_PROPS};
#[cfg(feature = "hydrate")]
use super::canvas_utils::hit_test_canvas;
use super::chat_types::ActiveBubble;
use super::context_menu::{ContextMenu, ContextMenuItem};
use super::loose_prop_canvas::LoosePropCanvas;
use super::settings::{
BASE_AVATAR_SCALE, BASE_PROP_SIZE, REFERENCE_HEIGHT, REFERENCE_WIDTH, ViewerSettings,
calculate_min_zoom,
};
use super::ws_client::FadingMember;
use crate::utils::parse_bounds_dimensions;
/// Scene viewer component for displaying a realm scene with avatars.
///
/// Uses three layered canvases:
/// - Background canvas (z-index 0): Static background, drawn once
/// - Props canvas (z-index 1): Loose props, redrawn on drop/pickup
/// - Avatar canvas (z-index 2): Transparent, redrawn on member updates
#[component]
pub fn RealmSceneViewer(
scene: Scene,
#[allow(unused)]
realm_slug: String,
#[prop(into)] members: Signal<Vec<ChannelMemberWithAvatar>>,
#[prop(into)] active_bubbles: Signal<HashMap<Uuid, ActiveBubble>>,
#[prop(into)] loose_props: Signal<Vec<LooseProp>>,
#[prop(into)] on_move: Callback<(f64, f64)>,
#[prop(into)] on_prop_click: Callback<Uuid>,
#[prop(optional)] settings: Option<Signal<ViewerSettings>>,
#[prop(optional)] on_zoom_change: Option<Callback<f64>>,
#[prop(optional, into)] fading_members: Option<Signal<Vec<FadingMember>>>,
#[prop(optional, into)] current_user_id: Option<Signal<Option<Uuid>>>,
#[prop(optional, into)] is_guest: Option<Signal<bool>>,
#[prop(optional, into)] on_whisper_request: Option<Callback<String>>,
#[prop(optional, into)] is_moderator: Option<Signal<bool>>,
#[prop(optional, into)] on_prop_scale_update: Option<Callback<(Uuid, f32)>>,
#[prop(optional, into)] on_prop_move: Option<Callback<(Uuid, f64, f64)>>,
#[prop(optional, into)] on_prop_lock_toggle: Option<Callback<(Uuid, bool)>>,
#[prop(optional, into)] on_prop_delete: Option<Callback<Uuid>>,
#[prop(optional, into)] on_view_prop_state: Option<Callback<Uuid>>,
) -> impl IntoView {
let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false));
let settings = settings.unwrap_or_else(|| Signal::derive(ViewerSettings::default));
let dimensions = parse_bounds_dimensions(&scene.bounds_wkt);
let (scene_width, scene_height) = dimensions.unwrap_or((800, 600));
let scene_width_f = scene_width as f64;
let scene_height_f = scene_height as f64;
// Derived signals for rendering mode
let is_pan_mode = Signal::derive(move || settings.get().panning_enabled);
let (viewport_dimensions, set_viewport_dimensions) = signal((800.0_f64, 600.0_f64));
let effective_min_zoom = Signal::derive(move || {
let (vp_w, vp_h) = viewport_dimensions.get();
calculate_min_zoom(scene_width_f, scene_height_f, vp_w, vp_h)
});
let zoom_level = Signal::derive(move || {
let s = settings.get();
if s.panning_enabled {
let min_zoom = effective_min_zoom.get();
s.zoom_level.max(min_zoom)
} else {
1.0
}
});
let enlarge_props = Signal::derive(move || settings.get().enlarge_props);
let bg_color = scene
.background_color
.clone()
.unwrap_or_else(|| "#1a1a2e".to_string());
let has_background_image = scene.background_image_path.is_some();
let image_path = scene.background_image_path.clone().unwrap_or_default();
let bg_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
let outer_container_ref = NodeRef::<leptos::html::Div>::new();
// Scale/offset state
let (scale_x, set_scale_x) = signal(1.0_f64);
let (scale_y, set_scale_y) = signal(1.0_f64);
let (offset_x, set_offset_x) = signal(0.0_f64);
let (offset_y, set_offset_y) = signal(0.0_f64);
let (scales_ready, set_scales_ready) = signal(false);
// Context menu state (for avatar whisper)
let (context_menu_open, set_context_menu_open) = signal(false);
let (context_menu_position, set_context_menu_position) = signal((0.0_f64, 0.0_f64));
let (context_menu_target, set_context_menu_target) = signal(Option::<String>::None);
let (context_menu_username, set_context_menu_username) = signal(Option::<String>::None);
// Prop context menu state
let (prop_context_menu_open, set_prop_context_menu_open) = signal(false);
let (prop_context_menu_position, set_prop_context_menu_position) = signal((0.0_f64, 0.0_f64));
let (prop_context_menu_target, set_prop_context_menu_target) = signal(Option::<Uuid>::None);
// Scale mode state
let (scale_mode_active, set_scale_mode_active) = signal(false);
let (scale_mode_prop_id, set_scale_mode_prop_id) = signal(Option::<Uuid>::None);
let (scale_mode_initial_scale, set_scale_mode_initial_scale) = signal(1.0_f32);
let scale_mode_preview_scale = RwSignal::new(1.0_f32);
let (scale_mode_prop_center, set_scale_mode_prop_center) = signal((0.0_f64, 0.0_f64));
// Move mode state
let (move_mode_active, set_move_mode_active) = signal(false);
let (move_mode_prop_id, set_move_mode_prop_id) = signal(Option::<Uuid>::None);
let move_mode_preview_position = RwSignal::new((0.0_f64, 0.0_f64));
let (move_mode_prop_scale, set_move_mode_prop_scale) = signal(1.0_f32);
let (prop_context_is_locked, set_prop_context_is_locked) = signal(false);
let (prop_context_name, set_prop_context_name) = signal(Option::<String>::None);
// Click handler for movement (props are now handled via context menu)
#[cfg(feature = "hydrate")]
let on_overlay_click = {
let on_move = on_move.clone();
move |ev: web_sys::MouseEvent| {
use wasm_bindgen::JsCast;
let client_x = ev.client_x() as f64;
let client_y = ev.client_y() as f64;
let document = web_sys::window().unwrap().document().unwrap();
let mut clicked_prop: Option<Uuid> = None;
if let Some(container) = document.query_selector(".props-container").ok().flatten() {
let canvases = container.get_elements_by_tag_name("canvas");
for i in 0..canvases.length() {
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") {
if hit_test_canvas(&canvas, client_x, client_y) {
if let Ok(prop_id) = prop_id_str.parse::<Uuid>() {
clicked_prop = Some(prop_id);
break;
}
}
}
}
}
}
}
if clicked_prop.is_none() {
let target = ev.current_target().unwrap();
let element: web_sys::HtmlElement = target.dyn_into().unwrap();
let rect = element.get_bounding_client_rect();
let click_x = client_x - rect.left();
let click_y = client_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 = ((click_x - ox) / sx).max(0.0).min(scene_width as f64);
let scene_y = ((click_y - oy) / sy).max(0.0).min(scene_height as f64);
on_move.run((scene_x, scene_y));
}
}
}
};
// Context menu handler
#[cfg(feature = "hydrate")]
let on_overlay_contextmenu = {
let current_user_id = current_user_id.clone();
move |ev: web_sys::MouseEvent| {
use wasm_bindgen::JsCast;
let client_x = ev.client_x() as f64;
let client_y = ev.client_y() as f64;
let document = web_sys::window().unwrap().document().unwrap();
// Check props first
if let Some(container) = document.query_selector(".props-container").ok().flatten() {
let canvases = container.get_elements_by_tag_name("canvas");
for i in 0..canvases.length() {
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") {
if hit_test_canvas(&canvas, client_x, client_y) {
if let Ok(prop_id) = prop_id_str.parse::<Uuid>() {
if let Some(prop) = loose_props.get().iter().find(|p| p.id == prop_id) {
let is_mod = is_moderator.map(|s| s.get()).unwrap_or(false);
// Don't show menu if prop is locked and user is not a moderator
if prop.is_locked && !is_mod {
ev.prevent_default();
return;
}
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);
set_scale_mode_initial_scale.set(prop.scale);
set_prop_context_is_locked.set(prop.is_locked);
set_prop_context_name.set(Some(prop.prop_name.clone()));
set_move_mode_prop_scale.set(prop.scale);
let rect = canvas.get_bounding_client_rect();
set_scale_mode_prop_center.set((
rect.left() + rect.width() / 2.0,
rect.top() + rect.height() / 2.0,
));
}
return;
}
}
}
}
}
}
}
// Guests cannot access avatar context menu
if is_guest.get() {
return;
}
let my_user_id = current_user_id.map(|s| s.get()).flatten();
// Check avatars
if let Some(container) = document.query_selector(".avatars-container").ok().flatten() {
let canvases = container.get_elements_by_tag_name("canvas");
for i in 0..canvases.length() {
if let Some(element) = canvases.item(i) {
if let Ok(canvas) = element.dyn_into::<web_sys::HtmlCanvasElement>() {
if let Some(member_id_str) = canvas.get_attribute("data-member-id") {
if hit_test_canvas(&canvas, client_x, client_y) {
if let Ok(member_id) = member_id_str.parse::<Uuid>() {
if my_user_id != Some(member_id) {
if let Some(member) = members.get().iter()
.find(|m| m.member.user_id == member_id)
{
ev.prevent_default();
set_context_menu_position.set((client_x, client_y));
set_context_menu_target.set(Some(member.member.display_name.clone()));
set_context_menu_username.set(Some(member.member.username.clone()));
set_context_menu_open.set(true);
return;
}
}
}
}
}
}
}
}
}
}
};
// Set up effects
#[cfg(feature = "hydrate")]
{
use std::cell::RefCell;
use std::rc::Rc;
let image_path_clone = image_path.clone();
let bg_color_clone = bg_color.clone();
// Viewport tracking
effects::setup_viewport_tracking(outer_container_ref, is_pan_mode, set_viewport_dimensions);
// Background drawing effect
let last_pan_mode = Rc::new(RefCell::new(None::<bool>));
let last_zoom = Rc::new(RefCell::new(None::<f64>));
let last_viewport = Rc::new(RefCell::new(None::<(f64, f64)>));
Effect::new(move |_| {
let current_pan_mode = is_pan_mode.get();
let current_zoom = zoom_level.get();
let current_viewport = viewport_dimensions.get();
let Some(canvas) = bg_canvas_ref.get() else { return };
let needs_redraw = {
let last_pan = *last_pan_mode.borrow();
let last_z = *last_zoom.borrow();
let last_vp = *last_viewport.borrow();
last_pan != Some(current_pan_mode)
|| (current_pan_mode && last_z != Some(current_zoom))
|| (!current_pan_mode && last_vp != Some(current_viewport))
};
if !needs_redraw { return }
*last_pan_mode.borrow_mut() = Some(current_pan_mode);
*last_zoom.borrow_mut() = Some(current_zoom);
*last_viewport.borrow_mut() = Some(current_viewport);
effects::draw_background(
&canvas,
&bg_color_clone,
&image_path_clone,
has_background_image,
scene_width_f,
scene_height_f,
current_pan_mode,
current_zoom,
set_scale_x,
set_scale_y,
set_offset_x,
set_offset_y,
set_scales_ready,
);
});
// Middle mouse pan
effects::setup_middle_mouse_pan(outer_container_ref, is_pan_mode);
// Wheel zoom
effects::setup_wheel_zoom(outer_container_ref, is_pan_mode, on_zoom_change);
}
let aspect_ratio = scene_width as f64 / scene_height as f64;
// Style computations
let container_class = move || {
if is_pan_mode.get() {
"scene-canvas relative cursor-pointer"
} else {
"scene-canvas relative overflow-hidden cursor-pointer"
}
};
let outer_container_class = move || {
if is_pan_mode.get() {
let zoom = zoom_level.get();
let (vp_w, vp_h) = viewport_dimensions.get();
let canvas_w = scene_width_f * zoom;
let canvas_h = scene_height_f * zoom;
if canvas_w <= vp_w && canvas_h <= vp_h {
"scene-container scene-viewer-container w-full overflow-auto flex justify-center items-center"
} else {
"scene-container scene-viewer-container w-full overflow-auto"
}
} else {
"scene-container scene-viewer-container w-full h-full flex justify-center items-center"
}
};
let outer_container_style = move || {
if is_pan_mode.get() {
"max-height: calc(100vh - 64px)".to_string()
} else {
String::new()
}
};
let container_style = move || {
if is_pan_mode.get() {
let zoom = zoom_level.get();
format!(
"width: {}px; height: {}px; background-color: {}",
(scene_width as f64 * zoom) as u32,
(scene_height as f64 * zoom) as u32,
bg_color
)
} else {
format!(
"aspect-ratio: {} / {}; width: min(100%, calc((100vh - 64px) * {})); max-height: calc(100vh - 64px); background-color: {}",
scene_width, scene_height, aspect_ratio, bg_color
)
}
};
let canvas_class = move || {
if is_pan_mode.get() { "absolute inset-0" } else { "absolute inset-0 w-full h-full" }
};
let canvas_style = move |z_index: i32| {
if is_pan_mode.get() {
let zoom = zoom_level.get();
format!(
"z-index: {}; width: {}px; height: {}px",
z_index,
(scene_width as f64 * zoom) as u32,
(scene_height as f64 * zoom) as u32
)
} else {
format!("z-index: {}; width: 100%; height: 100%", z_index)
}
};
// Member sorting and derived signals
let sorted_members = Signal::derive(move || {
let mut m = members.get();
m.sort_by(|a, b| b.member.joined_at.cmp(&a.member.joined_at));
m
});
let prop_size = Signal::derive(move || {
let current_pan_mode = is_pan_mode.get();
let current_zoom = zoom_level.get();
let current_enlarge = enlarge_props.get();
let sx = scale_x.get();
let sy = scale_y.get();
let ref_scale = (scene_width_f / REFERENCE_WIDTH).max(scene_height_f / REFERENCE_HEIGHT);
if current_pan_mode {
if current_enlarge {
BASE_PROP_SIZE * BASE_AVATAR_SCALE * ref_scale * current_zoom
} else {
BASE_PROP_SIZE * BASE_AVATAR_SCALE * current_zoom
}
} else if current_enlarge {
BASE_PROP_SIZE * BASE_AVATAR_SCALE * ref_scale * sx.min(sy)
} else {
BASE_PROP_SIZE * BASE_AVATAR_SCALE * sx.min(sy)
}
});
let text_em_size = Signal::derive(move || settings.get().text_em_size);
// Compute SceneTransform once for all avatars and props (memoized)
let scene_transform: Signal<SceneTransform> = Memo::new(move |_| SceneTransform {
scale_x: scale_x.get(),
scale_y: scale_y.get(),
offset_x: offset_x.get(),
offset_y: offset_y.get(),
})
.into();
// Compute ScreenBounds once for all avatars (memoized for clamping)
let screen_bounds: Signal<ScreenBounds> = Memo::new(move |_| {
let t = scene_transform.get();
ScreenBounds::from_transform(
scene_width_f,
scene_height_f,
t.scale_x,
t.scale_y,
t.offset_x,
t.offset_y,
)
})
.into();
let members_by_key = Signal::derive(move || {
sorted_members.get().into_iter().enumerate()
.map(|(idx, m)| (member_key(&m), (idx, m)))
.collect::<HashMap<_, _>>()
});
let member_keys = Memo::new(move |_| {
sorted_members.get().iter().map(member_key).collect::<Vec<_>>()
});
let scene_name = scene.name.clone();
view! {
<div node_ref=outer_container_ref class=outer_container_class style=outer_container_style>
<div class=container_class style=container_style>
<canvas
node_ref=bg_canvas_ref
class=canvas_class
style=move || canvas_style(0)
aria-hidden="true"
/>
<div class="props-container absolute inset-0" style=format!("z-index: {}; pointer-events: none;", Z_PROPS)>
<Show when=move || scales_ready.get() fallback=|| ()>
{move || {
loose_props.get().into_iter().map(|prop| {
let prop_id = prop.id;
let prop_signal = Signal::derive(move || {
loose_props.get().into_iter().find(|p| p.id == prop_id).unwrap_or_else(|| prop.clone())
});
view! {
<LoosePropCanvas
prop=prop_signal
transform=scene_transform
base_prop_size=prop_size
z_index=Z_LOOSE_PROP
/>
}
}).collect_view()
}}
</Show>
</div>
<div class="avatars-container absolute inset-0" style=format!("z-index: {}; pointer-events: none; overflow: visible;", Z_AVATARS_CONTAINER)>
<Show when=move || scales_ready.get() fallback=|| ()>
{move || {
member_keys.get().into_iter().map(|key| {
let member_signal = Signal::derive(move || {
members_by_key.get().get(&key).map(|(_, m)| m.clone()).expect("member key should exist")
});
let z_index_signal = Signal::derive(move || {
members_by_key.get().get(&key).map(|(idx, _)| (*idx as i32) + Z_AVATAR_BASE).unwrap_or(Z_AVATAR_BASE)
});
let z = z_index_signal.get_untracked();
// Derive bubble signal for this member
let bubble_signal = Signal::derive(move || {
active_bubbles.get().get(&key).cloned()
});
view! {
<Avatar
member=member_signal
transform=scene_transform
prop_size=prop_size
z_index=z
text_em_size=text_em_size
screen_bounds=screen_bounds
active_bubble=bubble_signal
/>
}
}).collect_view()
}}
{move || {
let Some(fading_signal) = fading_members else {
return Vec::new().into_iter().collect_view();
};
#[cfg(feature = "hydrate")]
let now = js_sys::Date::now() as i64;
#[cfg(not(feature = "hydrate"))]
let now = 0i64;
fading_signal.get().into_iter().filter_map(|fading| {
let elapsed = now - fading.fade_start;
if elapsed < fading.fade_duration {
let opacity = (1.0 - (elapsed as f64 / fading.fade_duration as f64)).max(0.0).min(1.0);
let member_signal = Signal::derive({ let m = fading.member.clone(); move || m.clone() });
// Fading members don't show bubbles
Some(view! {
<Avatar
member=member_signal
transform=scene_transform
prop_size=prop_size
z_index=Z_FADING_AVATAR
text_em_size=text_em_size
opacity=opacity
screen_bounds=screen_bounds
/>
})
} else { None }
}).collect_view()
}}
</Show>
</div>
<div
class="click-overlay absolute inset-0"
style=format!("z-index: {}; cursor: pointer;", Z_CLICK_OVERLAY)
aria-label=format!("Scene: {}", scene_name)
role="img"
on:click=move |ev| {
#[cfg(feature = "hydrate")]
on_overlay_click(ev);
#[cfg(not(feature = "hydrate"))]
let _ = ev;
}
on:contextmenu=move |ev| {
#[cfg(feature = "hydrate")]
on_overlay_contextmenu(ev);
#[cfg(not(feature = "hydrate"))]
let _ = ev;
}
/>
<ContextMenu
open=Signal::derive(move || context_menu_open.get())
position=Signal::derive(move || context_menu_position.get())
header=Signal::derive(move || context_menu_target.get())
items=Signal::derive(move || vec![
ContextMenuItem { label: "View Profile".to_string(), action: "view_profile".to_string() },
ContextMenuItem { label: "Whisper".to_string(), action: "whisper".to_string() },
])
on_select=Callback::new({
let on_whisper_request = on_whisper_request.clone();
move |action: String| {
match action.as_str() {
"view_profile" => {
if let Some(username) = context_menu_username.get() {
#[cfg(feature = "hydrate")]
{
let url = format!("/users/{}", username);
let _ = web_sys::window()
.unwrap()
.open_with_url_and_target(&url, "_blank");
}
}
}
"whisper" => {
if let Some(target) = context_menu_target.get() {
if let Some(ref callback) = on_whisper_request {
callback.run(target);
}
}
}
_ => {}
}
}
})
on_close=Callback::new(move |_: ()| {
set_context_menu_open.set(false);
set_context_menu_target.set(None);
set_context_menu_username.set(None);
})
/>
<ContextMenu
open=Signal::derive(move || prop_context_menu_open.get())
position=Signal::derive(move || prop_context_menu_position.get())
header=Signal::derive(move || {
prop_context_name.get().or_else(|| Some("Prop".to_string()))
})
items=Signal::derive(move || {
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();
// Always show View Info for props (to view state/business cards)
items.push(ContextMenuItem { label: "View Info".to_string(), action: "view_info".to_string() });
if !is_locked || is_mod {
items.push(ContextMenuItem { label: "Pick Up".to_string(), action: "pick_up".to_string() });
items.push(ContextMenuItem { label: "Move".to_string(), action: "move".to_string() });
}
if is_mod {
items.push(ContextMenuItem { label: "Set Scale".to_string(), action: "set_scale".to_string() });
items.push(ContextMenuItem {
label: if is_locked { "Unlock" } else { "Lock" }.to_string(),
action: if is_locked { "unlock" } else { "lock" }.to_string(),
});
items.push(ContextMenuItem { label: "Delete".to_string(), action: "delete".to_string() });
}
items
})
on_select=Callback::new({
let on_prop_lock_toggle = on_prop_lock_toggle.clone();
let on_prop_click = on_prop_click.clone();
let on_prop_delete = on_prop_delete.clone();
let on_view_prop_state = on_view_prop_state.clone();
move |action: String| {
match action.as_str() {
"view_info" => {
if let Some(prop_id) = prop_context_menu_target.get() {
if let Some(ref callback) = on_view_prop_state {
callback.run(prop_id);
}
}
}
"pick_up" => {
if let Some(prop_id) = prop_context_menu_target.get() {
on_prop_click.run(prop_id);
}
}
"set_scale" => {
if let Some(prop_id) = prop_context_menu_target.get() {
set_scale_mode_prop_id.set(Some(prop_id));
scale_mode_preview_scale.set(scale_mode_initial_scale.get());
set_scale_mode_active.set(true);
}
}
"move" => {
if let Some(prop_id) = prop_context_menu_target.get() {
set_move_mode_prop_id.set(Some(prop_id));
if let Some(prop) = loose_props.get().iter().find(|p| p.id == prop_id) {
move_mode_preview_position.set((prop.position_x, prop.position_y));
}
set_move_mode_active.set(true);
}
}
"lock" | "unlock" => {
if let Some(prop_id) = prop_context_menu_target.get() {
if let Some(ref callback) = on_prop_lock_toggle {
callback.run((prop_id, action == "lock"));
}
}
}
"delete" => {
if let Some(prop_id) = prop_context_menu_target.get() {
if let Some(ref callback) = on_prop_delete {
callback.run(prop_id);
}
}
}
_ => {}
}
set_prop_context_menu_open.set(false);
}
})
on_close=Callback::new(move |_: ()| {
set_prop_context_menu_open.set(false);
set_prop_context_menu_target.set(None);
set_prop_context_name.set(None);
})
/>
<ScaleOverlay
active=Signal::derive(move || scale_mode_active.get())
prop_id=Signal::derive(move || scale_mode_prop_id.get())
preview_scale=scale_mode_preview_scale
prop_center=Signal::derive(move || scale_mode_prop_center.get())
loose_props=loose_props
prop_size=prop_size
on_apply=on_prop_scale_update.unwrap_or_else(|| Callback::new(|_| {}))
on_cancel=Callback::new(move |_: ()| {
set_scale_mode_active.set(false);
set_scale_mode_prop_id.set(None);
})
/>
<MoveOverlay
active=Signal::derive(move || move_mode_active.get())
prop_id=Signal::derive(move || move_mode_prop_id.get())
preview_position=move_mode_preview_position
prop_scale=Signal::derive(move || move_mode_prop_scale.get())
loose_props=loose_props
prop_size=prop_size
transform=scene_transform
on_apply=on_prop_move.unwrap_or_else(|| Callback::new(|_| {}))
on_cancel=Callback::new(move |_: ()| {
set_move_mode_active.set(false);
set_move_mode_prop_id.set(None);
})
/>
</div>
</div>
}
}