From 66368fe274b6d1c7679bdb9959ec978e5d3a7b5a0bd3546d2e27ec1eed43dada Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Mon, 26 Jan 2026 00:05:41 -0600 Subject: [PATCH] fix: Move to use HTML for text Previously we had the bubbles drawn on the avatar canvas. Now it's actually text, so is the label. --- apps/chattyness-app/style/user.css | 114 +++- crates/chattyness-user-ui/src/components.rs | 4 + .../src/components/avatar_canvas.rs | 486 ++++-------------- .../src/components/scene_viewer.rs | 127 ++++- .../src/components/speech_bubble.rs | 248 +++++++++ .../src/components/username_label.rs | 74 +++ 6 files changed, 650 insertions(+), 403 deletions(-) create mode 100644 crates/chattyness-user-ui/src/components/speech_bubble.rs create mode 100644 crates/chattyness-user-ui/src/components/username_label.rs diff --git a/apps/chattyness-app/style/user.css b/apps/chattyness-app/style/user.css index a1a72cf..b2949d5 100644 --- a/apps/chattyness-app/style/user.css +++ b/apps/chattyness-app/style/user.css @@ -5,4 +5,116 @@ * This file is imported after admin.css to allow user-specific overrides. */ -/* User-specific styles will be added here as needed */ +/* ============================================================================= + * Speech Bubble Component + * ============================================================================= */ + +.speech-bubble { + pointer-events: none; + font-family: sans-serif; + /* Padding MUST be here (not on .bubble-content) for -webkit-line-clamp to work in Chrome. + Chrome breaks line-clamp when padding is on the clamped element itself. */ + background-color: var(--bubble-bg, #374151); + border: 2px solid var(--bubble-border, #4B5563); + padding: var(--padding, 8px); + border-radius: var(--border-radius, 8px); +} + +.speech-bubble .bubble-content { + /* WARNING: -webkit-line-clamp breaks if padding is on this element in Chrome. + Padding must be on the parent (.speech-bubble) instead. */ + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + overflow: hidden; + color: var(--bubble-text, #F9FAFB); + font-size: var(--font-size, 12px); + line-height: 1.5; + word-wrap: break-word; + overflow-wrap: break-word; +} + +.speech-bubble .bubble-content.whisper { + font-style: italic; +} + +/* Tail pointing down (bubble is above avatar) */ +.speech-bubble.tail-below::after { + content: ''; + position: absolute; + bottom: 0; + left: var(--tail-offset, 50%); + transform: translateX(-50%) translateY(100%); + width: 0; + height: 0; + border-left: var(--tail-size, 8px) solid transparent; + border-right: var(--tail-size, 8px) solid transparent; + border-top: var(--tail-size, 8px) solid var(--bubble-bg, #374151); +} + +/* Tail border (for outline effect) */ +.speech-bubble.tail-below::before { + content: ''; + position: absolute; + bottom: 0; + left: var(--tail-offset, 50%); + transform: translateX(-50%) translateY(100%); + width: 0; + height: 0; + border-left: calc(var(--tail-size, 8px) + 2px) solid transparent; + border-right: calc(var(--tail-size, 8px) + 2px) solid transparent; + border-top: calc(var(--tail-size, 8px) + 2px) solid var(--bubble-border, #4B5563); + margin-left: 0; + margin-bottom: -2px; +} + +/* Tail pointing up (bubble is below avatar) */ +.speech-bubble.tail-above::after { + content: ''; + position: absolute; + top: 0; + left: var(--tail-offset, 50%); + transform: translateX(-50%) translateY(-100%); + width: 0; + height: 0; + border-left: var(--tail-size, 8px) solid transparent; + border-right: var(--tail-size, 8px) solid transparent; + border-bottom: var(--tail-size, 8px) solid var(--bubble-bg, #374151); +} + +/* Tail border (for outline effect) */ +.speech-bubble.tail-above::before { + content: ''; + position: absolute; + top: 0; + left: var(--tail-offset, 50%); + transform: translateX(-50%) translateY(-100%); + width: 0; + height: 0; + border-left: calc(var(--tail-size, 8px) + 2px) solid transparent; + border-right: calc(var(--tail-size, 8px) + 2px) solid transparent; + border-bottom: calc(var(--tail-size, 8px) + 2px) solid var(--bubble-border, #4B5563); + margin-top: -2px; +} + +/* ============================================================================= + * Username Label Component + * ============================================================================= */ + +.username-label { + pointer-events: none; + font-family: sans-serif; + font-size: var(--font-size, 12px); + color: #fff; + white-space: nowrap; + /* Black outline via text-shadow (matches canvas 3px stroke) */ + text-shadow: + -1.5px -1.5px 0 #000, + 1.5px -1.5px 0 #000, + -1.5px 1.5px 0 #000, + 1.5px 1.5px 0 #000, + 0px -1.5px 0 #000, + 0px 1.5px 0 #000, + -1.5px 0px 0 #000, + 1.5px 0px 0 #000; +} diff --git a/crates/chattyness-user-ui/src/components.rs b/crates/chattyness-user-ui/src/components.rs index 4fd3459..72dd9b4 100644 --- a/crates/chattyness-user-ui/src/components.rs +++ b/crates/chattyness-user-ui/src/components.rs @@ -26,7 +26,9 @@ 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; @@ -57,5 +59,7 @@ 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_canvas.rs index d35f114..3f27d7e 100644 --- a/crates/chattyness-user-ui/src/components/avatar_canvas.rs +++ b/crates/chattyness-user-ui/src/components/avatar_canvas.rs @@ -4,6 +4,8 @@ //! 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; @@ -13,33 +15,39 @@ use chattyness_db::models::{ChannelMemberWithAvatar, EmotionState}; pub use super::canvas_utils::hit_test_canvas; #[cfg(feature = "hydrate")] use super::canvas_utils::normalize_asset_path; -use super::chat_types::{ActiveBubble, emotion_bubble_colors}; + +// ============================================================================= +// Avatar Bounds - Exported for use by SpeechBubble and other components +// ============================================================================= + +/// Computed screen-space bounds for an avatar's visible content. +/// +/// 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, +} + +/// Type alias for the shared avatar bounds store. +/// Maps user_id -> computed bounds. +pub type AvatarBoundsStore = RwSignal>; /// Base text size multiplier. Text at 100% slider = base_sizes * 1.4 const BASE_TEXT_SCALE: f64 = 1.4; -/// Estimate bubble height based on actual text content. -/// Returns the total height including padding, tail, and gap. -fn estimate_bubble_height(text: &str, text_scale: f64) -> f64 { - let max_bubble_width = 200.0 * text_scale; - let padding = 8.0 * text_scale; - let line_height = 16.0 * text_scale; - let tail_size = 8.0 * text_scale; - let gap = 5.0 * text_scale; - - // Estimate chars per line: roughly 6 pixels per char at default scale - let chars_per_line = ((max_bubble_width - padding * 2.0) / (6.0 * text_scale)).floor() as usize; - let chars_per_line = chars_per_line.max(10); // minimum 10 chars per line - let char_count = text.len(); - - // Simple heuristic: estimate lines based on character count - let estimated_lines = ((char_count as f64) / (chars_per_line as f64)).ceil() as usize; - let estimated_lines = estimated_lines.clamp(1, 4); // 1-4 lines max - - let bubble_height = (estimated_lines as f64) * line_height + padding * 2.0; - bubble_height + tail_size + gap -} - /// Content bounds for a 3x3 avatar grid. /// Tracks which rows/columns contain actual content for centering calculations. struct ContentBounds { @@ -153,21 +161,25 @@ impl ContentBounds { } } -/// Computed boundaries for visual clamping in screen space. -#[derive(Clone, Copy)] -struct ScreenBoundaries { +/// 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) - min_x: f64, + pub min_x: f64, /// Right edge (= offset_x + scene_width * scale_x) - max_x: f64, + pub max_x: f64, /// Top edge (= offset_y) - min_y: f64, + pub min_y: f64, /// Bottom edge (= offset_y + scene_height * scale_y) - max_y: f64, + pub max_y: f64, } -impl ScreenBoundaries { - fn from_transform( +impl ScreenBounds { + /// Create screen bounds from scene transform parameters. + pub fn from_transform( scene_width: f64, scene_height: f64, scale_x: f64, @@ -183,8 +195,8 @@ impl ScreenBoundaries { } } - /// Clamp avatar center so visual bounds stay within screen boundaries. - fn clamp_avatar_center( + /// Clamp a center point so content stays within screen boundaries. + pub fn clamp_center( &self, center_x: f64, center_y: f64, @@ -201,35 +213,6 @@ impl ScreenBoundaries { } } -/// Position of speech bubble relative to avatar. -#[derive(Clone, Copy, PartialEq)] -enum BubblePosition { - /// Default: bubble above avatar - Above, - /// When near top edge: bubble below avatar - Below, -} - -/// Determine bubble position based on available space above avatar. -fn determine_bubble_position( - avatar_screen_y: f64, - avatar_half_height: f64, - bubble_height: f64, - tail_size: f64, - gap: f64, - min_y: f64, -) -> BubblePosition { - let space_needed = bubble_height + tail_size + gap; - let avatar_top = avatar_screen_y - avatar_half_height; - let space_available = avatar_top - min_y; - - if space_available < space_needed { - BubblePosition::Below - } else { - BubblePosition::Above - } -} - /// Unified layout context for avatar canvas rendering. /// /// This struct computes all derived layout values once from the inputs, @@ -237,12 +220,9 @@ fn determine_bubble_position( /// - Canvas dimensions and position /// - Avatar positioning within the canvas /// - Coordinate transformations between canvas-local and screen space -/// - Bubble positioning and clamping /// -/// By centralizing these calculations, we avoid scattered, duplicated logic -/// and ensure the style closure, Effect, and draw_bubble all use consistent values. +/// Note: Speech bubbles are rendered separately as HTML elements (see speech_bubble.rs). #[derive(Clone, Copy)] -#[allow(dead_code)] // Some fields kept for potential future use struct CanvasLayout { // Core dimensions prop_size: f64, @@ -250,11 +230,9 @@ struct CanvasLayout { // Content offset from grid center content_x_offset: f64, - content_y_offset: f64, // Text scaling text_scale: f64, - bubble_max_width: f64, // Canvas dimensions canvas_width: f64, @@ -267,125 +245,61 @@ struct CanvasLayout { // Avatar center within canvas (canvas-local coordinates) avatar_cx: f64, avatar_cy: f64, - - // Scene boundaries for clamping - boundaries: ScreenBoundaries, - - // Bubble state - bubble_position: BubblePosition, - bubble_height_reserved: f64, - - // Content row info for positioning - empty_top_rows: usize, - empty_bottom_rows: usize, } impl CanvasLayout { - /// Create a new layout from all input parameters. + /// Create a new layout from input parameters. fn new( content_bounds: &ContentBounds, prop_size: f64, text_em_size: f64, avatar_screen_x: f64, avatar_screen_y: f64, - boundaries: ScreenBoundaries, - has_bubble: bool, - bubble_text: Option<&str>, + boundaries: ScreenBounds, ) -> Self { let avatar_size = prop_size * 3.0; let text_scale = text_em_size * BASE_TEXT_SCALE; - let bubble_max_width = 200.0 * text_scale; // Content offsets from grid center let content_x_offset = content_bounds.x_offset(prop_size); let content_y_offset = content_bounds.y_offset(prop_size); - // Empty rows for positioning elements relative to content - let empty_top_rows = content_bounds.empty_top_rows(); - let empty_bottom_rows = content_bounds.empty_bottom_rows(); - // Content dimensions for clamping let content_half_width = content_bounds.content_width(prop_size) / 2.0; let content_half_height = content_bounds.content_height(prop_size) / 2.0; // Clamp avatar so content stays within scene - let (clamped_x, clamped_y) = boundaries.clamp_avatar_center( + let (clamped_x, clamped_y) = boundaries.clamp_center( avatar_screen_x, avatar_screen_y, content_half_width, content_half_height, ); - // Calculate bubble height and position - let bubble_height_reserved = if has_bubble { - (4.0 * 16.0 + 16.0 + 8.0 + 5.0) * text_scale - } else { - 0.0 - }; - let name_height = 20.0 * text_scale; - - // Determine bubble position (above or below) - // Use actual content height, not full 3x3 grid size - let bubble_position = if has_bubble { - let estimated_height = bubble_text - .map(|t| estimate_bubble_height(t, text_scale)) - .unwrap_or(0.0); - // clamped_y is the content center, so use content_half_height - // to find the actual top of the visible avatar content - determine_bubble_position( - clamped_y, - content_half_height, - estimated_height, - 0.0, - 0.0, - boundaries.min_y, - ) - } else { - BubblePosition::Above - }; - - // Canvas dimensions - wide enough to fit shifted bubble - let extra_margin = if has_bubble { bubble_max_width } else { 0.0 }; - let canvas_width = avatar_size.max(bubble_max_width) + extra_margin; - let canvas_height = avatar_size + bubble_height_reserved + name_height; + // Canvas dimensions - sized for avatar grid only (name rendered via HTML) + let canvas_width = avatar_size; + let canvas_height = avatar_size; // Canvas position in screen space - // The avatar grid center maps to canvas_width/2, but we need to account - // for the content offset so the visible content aligns with clamped_x/y - let canvas_x = clamped_x - avatar_size / 2.0 - content_x_offset; - let canvas_screen_x = canvas_x - (canvas_width - avatar_size) / 2.0; - - let canvas_y = clamped_y - avatar_size / 2.0 - content_y_offset; - let canvas_screen_y = match bubble_position { - BubblePosition::Above => canvas_y - bubble_height_reserved, - BubblePosition::Below => canvas_y, - }; + // Account for content offset so visible content aligns with clamped position + let canvas_screen_x = clamped_x - avatar_size / 2.0 - content_x_offset; + let canvas_screen_y = clamped_y - avatar_size / 2.0 - content_y_offset; // Avatar center within canvas let avatar_cx = canvas_width / 2.0; - let avatar_cy = match bubble_position { - BubblePosition::Above => bubble_height_reserved + avatar_size / 2.0, - BubblePosition::Below => avatar_size / 2.0, - }; + let avatar_cy = avatar_size / 2.0; Self { prop_size, avatar_size, content_x_offset, - content_y_offset, text_scale, - bubble_max_width, canvas_width, canvas_height, canvas_screen_x, canvas_screen_y, avatar_cx, avatar_cy, - boundaries, - bubble_position, - bubble_height_reserved, - empty_top_rows, - empty_bottom_rows, } } @@ -424,39 +338,6 @@ impl CanvasLayout { fn avatar_bottom_y(&self) -> f64 { self.avatar_cy + self.avatar_size / 2.0 } - - /// Convert canvas-local X to screen X. - fn canvas_to_screen_x(&self, x: f64) -> f64 { - self.canvas_screen_x + x - } - - /// Clamp a bubble's X position to stay within scene boundaries. - /// Takes and returns canvas-local coordinates. - fn clamp_bubble_x(&self, bubble_x: f64, bubble_width: f64) -> f64 { - // Convert to screen space - let screen_left = self.canvas_to_screen_x(bubble_x); - let screen_right = screen_left + bubble_width; - - // Calculate shifts needed to stay within bounds - let shift_right = (self.boundaries.min_x - screen_left).max(0.0); - let shift_left = (screen_right - self.boundaries.max_x).max(0.0); - - // Apply shift and clamp to canvas bounds - let shifted = bubble_x + shift_right - shift_left; - shifted.max(0.0).min(self.canvas_width - bubble_width) - } - - /// Adjustment for bubble Y position based on empty rows at top. - /// Returns the distance in pixels from grid top to content top. - fn content_top_adjustment(&self) -> f64 { - self.empty_top_rows as f64 * self.prop_size - } - - /// Adjustment for name Y position based on empty rows at bottom. - /// Returns the distance in pixels from grid bottom to content bottom. - fn content_bottom_adjustment(&self) -> f64 { - self.empty_bottom_rows as f64 * self.prop_size - } } /// Get a unique key for a member (for Leptos For keying). @@ -470,7 +351,10 @@ pub fn member_key(m: &ChannelMemberWithAvatar) -> Uuid { /// Renders a single avatar with: /// - CSS transform for position (GPU-accelerated, no redraw on move) /// - Canvas for avatar sprite (redraws only on appearance change) -/// - Optional speech bubble above the avatar +/// +/// 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. #[component] pub fn AvatarCanvas( /// The member data for this avatar (as a signal for reactive updates). @@ -487,9 +371,7 @@ pub fn AvatarCanvas( prop_size: Signal, /// Z-index for stacking order (higher = on top). z_index: i32, - /// Active speech bubble for this user (if any). - active_bubble: Signal>, - /// Text size multiplier for display names, chat bubbles, and badges. + /// Text size multiplier for display names and badges. #[prop(default = 1.0.into())] text_em_size: Signal, /// Opacity for fade-out animation (0.0 to 1.0, default 1.0). @@ -501,6 +383,10 @@ 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. + #[prop(optional)] + bounds_store: Option, ) -> impl IntoView { let canvas_ref = NodeRef::::new(); @@ -517,7 +403,6 @@ pub fn AvatarCanvas( let ox = offset_x.get(); let oy = offset_y.get(); let te = text_em_size.get(); - let bubble = active_bubble.get(); // Calculate content bounds for centering on actual content let content_bounds = ContentBounds::from_layers( @@ -532,7 +417,7 @@ pub fn AvatarCanvas( let sh = scene_height.map(|s| s.get()).unwrap_or(10000.0); // Compute screen boundaries and avatar screen position - let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy); + let boundaries = ScreenBounds::from_transform(sw, sh, sx, sy, ox, oy); let avatar_screen_x = m.member.position_x * sx + ox; let avatar_screen_y = m.member.position_y * sy + oy; @@ -544,8 +429,6 @@ pub fn AvatarCanvas( avatar_screen_x, avatar_screen_y, boundaries, - bubble.is_some(), - bubble.as_ref().map(|b| b.message.content.as_str()), ); // Generate CSS style from layout @@ -575,9 +458,9 @@ pub fn AvatarCanvas( // Get current values from signals let m = member.get(); + let ps = prop_size.get(); let te = text_em_size.get(); - let bubble = active_bubble.get(); let Some(canvas) = canvas_ref.get() else { return; @@ -600,7 +483,7 @@ pub fn AvatarCanvas( let sh = scene_height.map(|s| s.get()).unwrap_or(10000.0); // Create unified layout - same calculation as style closure - let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy); + let boundaries = ScreenBounds::from_transform(sw, sh, sx, sy, ox, oy); let avatar_screen_x = m.member.position_x * sx + ox; let avatar_screen_y = m.member.position_y * sy + oy; @@ -611,10 +494,26 @@ pub fn AvatarCanvas( avatar_screen_x, avatar_screen_y, boundaries, - bubble.is_some(), - bubble.as_ref().map(|b| b.message.content.as_str()), ); + // 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); + }); + } + let canvas_el: &web_sys::HtmlCanvasElement = &canvas; // Set canvas resolution from layout @@ -764,30 +663,7 @@ pub fn AvatarCanvas( let _ = ctx.fill_text(&format!("{}", current_emotion), badge_x, badge_y); } - // Draw display name below avatar (with black outline for readability) - let name_x = layout.content_center_x(); - let name_y = layout.avatar_bottom_y() - layout.content_bottom_adjustment() - + 15.0 * layout.text_scale; - - let display_name = &m.member.display_name; - ctx.set_font(&format!("{}px sans-serif", 12.0 * layout.text_scale)); - ctx.set_text_align("center"); - ctx.set_text_baseline("alphabetic"); - // Black outline - ctx.set_stroke_style_str("#000"); - ctx.set_line_width(3.0); - let _ = ctx.stroke_text(display_name, name_x, name_y); - // White fill - ctx.set_fill_style_str("#fff"); - let _ = ctx.fill_text(display_name, name_x, name_y); - - // Draw speech bubble if active - if let Some(ref b) = bubble { - let current_time = js_sys::Date::now() as i64; - if b.expires_at >= current_time { - draw_bubble_with_layout(&ctx, b, &layout, te); - } - } + // Note: Display name and speech bubbles are now rendered separately as HTML elements }); } @@ -806,187 +682,5 @@ pub fn AvatarCanvas( } } - -/// Draw a speech bubble using the unified CanvasLayout. -/// -/// This is the preferred method for drawing bubbles - it uses the layout's -/// coordinate transformation and clamping methods, ensuring consistency -/// with the canvas positioning. -#[cfg(feature = "hydrate")] -fn draw_bubble_with_layout( - ctx: &web_sys::CanvasRenderingContext2d, - bubble: &ActiveBubble, - layout: &CanvasLayout, - text_em_size: f64, -) { - let text_scale = text_em_size * BASE_TEXT_SCALE; - let max_bubble_width = 200.0 * text_scale; - let padding = 8.0 * text_scale; - let font_size = 12.0 * text_scale; - let line_height = 16.0 * text_scale; - let tail_size = 8.0 * text_scale; - let border_radius = 8.0 * text_scale; - let gap = 5.0 * text_scale; - - let (bg_color, border_color, text_color) = emotion_bubble_colors(&bubble.message.emotion); - - // Use italic font for whispers - let font_style = if bubble.message.is_whisper { - "italic " - } else { - "" - }; - - // Measure and wrap text - ctx.set_font(&format!("{}{}px sans-serif", font_style, font_size)); - let lines = wrap_text(ctx, &bubble.message.content, max_bubble_width - padding * 2.0); - - // Calculate bubble dimensions - let bubble_width = lines - .iter() - .map(|line| ctx.measure_text(line).map(|m| m.width()).unwrap_or(0.0)) - .fold(0.0_f64, |a, b| a.max(b)) - + padding * 2.0; - let bubble_width = bubble_width.max(60.0 * text_scale); - let bubble_height = (lines.len() as f64) * line_height + padding * 2.0; - - // Get content center from layout - let content_center_x = layout.content_center_x(); - - // Calculate initial bubble X (centered on content) - let initial_bubble_x = content_center_x - bubble_width / 2.0; - - // Use layout's clamping method - this handles coordinate transformation correctly - let bubble_x = layout.clamp_bubble_x(initial_bubble_x, bubble_width); - - // Calculate tail center - point toward content center but stay within bubble bounds - let tail_center_x = content_center_x - .max(bubble_x + tail_size + border_radius) - .min(bubble_x + bubble_width - tail_size - border_radius); - - // Calculate vertical position based on bubble position - let bubble_y = match layout.bubble_position { - BubblePosition::Above => { - // Position vertically closer to content when top rows are empty - let adjusted_top_y = layout.avatar_top_y() + layout.content_top_adjustment(); - adjusted_top_y - bubble_height - tail_size - gap - } - BubblePosition::Below => { - // Position below avatar with gap for tail - layout.avatar_bottom_y() + tail_size + gap - } - }; - - // Draw bubble background - draw_rounded_rect(ctx, bubble_x, bubble_y, bubble_width, bubble_height, border_radius); - ctx.set_fill_style_str(bg_color); - ctx.fill(); - ctx.set_stroke_style_str(border_color); - ctx.set_line_width(2.0); - ctx.stroke(); - - // Draw tail pointing to content center - ctx.begin_path(); - match layout.bubble_position { - BubblePosition::Above => { - // Tail points DOWN toward avatar - ctx.move_to(tail_center_x - tail_size, bubble_y + bubble_height); - ctx.line_to(tail_center_x, bubble_y + bubble_height + tail_size); - ctx.line_to(tail_center_x + tail_size, bubble_y + bubble_height); - } - BubblePosition::Below => { - // Tail points UP toward avatar - ctx.move_to(tail_center_x - tail_size, bubble_y); - ctx.line_to(tail_center_x, bubble_y - tail_size); - ctx.line_to(tail_center_x + tail_size, bubble_y); - } - } - ctx.close_path(); - ctx.set_fill_style_str(bg_color); - ctx.fill(); - ctx.set_stroke_style_str(border_color); - ctx.stroke(); - - // Draw text - ctx.set_font(&format!("{}{}px sans-serif", font_style, font_size)); - ctx.set_fill_style_str(text_color); - ctx.set_text_align("left"); - ctx.set_text_baseline("top"); - for (i, line) in lines.iter().enumerate() { - let _ = ctx.fill_text( - line, - bubble_x + padding, - bubble_y + padding + (i as f64) * line_height, - ); - } -} - -/// Wrap text to fit within max_width. -#[cfg(feature = "hydrate")] -fn wrap_text(ctx: &web_sys::CanvasRenderingContext2d, text: &str, max_width: f64) -> Vec { - let words: Vec<&str> = text.split_whitespace().collect(); - let mut lines = Vec::new(); - let mut current_line = String::new(); - - for word in words { - let test_line = if current_line.is_empty() { - word.to_string() - } else { - format!("{} {}", current_line, word) - }; - - let width = ctx - .measure_text(&test_line) - .map(|m| m.width()) - .unwrap_or(0.0); - - if width > max_width && !current_line.is_empty() { - lines.push(current_line); - current_line = word.to_string(); - } else { - current_line = test_line; - } - } - - if !current_line.is_empty() { - lines.push(current_line); - } - - // Limit to 4 lines - if lines.len() > 4 { - lines.truncate(3); - if let Some(last) = lines.last_mut() { - last.push_str("..."); - } - } - - if lines.is_empty() { - lines.push(text.to_string()); - } - - lines -} - - -/// Draw a rounded rectangle path. -#[cfg(feature = "hydrate")] -fn draw_rounded_rect( - ctx: &web_sys::CanvasRenderingContext2d, - x: f64, - y: f64, - width: f64, - height: f64, - radius: f64, -) { - ctx.begin_path(); - ctx.move_to(x + radius, y); - ctx.line_to(x + width - radius, y); - ctx.quadratic_curve_to(x + width, y, x + width, y + radius); - ctx.line_to(x + width, y + height - radius); - ctx.quadratic_curve_to(x + width, y + height, x + width - radius, y + height); - ctx.line_to(x + radius, y + height); - ctx.quadratic_curve_to(x, y + height, x, y + height - radius); - ctx.line_to(x, y + radius); - ctx.quadratic_curve_to(x, y, x + radius, y); - ctx.close_path(); -} +// 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 20c11c3..8892d71 100644 --- a/crates/chattyness-user-ui/src/components/scene_viewer.rs +++ b/crates/chattyness-user-ui/src/components/scene_viewer.rs @@ -22,12 +22,14 @@ use uuid::Uuid; use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene}; -use super::avatar_canvas::{AvatarCanvas, member_key}; +use super::avatar_canvas::{AvatarBoundsStore, AvatarCanvas, ScreenBounds, 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, @@ -460,6 +462,34 @@ 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! { @@ -504,7 +534,6 @@ pub fn RealmSceneViewer( let z_index_signal = Signal::derive(move || { members_by_key.get().get(&key).map(|(idx, _)| (*idx as i32) + 10).unwrap_or(10) }); - let bubble_signal = Signal::derive(move || active_bubbles.get().get(&key).cloned()); let z = z_index_signal.get_untracked(); view! { @@ -516,10 +545,10 @@ pub fn RealmSceneViewer( offset_y=offset_y_signal prop_size=prop_size z_index=z - active_bubble=bubble_signal text_em_size=text_em_size scene_width=scene_width_signal scene_height=scene_height_signal + bounds_store=avatar_bounds_store /> } }).collect_view() @@ -538,7 +567,7 @@ 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() }); - let bubble_signal: Signal> = Signal::derive(|| None); + // Note: fading members don't need to update bounds store Some(view! { +
+ + // Debug dots at avatar bounds center + {move || { + members_by_key.get().into_iter().map(|(_, (_, m))| { + let user_id = m.member.user_id; + let dot_style = Memo::new(move |_| { + let bounds = avatar_bounds_store.get(); + if let Some(ab) = bounds.get(&user_id) { + format!( + "position: absolute; left: {}px; top: {}px; \ + width: 5px; height: 5px; background: red; \ + border-radius: 50%; transform: translate(-50%, -50%); \ + z-index: 99997;", + ab.content_center_x, ab.content_center_y + ) + } else { + "display: none;".to_string() + } + }); + view! { +
+ } + }).collect_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() + }} + +