//! 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, member_key}; #[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>, #[prop(into)] active_bubbles: Signal>, #[prop(into)] loose_props: Signal>, #[prop(into)] on_move: Callback<(f64, f64)>, #[prop(into)] on_prop_click: Callback, #[prop(optional)] settings: Option>, #[prop(optional)] on_zoom_change: Option>, #[prop(optional, into)] fading_members: Option>>, #[prop(optional, into)] current_user_id: Option>>, #[prop(optional, into)] is_guest: Option>, #[prop(optional, into)] on_whisper_request: Option>, #[prop(optional, into)] is_moderator: Option>, #[prop(optional, into)] on_prop_scale_update: Option>, #[prop(optional, into)] on_prop_move: Option>, #[prop(optional, into)] on_prop_lock_toggle: Option>, #[prop(optional, into)] on_prop_delete: Option>, #[prop(optional, into)] on_view_prop_state: Option>, ) -> 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::::new(); let outer_container_ref = NodeRef::::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::::None); let (context_menu_username, set_context_menu_username) = signal(Option::::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::::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::::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::::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::::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 = 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::() { 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::() { 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::() { 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::() { 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::() { 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::() { 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::)); let last_zoom = Rc::new(RefCell::new(None::)); 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); let scale_x_signal = Signal::derive(move || scale_x.get()); let scale_y_signal = Signal::derive(move || scale_y.get()); let offset_x_signal = Signal::derive(move || offset_x.get()); let offset_y_signal = Signal::derive(move || offset_y.get()); let scene_width_signal = Signal::derive(move || scene_width_f); let scene_height_signal = Signal::derive(move || scene_height_f); let members_by_key = Signal::derive(move || { sorted_members.get().into_iter().enumerate() .map(|(idx, m)| (member_key(&m), (idx, m))) .collect::>() }); let member_keys = Memo::new(move |_| { sorted_members.get().iter().map(member_key).collect::>() }); let scene_name = scene.name.clone(); view! {
} }