From fe40fd32ab55d8884fea7045424c9911684e9d73002d2a2a612a8b7851453924 Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Fri, 23 Jan 2026 19:41:33 -0600 Subject: [PATCH] clean up scene_viewer --- .../src/components/scene_viewer.rs | 1122 +++-------------- .../components/scene_viewer/coordinates.rs | 96 ++ .../src/components/scene_viewer/effects.rs | 392 ++++++ .../src/components/scene_viewer/overlays.rs | 306 +++++ 4 files changed, 958 insertions(+), 958 deletions(-) create mode 100644 crates/chattyness-user-ui/src/components/scene_viewer/coordinates.rs create mode 100644 crates/chattyness-user-ui/src/components/scene_viewer/effects.rs create mode 100644 crates/chattyness-user-ui/src/components/scene_viewer/overlays.rs diff --git a/crates/chattyness-user-ui/src/components/scene_viewer.rs b/crates/chattyness-user-ui/src/components/scene_viewer.rs index d69bf27..e0a6b33 100644 --- a/crates/chattyness-user-ui/src/components/scene_viewer.rs +++ b/crates/chattyness-user-ui/src/components/scene_viewer.rs @@ -8,6 +8,13 @@ //! - **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::*; @@ -22,8 +29,8 @@ 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_SCALE, BASE_PROP_SIZE, REFERENCE_HEIGHT, REFERENCE_WIDTH, - ViewerSettings, calculate_min_zoom, + 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; @@ -37,46 +44,25 @@ use crate::utils::parse_bounds_dimensions; #[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, - /// Viewer settings for pan/zoom/enlarge modes. - #[prop(optional)] - settings: Option>, - /// Callback for zoom changes (from mouse wheel). Receives zoom delta. - #[prop(optional)] - on_zoom_change: Option>, - /// Members that are fading out after timeout disconnect. - #[prop(optional, into)] - fading_members: Option>>, - /// Current user's user_id (for context menu filtering). - /// Note: Guests are now regular users with the 'guest' tag. - #[prop(optional, into)] - current_user_id: Option>>, - /// Whether the current user is a guest (guests cannot use context menu). - #[prop(optional, into)] - is_guest: Option>, - /// Callback when whisper is requested on a member. - #[prop(optional, into)] - on_whisper_request: Option>, - /// Whether the current user is a moderator (can edit prop scales). - #[prop(optional, into)] - is_moderator: Option>, - /// Callback when prop scale is updated (moderator only). - #[prop(optional, into)] - on_prop_scale_update: Option>, - /// Callback when prop is moved to new position. - #[prop(optional, into)] - on_prop_move: Option>, - /// Callback when prop lock is toggled (moderator only). - #[prop(optional, into)] - on_prop_lock_toggle: Option>, + #[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>, ) -> impl IntoView { let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false)); - // Use default settings if none provided 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)); @@ -85,18 +71,13 @@ pub fn RealmSceneViewer( // Derived signals for rendering mode let is_pan_mode = Signal::derive(move || settings.get().panning_enabled); - - // Signal for viewport dimensions (outer container size) - // Used to calculate effective minimum zoom in pan mode let (viewport_dimensions, set_viewport_dimensions) = signal((800.0_f64, 600.0_f64)); - // Calculate effective minimum zoom based on scene and viewport 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) }); - // Zoom level clamped to effective minimum let zoom_level = Signal::derive(move || { let s = settings.get(); if s.panning_enabled { @@ -117,21 +98,14 @@ pub fn RealmSceneViewer( let has_background_image = scene.background_image_path.is_some(); let image_path = scene.background_image_path.clone().unwrap_or_default(); - // Canvas ref for background layer - // Avatar and prop layers use individual canvas elements per user/prop let bg_canvas_ref = NodeRef::::new(); - - // Outer container ref for middle-mouse drag scrolling let outer_container_ref = NodeRef::::new(); - // Store scale factors for coordinate conversion (shared between both canvases) - // Using RwSignal for reactivity - derived signals will recompute on resize + // 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); - - // Signal to track when scale factors have been properly calculated let (scales_ready, set_scales_ready) = signal(false); // Context menu state (for avatar whisper) @@ -139,31 +113,26 @@ pub fn RealmSceneViewer( 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); - // Prop context menu state (for moderator scale editing) + // 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 (when dragging to resize prop) + // 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, set_scale_mode_preview_scale) = signal(1.0_f32); - // Prop center in canvas coordinates (for scale calculation) + 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 (when moving prop to new position) + // 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); - // 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_preview_position = RwSignal::new((0.0_f64, 0.0_f64)); 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 + // Click handler for movement or prop pickup #[cfg(feature = "hydrate")] let on_overlay_click = { let on_move = on_move.clone(); @@ -174,20 +143,15 @@ pub fn RealmSceneViewer( let client_x = ev.client_x() as f64; let client_y = ev.client_y() as f64; - // First check for pixel-perfect prop hits let document = web_sys::window().unwrap().document().unwrap(); let mut clicked_prop: Option = None; - // Query prop canvases in the props container 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 { + 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") { - // Pixel-perfect hit test if hit_test_canvas(&canvas, client_x, client_y) { if let Ok(prop_id) = prop_id_str.parse::() { clicked_prop = Some(prop_id); @@ -203,7 +167,6 @@ pub fn RealmSceneViewer( if let Some(prop_id) = clicked_prop { on_prop_click.run(prop_id); } else { - // No prop hit - handle as movement let target = ev.current_target().unwrap(); let element: web_sys::HtmlElement = target.dyn_into().unwrap(); let rect = element.get_bounding_client_rect(); @@ -217,69 +180,48 @@ pub fn RealmSceneViewer( let oy = offset_y.get(); if sx > 0.0 && sy > 0.0 { - let scene_x = (click_x - ox) / sx; - let scene_y = (click_y - oy) / sy; - - let scene_x = scene_x.max(0.0).min(scene_width as f64); - let scene_y = scene_y.max(0.0).min(scene_height as f64); - + 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)); } } } }; - // Handle right-click for context menu on avatars or props + // 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; - // Get click position let client_x = ev.client_x() as f64; let client_y = ev.client_y() as f64; - - // 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() - { + // Check props first + 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 { + 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") { - // Pixel-perfect hit test if hit_test_canvas(&canvas, client_x, client_y) { if let Ok(prop_id) = prop_id_str.parse::() { - // 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 and lock state - if let Some(prop) = loose_props - .get() - .iter() - .find(|p| p.id == prop_id) - { + 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)); + set_scale_mode_prop_center.set(( + rect.left() + rect.width() / 2.0, + rect.top() + rect.height() / 2.0, + )); } return; } @@ -290,47 +232,28 @@ pub fn RealmSceneViewer( } } - // Guests cannot message other users - don't show avatar context menu + // Guests cannot access avatar context menu if is_guest.get() { return; } - // Get current user identity for filtering let my_user_id = current_user_id.map(|s| s.get()).flatten(); - // Query all avatar canvases and check for hit - let document = web_sys::window().unwrap().document().unwrap(); - - // Get the avatars container and find all canvas elements within it + // Check avatars if let Some(container) = document.query_selector(".avatars-container").ok().flatten() { let canvases = container.get_elements_by_tag_name("canvas"); - let canvas_count = canvases.length(); - - for i in 0..canvas_count { + for i in 0..canvases.length() { if let Some(element) = canvases.item(i) { if let Ok(canvas) = element.dyn_into::() { - // Check for data-member-id attribute if let Some(member_id_str) = canvas.get_attribute("data-member-id") { - // Check if click hits a non-transparent pixel if hit_test_canvas(&canvas, client_x, client_y) { - // Parse the member ID (now always user_id since guests are users) if let Ok(member_id) = member_id_str.parse::() { - // Check if this is the current user's avatar - let is_current_user = my_user_id == Some(member_id); - - if !is_current_user { - // Find the display name for this member - let display_name = members - .get() - .iter() + if my_user_id != Some(member_id) { + if let Some(name) = members.get().iter() .find(|m| m.member.user_id == member_id) - .map(|m| m.member.display_name.clone()); - - if let Some(name) = display_name { - // Prevent default browser context menu + .map(|m| m.member.display_name.clone()) + { ev.prevent_default(); - - // Show context menu at click position set_context_menu_position.set((client_x, client_y)); set_context_menu_target.set(Some(name)); set_context_menu_open.set(true); @@ -344,90 +267,33 @@ pub fn RealmSceneViewer( } } } - - // No avatar hit - allow default context menu } }; + // Set up effects #[cfg(feature = "hydrate")] { use std::cell::RefCell; use std::rc::Rc; - use wasm_bindgen::{JsCast, closure::Closure}; let image_path_clone = image_path.clone(); let bg_color_clone = bg_color.clone(); - // ========================================================= - // Viewport Dimensions Effect - tracks outer container size - // Uses window resize event to detect size changes - // ========================================================= - Effect::new(move |_| { - // Track pan mode to re-run when it changes (affects container layout) - let _ = is_pan_mode.get(); + // Viewport tracking + effects::setup_viewport_tracking(outer_container_ref, is_pan_mode, set_viewport_dimensions); - let Some(container) = outer_container_ref.get() else { - return; - }; - - let container_el: web_sys::HtmlElement = container.into(); - - // Measure and update dimensions - let measure_container = { - let container_el = container_el.clone(); - move || { - let width = container_el.client_width() as f64; - let height = container_el.client_height() as f64; - if width > 0.0 && height > 0.0 { - set_viewport_dimensions.set((width, height)); - } - } - }; - - // Measure immediately - measure_container(); - - // Also measure on window resize - let resize_handler = Closure::wrap(Box::new({ - let container_el = container_el.clone(); - move |_: web_sys::Event| { - let width = container_el.client_width() as f64; - let height = container_el.client_height() as f64; - if width > 0.0 && height > 0.0 { - set_viewport_dimensions.set((width, height)); - } - } - }) as Box); - - let window = web_sys::window().unwrap(); - let _ = window.add_event_listener_with_callback( - "resize", - resize_handler.as_ref().unchecked_ref(), - ); - - // Keep the closure alive - resize_handler.forget(); - }); - - // Track the last settings to detect changes + // 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)>)); - // ========================================================= - // Background Effect - redraws when settings or viewport change - // ========================================================= Effect::new(move |_| { - // Track settings signals - this Effect reruns when they change 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 Some(canvas) = bg_canvas_ref.get() else { return }; - // Check if we need to redraw (settings or viewport changed, or first render) let needs_redraw = { let last_pan = *last_pan_mode.borrow(); let last_z = *last_zoom.borrow(); @@ -437,346 +303,39 @@ pub fn RealmSceneViewer( || (!current_pan_mode && last_vp != Some(current_viewport)) }; - if !needs_redraw { - return; - } + if !needs_redraw { return } - // Update last values *last_pan_mode.borrow_mut() = Some(current_pan_mode); *last_zoom.borrow_mut() = Some(current_zoom); *last_viewport.borrow_mut() = Some(current_viewport); - let canvas_el: &web_sys::HtmlCanvasElement = &canvas; - let canvas_el = canvas_el.clone(); - let bg_color = bg_color_clone.clone(); - let image_path = image_path_clone.clone(); - - // Use setTimeout to ensure DOM is ready before drawing - let draw_bg = Closure::once(Box::new(move || { - if current_pan_mode { - // Pan mode: canvas at native resolution * zoom - let canvas_width = (scene_width_f * current_zoom) as u32; - let canvas_height = (scene_height_f * current_zoom) as u32; - - canvas_el.set_width(canvas_width); - canvas_el.set_height(canvas_height); - - // Store scale factors (zoom level, no offset) - set_scale_x.set(current_zoom); - set_scale_y.set(current_zoom); - set_offset_x.set(0.0); - set_offset_y.set(0.0); - - // Signal that scale factors are ready - set_scales_ready.set(true); - - if let Ok(Some(ctx)) = canvas_el.get_context("2d") { - let ctx: web_sys::CanvasRenderingContext2d = - ctx.dyn_into::().unwrap(); - - // Fill entire canvas with background color (no letterboxing) - ctx.set_fill_style_str(&bg_color); - ctx.fill_rect(0.0, 0.0, canvas_width as f64, canvas_height as f64); - - // Draw background image if available - if has_background_image && !image_path.is_empty() { - let img = web_sys::HtmlImageElement::new().unwrap(); - let img_clone = img.clone(); - let ctx_clone = ctx.clone(); - - let onload = Closure::once(Box::new(move || { - let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh( - &img_clone, - 0.0, - 0.0, - canvas_width as f64, - canvas_height as f64, - ); - }) - as Box); - - img.set_onload(Some(onload.as_ref().unchecked_ref())); - onload.forget(); - img.set_src(&image_path); - } - } - } else { - // Fit mode: scale to viewport with letterboxing - let display_width = canvas_el.client_width() as u32; - let display_height = canvas_el.client_height() as u32; - - // If still no dimensions, the canvas likely isn't visible - skip drawing - if display_width == 0 || display_height == 0 { - return; - } - - canvas_el.set_width(display_width); - canvas_el.set_height(display_height); - - // Calculate scale to fit scene in canvas - let canvas_aspect = display_width as f64 / display_height as f64; - let scene_aspect = scene_width_f / scene_height_f; - - let (draw_width, draw_height, draw_x, draw_y) = if canvas_aspect > scene_aspect - { - let h = display_height as f64; - let w = h * scene_aspect; - let x = (display_width as f64 - w) / 2.0; - (w, h, x, 0.0) - } else { - let w = display_width as f64; - let h = w / scene_aspect; - let y = (display_height as f64 - h) / 2.0; - (w, h, 0.0, y) - }; - - // Store scale factors - let sx = draw_width / scene_width_f; - let sy = draw_height / scene_height_f; - set_scale_x.set(sx); - set_scale_y.set(sy); - set_offset_x.set(draw_x); - set_offset_y.set(draw_y); - - // Signal that scale factors are ready - set_scales_ready.set(true); - - if let Ok(Some(ctx)) = canvas_el.get_context("2d") { - let ctx: web_sys::CanvasRenderingContext2d = - ctx.dyn_into::().unwrap(); - - // Fill letterbox area with black - ctx.set_fill_style_str("#000"); - ctx.fill_rect(0.0, 0.0, display_width as f64, display_height as f64); - - // Fill scene area with background color - ctx.set_fill_style_str(&bg_color); - ctx.fill_rect(draw_x, draw_y, draw_width, draw_height); - - // Draw background image if available - if has_background_image && !image_path.is_empty() { - let img = web_sys::HtmlImageElement::new().unwrap(); - let img_clone = img.clone(); - let ctx_clone = ctx.clone(); - - let onload = Closure::once(Box::new(move || { - let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh( - &img_clone, - draw_x, - draw_y, - draw_width, - draw_height, - ); - }) - as Box); - - img.set_onload(Some(onload.as_ref().unchecked_ref())); - onload.forget(); - img.set_src(&image_path); - } - } - } - }) as Box); - - // Use setTimeout with small delay to ensure canvas is in DOM and has dimensions - let window = web_sys::window().unwrap(); - let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0( - draw_bg.as_ref().unchecked_ref(), - 100, // 100ms delay to allow DOM to settle + 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, ); - draw_bg.forget(); }); - // Note: Props are now rendered as individual LoosePropCanvas components - // that manage their own positioning and sizing via CSS transforms. - // No shared props canvas or effect needed. + // Middle mouse pan + effects::setup_middle_mouse_pan(outer_container_ref, is_pan_mode); - // ========================================================= - // Middle mouse button drag-to-pan (only in pan mode) - // ========================================================= - Effect::new(move |_| { - // Track pan mode - re-run when it changes - let pan_mode_enabled = is_pan_mode.get(); - - let Some(container) = outer_container_ref.get() else { - return; - }; - - let container_el: web_sys::HtmlElement = container.into(); - - if !pan_mode_enabled { - // Reset cursor when not in pan mode - let _ = container_el.style().set_property("cursor", ""); - return; - } - - use std::cell::Cell; - use std::rc::Rc; - use wasm_bindgen::closure::Closure; - - let is_dragging = Rc::new(Cell::new(false)); - let last_x = Rc::new(Cell::new(0i32)); - let last_y = Rc::new(Cell::new(0i32)); - - let container_for_move = container_el.clone(); - let is_dragging_move = is_dragging.clone(); - let last_x_move = last_x.clone(); - let last_y_move = last_y.clone(); - - let container_for_down = container_el.clone(); - let is_dragging_down = is_dragging.clone(); - let last_x_down = last_x.clone(); - let last_y_down = last_y.clone(); - - // Middle mouse down - start drag - let onmousedown = - Closure::::new(move |ev: web_sys::MouseEvent| { - // Button 1 is middle mouse button - if ev.button() == 1 { - is_dragging_down.set(true); - last_x_down.set(ev.client_x()); - last_y_down.set(ev.client_y()); - let _ = container_for_down - .style() - .set_property("cursor", "grabbing"); - ev.prevent_default(); - } - }); - - // Mouse move - drag scroll - let onmousemove = - Closure::::new(move |ev: web_sys::MouseEvent| { - if is_dragging_move.get() { - let dx = last_x_move.get() - ev.client_x(); - let dy = last_y_move.get() - ev.client_y(); - last_x_move.set(ev.client_x()); - last_y_move.set(ev.client_y()); - container_for_move.set_scroll_left(container_for_move.scroll_left() + dx); - container_for_move.set_scroll_top(container_for_move.scroll_top() + dy); - } - }); - - let container_for_up = container_el.clone(); - let is_dragging_up = is_dragging.clone(); - - // Mouse up - stop drag - let onmouseup = - Closure::::new(move |_ev: web_sys::MouseEvent| { - if is_dragging_up.get() { - is_dragging_up.set(false); - let _ = container_for_up.style().set_property("cursor", ""); - } - }); - - // Add event listeners - let _ = container_el.add_event_listener_with_callback( - "mousedown", - onmousedown.as_ref().unchecked_ref(), - ); - let _ = container_el.add_event_listener_with_callback( - "mousemove", - onmousemove.as_ref().unchecked_ref(), - ); - let _ = container_el - .add_event_listener_with_callback("mouseup", onmouseup.as_ref().unchecked_ref()); - - // Also listen for mouseup on window (in case mouse released outside container) - if let Some(window) = web_sys::window() { - let is_dragging_window = is_dragging.clone(); - let container_for_window = container_el.clone(); - let onmouseup_window = - Closure::::new(move |_ev: web_sys::MouseEvent| { - if is_dragging_window.get() { - is_dragging_window.set(false); - let _ = container_for_window.style().set_property("cursor", ""); - } - }); - let _ = window.add_event_listener_with_callback( - "mouseup", - onmouseup_window.as_ref().unchecked_ref(), - ); - onmouseup_window.forget(); - } - - // Prevent context menu on middle click - let oncontextmenu = - Closure::::new(move |ev: web_sys::MouseEvent| { - if ev.button() == 1 { - ev.prevent_default(); - } - }); - let _ = container_el.add_event_listener_with_callback( - "auxclick", - oncontextmenu.as_ref().unchecked_ref(), - ); - - // Forget closures to keep them alive - onmousedown.forget(); - onmousemove.forget(); - onmouseup.forget(); - oncontextmenu.forget(); - }); - } - - // Dynamically add/remove wheel listener based on pan mode - // This avoids Chrome's "non-passive wheel listener" warning when not in pan mode - #[cfg(feature = "hydrate")] - { - use std::cell::RefCell; - use std::rc::Rc; - use wasm_bindgen::{closure::Closure, JsCast}; - - let wheel_closure: Rc>>> = - Rc::new(RefCell::new(None)); - let wheel_closure_clone = wheel_closure.clone(); - - Effect::new(move |_| { - let pan_mode = is_pan_mode.get(); - - if let Some(container) = outer_container_ref.get() { - let element: &web_sys::Element = &container; - - // Remove existing listener if any - if let Some(closure) = wheel_closure_clone.borrow().as_ref() { - let _ = element.remove_event_listener_with_callback( - "wheel", - closure.as_ref().unchecked_ref(), - ); - } - - if pan_mode { - // Add non-passive wheel listener for zoom - let closure = Closure::new(move |ev: web_sys::WheelEvent| { - if !ev.ctrl_key() { - if let Some(zoom_callback) = on_zoom_change { - let delta_y = ev.delta_y(); - let zoom_delta = if delta_y < 0.0 { 0.1 } else { -0.1 }; - zoom_callback.run(zoom_delta); - ev.prevent_default(); - } - } - }); - - let options = web_sys::AddEventListenerOptions::new(); - options.set_passive(false); - - let _ = element.add_event_listener_with_callback_and_add_event_listener_options( - "wheel", - closure.as_ref().unchecked_ref(), - &options, - ); - - *wheel_closure_clone.borrow_mut() = Some(closure); - } else { - *wheel_closure_clone.borrow_mut() = None; - } - } - }); + // 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; - // Computed styles based on mode + // Style computations let container_class = move || { if is_pan_mode.get() { "scene-canvas relative cursor-pointer" @@ -792,7 +351,6 @@ pub fn RealmSceneViewer( let canvas_w = scene_width_f * zoom; let canvas_h = scene_height_f * zoom; - // Center canvas if smaller than viewport in both dimensions if canvas_w <= vp_w && canvas_h <= vp_h { "scene-container scene-viewer-container w-full overflow-auto flex justify-center items-center" } else { @@ -803,7 +361,6 @@ pub fn RealmSceneViewer( } }; - // Outer container needs max-height in pan mode to enable vertical scrolling let outer_container_style = move || { if is_pan_mode.get() { "max-height: calc(100vh - 64px)".to_string() @@ -830,11 +387,7 @@ pub fn RealmSceneViewer( }; let canvas_class = move || { - if is_pan_mode.get() { - "absolute inset-0" - } else { - "absolute inset-0 w-full h-full" - } + if is_pan_mode.get() { "absolute inset-0" } else { "absolute inset-0 w-full h-full" } }; let canvas_style = move |z_index: i32| { @@ -851,26 +404,21 @@ pub fn RealmSceneViewer( } }; - // Sorted members signal for z-ordering (most recently joined = highest z-index) + // Member sorting and derived signals let sorted_members = Signal::derive(move || { let mut m = members.get(); - // Sort by joined_at descending - most recent joins on top m.sort_by(|a, b| b.member.joined_at.cmp(&a.member.joined_at)); m }); - // Calculate prop size based on current settings (for avatars, uses BASE_AVATAR_SCALE) 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(); - - // Reference scale factor for "enlarge props" mode let ref_scale = (scene_width_f / REFERENCE_WIDTH).max(scene_height_f / REFERENCE_HEIGHT); - // Avatar size uses BASE_AVATAR_SCALE (60px cells at native size) if current_pan_mode { if current_enlarge { BASE_PROP_SIZE * BASE_AVATAR_SCALE * ref_scale * current_zoom @@ -884,75 +432,43 @@ pub fn RealmSceneViewer( } }); - // Text size multiplier from settings let text_em_size = Signal::derive(move || settings.get().text_em_size); - - // Create signals for scale/offset values to pass to AvatarCanvas 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()); - - // Create signals for scene dimensions to pass to AvatarCanvas for boundary awareness let scene_width_signal = Signal::derive(move || scene_width_f); let scene_height_signal = Signal::derive(move || scene_height_f); - // Create a map of members by key for efficient lookup let members_by_key = Signal::derive(move || { - use std::collections::HashMap; - sorted_members - .get() - .into_iter() - .enumerate() + sorted_members.get().into_iter().enumerate() .map(|(idx, m)| (member_key(&m), (idx, m))) .collect::>() }); - // Get the list of member keys - use Memo so it only updates when keys actually change - // (not when member data like position changes) let member_keys = Memo::new(move |_| { - sorted_members - .get() - .iter() - .map(member_key) - .collect::>() + sorted_members.get().iter().map(member_key).collect::>() }); let scene_name = scene.name.clone(); view! {
-
- // Background layer - static, drawn once +