diff --git a/crates/chattyness-user-ui/src/components.rs b/crates/chattyness-user-ui/src/components.rs index 72dd9b4..5dca02f 100644 --- a/crates/chattyness-user-ui/src/components.rs +++ b/crates/chattyness-user-ui/src/components.rs @@ -1,6 +1,6 @@ //! Reusable UI components. -pub mod avatar_canvas; +pub mod avatar; pub mod avatar_editor; pub mod canvas_utils; pub mod avatar_store; @@ -26,13 +26,11 @@ pub mod scene_list_popup; pub mod scene_viewer; pub mod settings; pub mod settings_popup; -pub mod speech_bubble; pub mod tabs; -pub mod username_label; pub mod reconnection_overlay; pub mod ws_client; -pub use avatar_canvas::*; +pub use avatar::*; pub use avatar_editor::*; pub use avatar_store::*; pub use canvas_utils::*; @@ -59,7 +57,5 @@ pub use scene_list_popup::*; pub use scene_viewer::*; pub use settings::*; pub use settings_popup::*; -pub use speech_bubble::*; pub use tabs::*; -pub use username_label::*; pub use ws_client::*; diff --git a/crates/chattyness-user-ui/src/components/avatar_canvas.rs b/crates/chattyness-user-ui/src/components/avatar.rs similarity index 59% rename from crates/chattyness-user-ui/src/components/avatar_canvas.rs rename to crates/chattyness-user-ui/src/components/avatar.rs index 3f27d7e..f05c698 100644 --- a/crates/chattyness-user-ui/src/components/avatar_canvas.rs +++ b/crates/chattyness-user-ui/src/components/avatar.rs @@ -1,49 +1,79 @@ -//! Individual avatar canvas component for per-user rendering. +//! Individual avatar component for per-user rendering. +//! +//! Each avatar gets its own wrapper div containing: +//! - A canvas element for the avatar sprite (positioned via CSS transforms) +//! - A username label (HTML div below the avatar) +//! - A speech bubble (HTML div, conditionally rendered when speaking) //! -//! Each avatar gets its own canvas element positioned via CSS transforms. //! This enables efficient updates: position changes only update CSS (no redraw), //! while appearance changes (emotion, skin) redraw only that avatar's canvas. -use std::collections::HashMap; - use leptos::prelude::*; use uuid::Uuid; use chattyness_db::models::{ChannelMemberWithAvatar, EmotionState}; +use super::chat_types::{ActiveBubble, emotion_bubble_colors}; + #[cfg(feature = "hydrate")] pub use super::canvas_utils::hit_test_canvas; #[cfg(feature = "hydrate")] use super::canvas_utils::normalize_asset_path; // ============================================================================= -// Avatar Bounds - Exported for use by SpeechBubble and other components +// Screen Bounds - Exported for use by other components // ============================================================================= -/// Computed screen-space bounds for an avatar's visible content. +/// Screen boundaries for visual clamping in screen space. /// -/// This is computed by AvatarCanvas and exported via a shared store so that -/// other components (like SpeechBubble) can position themselves relative to -/// the avatar without duplicating the bounds calculation. -#[derive(Clone, Copy, Debug, Default)] -pub struct AvatarBounds { - /// X position of the content center in screen coordinates. - pub content_center_x: f64, - /// Y position of the content center in screen coordinates. - pub content_center_y: f64, - /// Half-width of the actual content area in pixels. - pub content_half_width: f64, - /// Half-height of the actual content area in pixels. - pub content_half_height: f64, - /// Top edge of the content in screen coordinates. - pub content_top_y: f64, - /// Bottom edge of the content in screen coordinates. - pub content_bottom_y: f64, +/// Used by Avatar for clamping avatar positions and bubble positions. +#[derive(Clone, Copy, Debug)] +pub struct ScreenBounds { + /// Left edge of drawable area (= offset_x) + pub min_x: f64, + /// Right edge (= offset_x + scene_width * scale_x) + pub max_x: f64, + /// Top edge (= offset_y) + pub min_y: f64, + /// Bottom edge (= offset_y + scene_height * scale_y) + pub max_y: f64, } -/// Type alias for the shared avatar bounds store. -/// Maps user_id -> computed bounds. -pub type AvatarBoundsStore = RwSignal>; +impl ScreenBounds { + /// Create screen bounds from scene transform parameters. + pub fn from_transform( + scene_width: f64, + scene_height: f64, + scale_x: f64, + scale_y: f64, + offset_x: f64, + offset_y: f64, + ) -> Self { + Self { + min_x: offset_x, + max_x: offset_x + (scene_width * scale_x), + min_y: offset_y, + max_y: offset_y + (scene_height * scale_y), + } + } + + /// Clamp a center point so content stays within screen boundaries. + pub fn clamp_center( + &self, + center_x: f64, + center_y: f64, + half_width: f64, + half_height: f64, + ) -> (f64, f64) { + let clamped_x = center_x + .max(self.min_x + half_width) + .min(self.max_x - half_width); + let clamped_y = center_y + .max(self.min_y + half_height) + .min(self.max_y - half_height); + (clamped_x, clamped_y) + } +} /// Base text size multiplier. Text at 100% slider = base_sizes * 1.4 const BASE_TEXT_SCALE: f64 = 1.4; @@ -161,58 +191,6 @@ impl ContentBounds { } } -/// Screen boundaries for visual clamping in screen space. -/// -/// Used by both AvatarCanvas (for clamping avatar positions) and -/// SpeechBubble (for clamping bubble positions). -#[derive(Clone, Copy, Debug)] -pub struct ScreenBounds { - /// Left edge of drawable area (= offset_x) - pub min_x: f64, - /// Right edge (= offset_x + scene_width * scale_x) - pub max_x: f64, - /// Top edge (= offset_y) - pub min_y: f64, - /// Bottom edge (= offset_y + scene_height * scale_y) - pub max_y: f64, -} - -impl ScreenBounds { - /// Create screen bounds from scene transform parameters. - pub fn from_transform( - scene_width: f64, - scene_height: f64, - scale_x: f64, - scale_y: f64, - offset_x: f64, - offset_y: f64, - ) -> Self { - Self { - min_x: offset_x, - max_x: offset_x + (scene_width * scale_x), - min_y: offset_y, - max_y: offset_y + (scene_height * scale_y), - } - } - - /// Clamp a center point so content stays within screen boundaries. - pub fn clamp_center( - &self, - center_x: f64, - center_y: f64, - half_width: f64, - half_height: f64, - ) -> (f64, f64) { - let clamped_x = center_x - .max(self.min_x + half_width) - .min(self.max_x - half_width); - let clamped_y = center_y - .max(self.min_y + half_height) - .min(self.max_y - half_height); - (clamped_x, clamped_y) - } -} - /// Unified layout context for avatar canvas rendering. /// /// This struct computes all derived layout values once from the inputs, @@ -220,8 +198,7 @@ impl ScreenBounds { /// - Canvas dimensions and position /// - Avatar positioning within the canvas /// - Coordinate transformations between canvas-local and screen space -/// -/// Note: Speech bubbles are rendered separately as HTML elements (see speech_bubble.rs). +/// - Username label and speech bubble positioning #[derive(Clone, Copy)] struct CanvasLayout { // Core dimensions @@ -245,6 +222,16 @@ struct CanvasLayout { // Avatar center within canvas (canvas-local coordinates) avatar_cx: f64, avatar_cy: f64, + + // Content bounds info for positioning + empty_top_rows: usize, + empty_bottom_rows: usize, + + // Scene bounds for bubble clamping (screen coordinates) + scene_min_x: f64, + scene_max_x: f64, + scene_min_y: f64, + scene_max_y: f64, } impl CanvasLayout { @@ -300,30 +287,15 @@ impl CanvasLayout { canvas_screen_y, avatar_cx, avatar_cy, + empty_top_rows: content_bounds.empty_top_rows(), + empty_bottom_rows: content_bounds.empty_bottom_rows(), + scene_min_x: boundaries.min_x, + scene_max_x: boundaries.max_x, + scene_min_y: boundaries.min_y, + scene_max_y: boundaries.max_y, } } - /// CSS style string for positioning the canvas element. - fn css_style(&self, z_index: i32, pointer_events: &str, opacity: f64) -> String { - format!( - "position: absolute; \ - left: 0; top: 0; \ - transform: translate({}px, {}px); \ - z-index: {}; \ - pointer-events: {}; \ - width: {}px; \ - height: {}px; \ - opacity: {};", - self.canvas_screen_x, - self.canvas_screen_y, - z_index, - pointer_events, - self.canvas_width, - self.canvas_height, - opacity - ) - } - /// Content center X in canvas-local coordinates. fn content_center_x(&self) -> f64 { self.avatar_cx + self.content_x_offset @@ -346,17 +318,22 @@ pub fn member_key(m: &ChannelMemberWithAvatar) -> Uuid { m.member.user_id } -/// Individual avatar canvas component. +/// Position of speech bubble relative to avatar. +#[derive(Clone, Copy, PartialEq, Debug)] +enum BubblePosition { + Above, + Below, +} + +/// Individual avatar component. /// /// Renders a single avatar with: /// - CSS transform for position (GPU-accelerated, no redraw on move) /// - Canvas for avatar sprite (redraws only on appearance change) -/// -/// Note: Speech bubbles are rendered separately as HTML elements for efficiency. -/// The avatar's computed bounds are written to `bounds_store` (if provided) so -/// that SpeechBubble and other components can position relative to the avatar. +/// - Username label as HTML div below the avatar +/// - Speech bubble as HTML div (conditionally rendered when speaking) #[component] -pub fn AvatarCanvas( +pub fn Avatar( /// The member data for this avatar (as a signal for reactive updates). member: Signal, /// X scale factor for coordinate conversion. @@ -383,19 +360,27 @@ pub fn AvatarCanvas( /// Scene height in scene coordinates (for boundary calculations). #[prop(optional)] scene_height: Option>, - /// Shared store for exporting computed avatar bounds. - /// If provided, this avatar will write its bounds to the store. + /// Active speech bubble for this avatar (None if not speaking). #[prop(optional)] - bounds_store: Option, + active_bubble: Option>>, ) -> impl IntoView { let canvas_ref = NodeRef::::new(); + let bubble_ref = NodeRef::::new(); // Disable pointer-events when fading (opacity < 1.0) so fading avatars aren't clickable let pointer_events = if opacity < 1.0 { "none" } else { "auto" }; - // Reactive style for CSS positioning (GPU-accelerated transforms) - // This closure re-runs when position, scale, offset, or prop_size changes - let style = move || { + // Signal to store computed layout for use by label and bubble + let (layout_signal, set_layout_signal) = signal(None::); + + // Bubble measurement state - only SIZE is stored, position is calculated reactively + let (bubble_measured_width, set_bubble_measured_width) = signal(0.0_f64); + let (bubble_measured_height, set_bubble_measured_height) = signal(0.0_f64); + let (bubble_is_measured, set_bubble_is_measured) = signal(false); + let (bubble_position, set_bubble_position) = signal(BubblePosition::Above); + + // Compute layout reactively + let compute_layout = move || { let m = member.get(); let ps = prop_size.get(); let sx = scale_x.get(); @@ -422,20 +407,74 @@ pub fn AvatarCanvas( let avatar_screen_y = m.member.position_y * sy + oy; // Create unified layout - all calculations happen in one place - let layout = CanvasLayout::new( + CanvasLayout::new( &content_bounds, ps, te, avatar_screen_x, avatar_screen_y, boundaries, - ); - - // Generate CSS style from layout - layout.css_style(z_index, pointer_events, opacity) + ) }; - // Store references for the effect + // Wrapper style (positions the entire avatar container) + let wrapper_style = move || { + let layout = compute_layout(); + format!( + "position: absolute; \ + left: 0; top: 0; \ + transform: translate({}px, {}px); \ + z-index: {}; \ + pointer-events: {}; \ + width: {}px; \ + height: {}px; \ + opacity: {}; \ + overflow: visible;", + layout.canvas_screen_x, + layout.canvas_screen_y, + z_index, + pointer_events, + layout.canvas_width, + layout.canvas_height, + opacity + ) + }; + + // Canvas style (fills the wrapper) + let canvas_style = "width: 100%; height: 100%;"; + + // Label style (positioned relative to wrapper, below avatar) + let label_style = move || { + let layout = compute_layout(); + let font_size = 12.0 * layout.text_scale; + // Position at content center X, below content bottom + let label_x = layout.content_center_x(); + let label_y = layout.avatar_bottom_y() + - layout.empty_bottom_rows as f64 * layout.prop_size + + 15.0 * layout.text_scale; + + format!( + "position: absolute; \ + left: {}px; \ + top: {}px; \ + transform: translateX(-50%); \ + font-size: {}px; \ + white-space: nowrap; \ + z-index: 99998;", + label_x, label_y, font_size + ) + }; + + // Get display name reactively + let display_name = move || member.get().member.display_name.clone(); + + // Compute data-member-id reactively + let data_member_id = move || { + let m = member.get(); + m.member.user_id.to_string() + }; + + // Store references for the canvas drawing effect #[cfg(feature = "hydrate")] { use std::cell::RefCell; @@ -496,23 +535,8 @@ pub fn AvatarCanvas( boundaries, ); - // Write computed bounds to shared store (if provided) - // This allows SpeechBubble and other components to position relative to this avatar - if let Some(store) = bounds_store { - let bounds = AvatarBounds { - content_center_x: layout.canvas_screen_x + layout.content_center_x(), - content_center_y: layout.canvas_screen_y + layout.avatar_cy, - content_half_width: content_bounds.content_width(ps) / 2.0, - content_half_height: content_bounds.content_height(ps) / 2.0, - content_top_y: layout.canvas_screen_y + layout.avatar_top_y() - + content_bounds.empty_top_rows() as f64 * ps, - content_bottom_y: layout.canvas_screen_y + layout.avatar_bottom_y() - - content_bounds.empty_bottom_rows() as f64 * ps, - }; - store.update(|map| { - map.insert(m.member.user_id, bounds); - }); - } + // Store layout for use by label and bubble + set_layout_signal.set(Some(layout)); let canvas_el: &web_sys::HtmlCanvasElement = &canvas; @@ -662,25 +686,287 @@ pub fn AvatarCanvas( ctx.set_text_baseline("middle"); let _ = ctx.fill_text(&format!("{}", current_emotion), badge_x, badge_y); } + }); - // Note: Display name and speech bubbles are now rendered separately as HTML elements + // Track the message ID we've measured to detect new messages + let (last_measured_message_id, set_last_measured_message_id) = + signal(Option::::None); + + // Effect to measure bubble SIZE only (runs once per message) + // Position is calculated reactively in bubble_style based on current layout + Effect::new(move |_| { + let Some(bubble_signal) = active_bubble else { + return; + }; + let Some(bubble) = bubble_signal.get() else { + // Reset measurement state when bubble disappears + untrack(|| { + set_bubble_is_measured.set(false); + set_last_measured_message_id.set(None); + }); + return; + }; + + // Check if this is a new message (different message_id) + let current_message_id = bubble.message.message_id; + let last_id = untrack(|| last_measured_message_id.get()); + let is_new_message = last_id != Some(current_message_id); + + // If already measured the SAME message, skip - prevents infinite loop + let is_measured = untrack(|| bubble_is_measured.get()); + if is_measured && !is_new_message { + return; + } + + // Reset measurement state for new message + if is_new_message { + untrack(|| { + set_bubble_is_measured.set(false); + set_last_measured_message_id.set(Some(current_message_id)); + }); + } + + // Use request_animation_frame to measure after DOM updates + let bubble_ref_clone = bubble_ref.clone(); + request_animation_frame(move || { + let Some(el) = bubble_ref_clone.get() else { + return; + }; + + let rect = el.get_bounding_client_rect(); + let bubble_width = rect.width(); + let bubble_height = rect.height(); + + if bubble_width <= 0.0 || bubble_height <= 0.0 { + return; + } + + // Only store the measured SIZE - position is calculated reactively + untrack(|| { + set_bubble_measured_width.set(bubble_width); + set_bubble_measured_height.set(bubble_height); + set_bubble_is_measured.set(true); + }); + }); }); } - // Compute data-member-id reactively - let data_member_id = move || { - let m = member.get(); - m.member.user_id.to_string() + // Calculate bubble position reactively based on current layout + // This runs whenever the avatar moves, keeping the bubble following the avatar + let calc_bubble_position = move || -> Option<(f64, f64, f64, BubblePosition)> { + let layout = layout_signal.get()?; + let measured = bubble_is_measured.get(); + if !measured { + return None; + } + + let bubble_width = bubble_measured_width.get(); + let bubble_height = bubble_measured_height.get(); + + if bubble_width <= 0.0 || bubble_height <= 0.0 { + return None; + } + + let tail_size = 8.0 * layout.text_scale; + let gap = 5.0 * layout.text_scale; + + // === AVATAR BOUNDS (in canvas/wrapper-local coordinates) === + let avatar_center_x = layout.content_center_x(); + let avatar_top = + layout.avatar_top_y() + layout.empty_top_rows as f64 * layout.prop_size; + let avatar_bottom = + layout.avatar_bottom_y() - layout.empty_bottom_rows as f64 * layout.prop_size; + + // === CONVERT TO SCENE COORDINATES FOR CLAMPING === + let avatar_screen_center_x = layout.canvas_screen_x + avatar_center_x; + let avatar_screen_top = layout.canvas_screen_y + avatar_top; + let avatar_screen_bottom = layout.canvas_screen_y + avatar_bottom; + + // === HORIZONTAL POSITIONING (in scene coordinates) === + let ideal_bubble_screen_left = avatar_screen_center_x - bubble_width / 2.0; + let clamped_bubble_screen_left = ideal_bubble_screen_left + .max(layout.scene_min_x) + .min(layout.scene_max_x - bubble_width); + + // === VERTICAL POSITIONING === + let space_above = avatar_screen_top - layout.scene_min_y; + let space_needed = bubble_height + tail_size + gap; + + let position = if space_above >= space_needed { + BubblePosition::Above + } else { + BubblePosition::Below + }; + + let ideal_bubble_screen_top = match position { + BubblePosition::Above => avatar_screen_top - gap - tail_size - bubble_height, + BubblePosition::Below => avatar_screen_bottom + gap + tail_size, + }; + let clamped_bubble_screen_top = ideal_bubble_screen_top + .max(layout.scene_min_y) + .min(layout.scene_max_y - bubble_height); + + // === CONVERT BACK TO WRAPPER-RELATIVE COORDINATES === + let bubble_left = clamped_bubble_screen_left - layout.canvas_screen_x; + let bubble_top = clamped_bubble_screen_top - layout.canvas_screen_y; + + // === CALCULATE TAIL OFFSET === + let tail_offset = if bubble_width > 0.0 { + let avatar_rel_to_bubble = avatar_center_x - bubble_left; + (avatar_rel_to_bubble / bubble_width * 100.0).clamp(10.0, 90.0) + } else { + 50.0 + }; + + Some((bubble_left, bubble_top, tail_offset, position)) + }; + + // Bubble visibility check + #[cfg(feature = "hydrate")] + let is_bubble_visible = move || { + let Some(bubble_signal) = active_bubble else { + return false; + }; + let Some(bubble) = bubble_signal.get() else { + return false; + }; + let now = js_sys::Date::now() as i64; + now < bubble.expires_at + }; + #[cfg(not(feature = "hydrate"))] + let is_bubble_visible = move || { + active_bubble.map(|s| s.get().is_some()).unwrap_or(false) + }; + + // Bubble style + let bubble_style = move || { + let Some(bubble_signal) = active_bubble else { + return "display: none;".to_string(); + }; + let Some(bubble) = bubble_signal.get() else { + return "display: none;".to_string(); + }; + let Some(layout) = layout_signal.get() else { + return "display: none;".to_string(); + }; + + let (bg_color, border_color, text_color) = + emotion_bubble_colors(&bubble.message.emotion); + + let max_width = 200.0 * layout.text_scale; + let padding = 8.0 * layout.text_scale; + let tail_size = 8.0 * layout.text_scale; + let font_size = 12.0 * layout.text_scale; + let border_width = 2.0; // CSS border width + + let measured = bubble_is_measured.get(); + let m_height = bubble_measured_height.get(); + + // Detect multi-line: if measured height exceeds what a single line would be + // Single line height = font-size * line-height + padding * 2 + border * 2 + let single_line_height = font_size * 1.5 + padding * 2.0 + border_width * 2.0; + let is_multiline = measured && m_height > single_line_height * 1.3; + + // Width strategy: fit-content for single line, explicit max-width for multi-line + // This ensures long text gets full width while short text shrinks to fit + let width_style = if is_multiline { + format!("width: {}px;", max_width) + } else { + format!("width: fit-content; max-width: {}px;", max_width) + }; + + // Get reactive position (recalculates when avatar moves) + let (position_type, final_left, final_top, tail_offset, position) = + if let Some((left, top, tail, pos)) = calc_bubble_position() { + ("absolute", left, top, tail, pos) + } else { + // Not yet measured - use fixed positioning off-screen + ("fixed", -1000.0, -1000.0, 50.0, BubblePosition::Above) + }; + + // Start invisible until measured (prevents flash) + let bubble_opacity = if measured { 1.0 } else { 0.0 }; + + // Store position for bubble_class to use + set_bubble_position.set(position); + + format!( + "position: {}; \ + left: {}px; \ + top: {}px; \ + {} \ + opacity: {}; \ + --bubble-bg: {}; \ + --bubble-border: {}; \ + --bubble-text: {}; \ + --tail-offset: {}%; \ + --font-size: {}px; \ + --padding: {}px; \ + --tail-size: {}px; \ + --border-radius: {}px; \ + z-index: 99999; \ + pointer-events: none;", + position_type, + final_left, + final_top, + width_style, + bubble_opacity, + bg_color, + border_color, + text_color, + tail_offset, + font_size, + padding, + tail_size, + 8.0 * layout.text_scale, + ) + }; + + let bubble_class = move || { + let position = bubble_position.get(); + match position { + BubblePosition::Above => "speech-bubble tail-below", + BubblePosition::Below => "speech-bubble tail-above", + } + }; + + let bubble_content = move || { + active_bubble + .and_then(|s| s.get()) + .map(|b| b.message.content.clone()) + .unwrap_or_default() + }; + + let bubble_is_whisper = move || { + active_bubble + .and_then(|s| s.get()) + .map(|b| b.message.is_whisper) + .unwrap_or(false) }; view! { - +
+ +
+ {display_name} +
+ +
+
+ {bubble_content} +
+
+
+
} } - -// Note: Speech bubble rendering functions have been removed. -// Bubbles are now rendered as separate HTML elements (see speech_bubble.rs). diff --git a/crates/chattyness-user-ui/src/components/scene_viewer.rs b/crates/chattyness-user-ui/src/components/scene_viewer.rs index aaf4e3e..b87c99a 100644 --- a/crates/chattyness-user-ui/src/components/scene_viewer.rs +++ b/crates/chattyness-user-ui/src/components/scene_viewer.rs @@ -22,14 +22,12 @@ use uuid::Uuid; use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene}; -use super::avatar_canvas::{AvatarBoundsStore, AvatarCanvas, ScreenBounds, member_key}; +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::speech_bubble::SpeechBubble; -use super::username_label::UsernameLabel; use super::settings::{ BASE_AVATAR_SCALE, BASE_PROP_SIZE, REFERENCE_HEIGHT, REFERENCE_WIDTH, ViewerSettings, calculate_min_zoom, @@ -462,34 +460,6 @@ pub fn RealmSceneViewer( sorted_members.get().iter().map(member_key).collect::>() }); - // Shared store for avatar bounds - AvatarCanvas writes, SpeechBubble reads - let avatar_bounds_store: AvatarBoundsStore = RwSignal::new(HashMap::new()); - - // Clean up bounds store when members change (prevent memory leak) - Effect::new(move |_| { - let current_member_ids: std::collections::HashSet<_> = members - .get() - .iter() - .map(|m| m.member.user_id) - .collect(); - - avatar_bounds_store.update(|map| { - map.retain(|id, _| current_member_ids.contains(id)); - }); - }); - - // Scene bounds for clamping bubbles - computed once outside render loop - let scene_bounds_signal = Signal::derive(move || { - ScreenBounds::from_transform( - scene_width_signal.get(), - scene_height_signal.get(), - scale_x_signal.get(), - scale_y_signal.get(), - offset_x_signal.get(), - offset_y_signal.get(), - ) - }); - let scene_name = scene.name.clone(); view! { @@ -524,7 +494,7 @@ pub fn RealmSceneViewer( }} -
+
{move || { member_keys.get().into_iter().map(|key| { @@ -535,9 +505,13 @@ pub fn RealmSceneViewer( members_by_key.get().get(&key).map(|(idx, _)| (*idx as i32) + 10).unwrap_or(10) }); 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! { - } }).collect_view() @@ -567,9 +541,9 @@ pub fn RealmSceneViewer( 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() }); - // Note: fading members don't need to update bounds store + // Fading members don't show bubbles Some(view! { -
-
- - // Active members - {move || { - members_by_key.get().into_iter().map(|(_, (_, m))| { - let user_id = m.member.user_id; - let display_name = m.member.display_name.clone(); - view! { - - } - }).collect_view() - }} - // Fading members - {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 user_id = fading.member.member.user_id; - let display_name = fading.member.member.display_name.clone(); - Some(view! { - - }) - } else { None } - }).collect_view() - }} - -
-
- - {move || { - active_bubbles.get().into_iter().map(|(user_id, bubble)| { - view! { - - } - }).collect_view() - }} - -
, - #[prop(default = 1.0.into())] - text_em_size: Signal, -) -> impl IntoView { - let content = bubble.message.content.clone(); - let emotion = bubble.message.emotion.clone(); - let is_whisper = bubble.message.is_whisper; - let expires_at = bubble.expires_at; - - let (bg_color, border_color, text_color) = emotion_bubble_colors(&emotion); - - // Reference to the bubble element for measuring - let bubble_ref = NodeRef::::new(); - - // After measuring: store the computed position and dimensions - let (measured_left, set_measured_left) = signal(0.0_f64); - let (measured_width, set_measured_width) = signal(0.0_f64); - let (measured_top, set_measured_top) = signal(0.0_f64); - let (is_measured, set_is_measured) = signal(false); - - // Track the measured position (above or below) - let (measured_position, set_measured_position) = signal(BubblePosition::Above); - - // Compute base layout values (position will be determined after measuring) - let base_layout = Memo::new(move |_| { - let te = text_em_size.get(); - let text_scale = te * BASE_TEXT_SCALE; - let max_width = 200.0 * text_scale; - let padding = 8.0 * text_scale; - let tail_size = 8.0 * text_scale; - let gap = 5.0 * text_scale; - - let b = bounds.get(); - let avatar_bounds = avatar_bounds_store - .get() - .get(&user_id) - .copied() - .unwrap_or_default(); - - let ax = avatar_bounds.content_center_x; - let content_top = avatar_bounds.content_top_y; - let content_bottom = avatar_bounds.content_bottom_y; - - // Check if avatar has rendered - let visible = !(ax == 0.0 && content_top == 0.0); - - // Container-relative coordinates - let container_x = ax - b.min_x; - let container_top = content_top - b.min_y; - let container_bottom = content_bottom - b.min_y; - let container_width = b.max_x - b.min_x; - let container_height = b.max_y - b.min_y; - - (visible, container_x, container_top, container_bottom, container_width, container_height, max_width, text_scale, padding, tail_size, gap) - }); - - // Effect to measure bubble and calculate final position - #[cfg(feature = "hydrate")] - Effect::new(move |_| { - let (visible, container_x, container_top, container_bottom, container_width, container_height, _, _, _, tail_size, gap) = base_layout.get(); - if !visible { - return; - } - - if let Some(el) = bubble_ref.get() { - // Get actual bubble dimensions - let rect = el.get_bounding_client_rect(); - let bubble_width = rect.width(); - let bubble_height = rect.height(); - - if bubble_width > 0.0 && bubble_height > 0.0 { - // Calculate ideal left edge (centered on avatar) - let ideal_left = container_x - bubble_width / 2.0; - - // Clamp to keep bubble within container horizontally - let clamped_left = ideal_left - .max(0.0) - .min((container_width - bubble_width).max(0.0)); - - // Determine position based on actual measured height - // Space needed above = bubble_height + tail_size + gap - let space_needed = bubble_height + tail_size + gap; - let position = if container_top >= space_needed { - BubblePosition::Above - } else { - BubblePosition::Below - }; - - // Calculate vertical position based on determined position - let ideal_top = match position { - BubblePosition::Above => container_top - gap - tail_size - bubble_height, - BubblePosition::Below => container_bottom + gap + tail_size, - }; - - // Clamp to keep bubble within container vertically - let clamped_top = ideal_top - .max(0.0) - .min((container_height - bubble_height).max(0.0)); - - set_measured_left.set(clamped_left); - set_measured_width.set(bubble_width); - set_measured_top.set(clamped_top); - set_measured_position.set(position); - set_is_measured.set(true); - } - } - }); - - // Generate style - let style = move || { - let (visible, container_x, _, _, _, _, max_width, text_scale, padding, tail_size, _) = base_layout.get(); - - if !visible { - return "display: none;".to_string(); - } - - let measured = is_measured.get(); - let m_left = measured_left.get(); - let m_width = measured_width.get(); - let m_top = measured_top.get(); - - // Before measurement: position at left=0 so bubble can expand to full width - // After measurement: use calculated positions directly (no transforms) - let (final_left, final_top) = if measured { - (m_left, m_top) - } else { - // Position at left edge initially to allow full-width measurement - // The bubble is invisible during this phase anyway - (0.0, 0.0) - }; - - // Tail offset: where is avatar relative to bubble left edge? - let tail_offset = if measured && m_width > 0.0 { - // avatar_x (container_x) relative to bubble left, as percentage of bubble width - let avatar_offset = container_x - m_left; - (avatar_offset / m_width * 100.0).clamp(10.0, 90.0) - } else { - 50.0 - }; - - // Start invisible until measured (prevents flash) - let opacity = if measured { 1.0 } else { 0.0 }; - - format!( - "position: absolute; \ - left: {}px; \ - top: {}px; \ - width: fit-content; \ - max-width: {}px; \ - opacity: {}; \ - --bubble-bg: {}; \ - --bubble-border: {}; \ - --bubble-text: {}; \ - --tail-offset: {}%; \ - --font-size: {}px; \ - --padding: {}px; \ - --tail-size: {}px; \ - --border-radius: {}px; \ - z-index: 99999;", - final_left, - final_top, - max_width, - opacity, - bg_color, - border_color, - text_color, - tail_offset, - 12.0 * text_scale, - padding, - tail_size, - 8.0 * text_scale, - ) - }; - - let tail_class = move || { - let position = measured_position.get(); - match position { - BubblePosition::Above => "speech-bubble tail-below", - BubblePosition::Below => "speech-bubble tail-above", - } - }; - - #[cfg(feature = "hydrate")] - let is_visible = move || { - let now = js_sys::Date::now() as i64; - now < expires_at - }; - #[cfg(not(feature = "hydrate"))] - let is_visible = move || true; - - view! { - -
-
- {content.clone()} -
-
-
- } -} diff --git a/crates/chattyness-user-ui/src/components/username_label.rs b/crates/chattyness-user-ui/src/components/username_label.rs deleted file mode 100644 index 6749545..0000000 --- a/crates/chattyness-user-ui/src/components/username_label.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! HTML-based username label component. -//! -//! Renders display names as HTML/CSS elements instead of canvas, -//! allowing independent updates without triggering avatar redraws. - -use leptos::prelude::*; -use uuid::Uuid; - -use super::avatar_canvas::AvatarBoundsStore; - -/// Base text size multiplier. Text at 100% slider = base_sizes * 1.4 -const BASE_TEXT_SCALE: f64 = 1.4; - -/// Individual username label component. -/// -/// Renders as HTML/CSS for efficient updates independent of avatar canvas. -/// Reads avatar position from the shared bounds store written by AvatarCanvas. -#[component] -pub fn UsernameLabel( - /// The user ID this label belongs to (for reading bounds from store). - user_id: Uuid, - /// The display name to show. - display_name: String, - /// Shared store containing avatar bounds (written by AvatarCanvas). - avatar_bounds_store: AvatarBoundsStore, - /// Text size multiplier. - #[prop(default = 1.0.into())] - text_em_size: Signal, - /// Optional opacity for fading members. - #[prop(default = 1.0)] - opacity: f64, -) -> impl IntoView { - // Compute style based on avatar bounds - let style = Memo::new(move |_| { - let te = text_em_size.get(); - let text_scale = te * BASE_TEXT_SCALE; - let font_size = 12.0 * text_scale; - - let avatar_bounds = avatar_bounds_store - .get() - .get(&user_id) - .copied() - .unwrap_or_default(); - - // If bounds are all zero, avatar hasn't rendered yet - if avatar_bounds.content_center_x == 0.0 && avatar_bounds.content_top_y == 0.0 { - return "display: none;".to_string(); - } - - let x = avatar_bounds.content_center_x; - let y = avatar_bounds.content_bottom_y + 15.0 * text_scale; - - format!( - "position: absolute; \ - left: {}px; \ - top: {}px; \ - transform: translateX(-50%); \ - --font-size: {}px; \ - opacity: {}; \ - z-index: 99998;", - x, y, font_size, opacity - ) - }); - - view! { -
- {display_name} -
- } -}