//! Individual avatar canvas component for per-user rendering. //! //! 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 leptos::prelude::*; use uuid::Uuid; use chattyness_db::models::{ChannelMemberWithAvatar, EmotionState}; use super::chat_types::{ActiveBubble, emotion_bubble_colors}; /// 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 { min_col: usize, max_col: usize, min_row: usize, max_row: usize, } impl ContentBounds { /// Calculate content bounds from 4 layers (9 positions each). fn from_layers( skin: &[Option; 9], clothes: &[Option; 9], accessories: &[Option; 9], emotion: &[Option; 9], ) -> Self { let has_content_at = |pos: usize| -> bool { skin[pos].is_some() || clothes[pos].is_some() || accessories[pos].is_some() || emotion[pos].is_some() }; // Columns: 0 (left), 1 (middle), 2 (right) let left_col = [0, 3, 6].iter().any(|&p| has_content_at(p)); let mid_col = [1, 4, 7].iter().any(|&p| has_content_at(p)); let right_col = [2, 5, 8].iter().any(|&p| has_content_at(p)); let min_col = if left_col { 0 } else if mid_col { 1 } else { 2 }; let max_col = if right_col { 2 } else if mid_col { 1 } else { 0 }; // Rows: 0 (top), 1 (middle), 2 (bottom) let top_row = [0, 1, 2].iter().any(|&p| has_content_at(p)); let mid_row = [3, 4, 5].iter().any(|&p| has_content_at(p)); let bot_row = [6, 7, 8].iter().any(|&p| has_content_at(p)); let min_row = if top_row { 0 } else if mid_row { 1 } else { 2 }; let max_row = if bot_row { 2 } else if mid_row { 1 } else { 0 }; Self { min_col, max_col, min_row, max_row, } } /// Content center column (0.0 to 2.0, grid center is 1.0). fn center_col(&self) -> f64 { (self.min_col + self.max_col) as f64 / 2.0 } /// Content center row (0.0 to 2.0, grid center is 1.0). fn center_row(&self) -> f64 { (self.min_row + self.max_row) as f64 / 2.0 } /// X offset from grid center to content center. fn x_offset(&self, cell_size: f64) -> f64 { (self.center_col() - 1.0) * cell_size } /// Y offset from grid center to content center. fn y_offset(&self, cell_size: f64) -> f64 { (self.center_row() - 1.0) * cell_size } /// Number of empty rows at the bottom (for name positioning). fn empty_bottom_rows(&self) -> usize { 2 - self.max_row } /// Number of empty rows at the top (for bubble positioning). fn empty_top_rows(&self) -> usize { self.min_row } /// Width of actual content in pixels. fn content_width(&self, cell_size: f64) -> f64 { (self.max_col - self.min_col + 1) as f64 * cell_size } /// Height of actual content in pixels. fn content_height(&self, cell_size: f64) -> f64 { (self.max_row - self.min_row + 1) as f64 * cell_size } } /// Computed boundaries for visual clamping in screen space. #[derive(Clone, Copy)] struct ScreenBoundaries { /// Left edge of drawable area (= offset_x) min_x: f64, /// Right edge (= offset_x + scene_width * scale_x) max_x: f64, /// Top edge (= offset_y) min_y: f64, /// Bottom edge (= offset_y + scene_height * scale_y) max_y: f64, } impl ScreenBoundaries { 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 avatar center so visual bounds stay within screen boundaries. fn clamp_avatar_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) } } /// 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, /// providing a single source of truth for: /// - 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. #[derive(Clone, Copy)] #[allow(dead_code)] // Some fields kept for potential future use struct CanvasLayout { // Core dimensions prop_size: f64, avatar_size: f64, // 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, canvas_height: f64, // Canvas position in screen space canvas_screen_x: f64, canvas_screen_y: f64, // 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. 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>, ) -> 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( 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 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, }; // 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, }; 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, } } /// 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 } /// Top of avatar in canvas-local coordinates. fn avatar_top_y(&self) -> f64 { self.avatar_cy - self.avatar_size / 2.0 } /// Bottom of avatar in canvas-local coordinates. 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). /// Note: Guests are now regular users with the 'guest' tag, so user_id is always present. pub fn member_key(m: &ChannelMemberWithAvatar) -> Uuid { m.member.user_id } /// Individual avatar canvas 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) /// - Optional speech bubble above the avatar #[component] pub fn AvatarCanvas( /// The member data for this avatar (as a signal for reactive updates). member: Signal, /// X scale factor for coordinate conversion. scale_x: Signal, /// Y scale factor for coordinate conversion. scale_y: Signal, /// X offset for coordinate conversion. offset_x: Signal, /// Y offset for coordinate conversion. offset_y: Signal, /// Size of the avatar in pixels. 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. #[prop(default = 1.0.into())] text_em_size: Signal, /// Opacity for fade-out animation (0.0 to 1.0, default 1.0). #[prop(default = 1.0)] opacity: f64, /// Scene width in scene coordinates (for boundary calculations). #[prop(optional)] scene_width: Option>, /// Scene height in scene coordinates (for boundary calculations). #[prop(optional)] scene_height: Option>, ) -> impl IntoView { let canvas_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 || { let m = member.get(); let ps = prop_size.get(); let sx = scale_x.get(); let sy = scale_y.get(); 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( &m.avatar.skin_layer, &m.avatar.clothes_layer, &m.avatar.accessories_layer, &m.avatar.emotion_layer, ); // Get scene dimensions (use large defaults if not provided) let sw = scene_width.map(|s| s.get()).unwrap_or(10000.0); 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 avatar_screen_x = m.member.position_x * sx + ox; let avatar_screen_y = m.member.position_y * sy + oy; // Create unified layout - all calculations happen in one place let layout = CanvasLayout::new( &content_bounds, ps, te, 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 layout.css_style(z_index, pointer_events, opacity) }; // Store references for the effect #[cfg(feature = "hydrate")] { use std::cell::RefCell; use std::collections::HashMap; use std::rc::Rc; use wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure; // Image cache for this avatar (persists across re-renders) let image_cache: Rc>> = Rc::new(RefCell::new(HashMap::new())); // Redraw trigger - incremented when images load to cause Effect to re-run let (redraw_trigger, set_redraw_trigger) = signal(0u32); // Effect to draw the avatar when canvas is ready or appearance changes Effect::new(move |_| { // Subscribe to redraw trigger so this effect re-runs when images load let _ = redraw_trigger.get(); // 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; }; // Calculate content bounds for the avatar let content_bounds = ContentBounds::from_layers( &m.avatar.skin_layer, &m.avatar.clothes_layer, &m.avatar.accessories_layer, &m.avatar.emotion_layer, ); // Get scene dimensions and transform parameters let sx = scale_x.get(); let sy = scale_y.get(); let ox = offset_x.get(); let oy = offset_y.get(); let sw = scene_width.map(|s| s.get()).unwrap_or(10000.0); 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 avatar_screen_x = m.member.position_x * sx + ox; let avatar_screen_y = m.member.position_y * sy + oy; let layout = CanvasLayout::new( &content_bounds, ps, te, avatar_screen_x, avatar_screen_y, boundaries, bubble.is_some(), bubble.as_ref().map(|b| b.message.content.as_str()), ); let canvas_el: &web_sys::HtmlCanvasElement = &canvas; // Set canvas resolution from layout canvas_el.set_width(layout.canvas_width as u32); canvas_el.set_height(layout.canvas_height as u32); let Ok(Some(ctx)) = canvas_el.get_context("2d") else { return; }; let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into().unwrap(); // Clear canvas ctx.clear_rect(0.0, 0.0, layout.canvas_width, layout.canvas_height); // Helper to load and draw an image // Images are cached; when loaded, triggers a redraw via signal let draw_image = |path: &str, cache: &Rc>>, ctx: &web_sys::CanvasRenderingContext2d, x: f64, y: f64, size: f64| { let normalized_path = normalize_asset_path(path); let mut cache_borrow = cache.borrow_mut(); if let Some(img) = cache_borrow.get(&normalized_path) { // Image is in cache - draw if loaded if img.complete() && img.natural_width() > 0 { let _ = ctx.draw_image_with_html_image_element_and_dw_and_dh( img, x - size / 2.0, y - size / 2.0, size, size, ); } // If not complete, onload handler will trigger redraw } else { // Not in cache - create and start loading let img = web_sys::HtmlImageElement::new().unwrap(); // Set onload handler to trigger redraw when image loads let trigger = set_redraw_trigger; let onload = Closure::once(Box::new(move || { trigger.update(|v| *v += 1); }) as Box); img.set_onload(Some(onload.as_ref().unchecked_ref())); onload.forget(); img.set_src(&normalized_path); cache_borrow.insert(normalized_path, img); } }; // Draw all 9 positions of the avatar grid (3x3 layout) let cell_size = layout.prop_size; let grid_origin_x = layout.avatar_cx - layout.avatar_size / 2.0; let grid_origin_y = layout.avatar_cy - layout.avatar_size / 2.0; // Draw skin layer for all 9 positions for pos in 0..9 { if let Some(ref skin_path) = m.avatar.skin_layer[pos] { let col = pos % 3; let row = pos / 3; let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size; let cell_cy = grid_origin_y + (row as f64 + 0.5) * cell_size; draw_image(skin_path, &image_cache, &ctx, cell_cx, cell_cy, cell_size); } } // Draw clothes layer for all 9 positions for pos in 0..9 { if let Some(ref clothes_path) = m.avatar.clothes_layer[pos] { let col = pos % 3; let row = pos / 3; let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size; let cell_cy = grid_origin_y + (row as f64 + 0.5) * cell_size; draw_image( clothes_path, &image_cache, &ctx, cell_cx, cell_cy, cell_size, ); } } // Draw accessories layer for all 9 positions for pos in 0..9 { if let Some(ref accessories_path) = m.avatar.accessories_layer[pos] { let col = pos % 3; let row = pos / 3; let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size; let cell_cy = grid_origin_y + (row as f64 + 0.5) * cell_size; draw_image( accessories_path, &image_cache, &ctx, cell_cx, cell_cy, cell_size, ); } } // Draw emotion overlay for all 9 positions for pos in 0..9 { if let Some(ref emotion_path) = m.avatar.emotion_layer[pos] { let col = pos % 3; let row = pos / 3; let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size; let cell_cy = grid_origin_y + (row as f64 + 0.5) * cell_size; draw_image( emotion_path, &image_cache, &ctx, cell_cx, cell_cy, cell_size, ); } } // Draw emotion badge if non-neutral let current_emotion = m.member.current_emotion; if current_emotion != EmotionState::Neutral { let badge_size = 16.0 * layout.text_scale; let badge_x = layout.avatar_cx + layout.avatar_size / 2.0 - badge_size / 2.0; let badge_y = layout.avatar_cy - layout.avatar_size / 2.0 - badge_size / 2.0; ctx.begin_path(); let _ = ctx.arc( badge_x, badge_y, badge_size / 2.0, 0.0, std::f64::consts::PI * 2.0, ); ctx.set_fill_style_str("#f59e0b"); ctx.fill(); ctx.set_fill_style_str("#000"); ctx.set_font(&format!("bold {}px sans-serif", 10.0 * layout.text_scale)); ctx.set_text_align("center"); ctx.set_text_baseline("middle"); 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); } } }); } // Compute data-member-id reactively let data_member_id = move || { let m = member.get(); m.member.user_id.to_string() }; view! { } } /// Normalize an asset path to be absolute, prefixing with /static/ if needed. #[cfg(feature = "hydrate")] fn normalize_asset_path(path: &str) -> String { if path.starts_with('/') { path.to_string() } else { format!("/static/{}", path) } } /// 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 } /// Test if a click at the given client coordinates hits a non-transparent pixel. /// /// Returns true if the alpha channel at the clicked pixel is > 0. /// This enables pixel-perfect hit detection on avatar canvases. #[cfg(feature = "hydrate")] pub fn hit_test_canvas(canvas: &web_sys::HtmlCanvasElement, client_x: f64, client_y: f64) -> bool { use wasm_bindgen::JsCast; // Get the canvas bounding rect to transform client coords to canvas coords let rect = canvas.get_bounding_client_rect(); // Calculate click position relative to the canvas element let relative_x = client_x - rect.left(); let relative_y = client_y - rect.top(); // Check if click is within canvas bounds if relative_x < 0.0 || relative_y < 0.0 || relative_x >= rect.width() || relative_y >= rect.height() { return false; } // Transform to canvas pixel coordinates (accounting for CSS scaling) let canvas_width = canvas.width() as f64; let canvas_height = canvas.height() as f64; // Avoid division by zero if rect.width() == 0.0 || rect.height() == 0.0 { return false; } let scale_x = canvas_width / rect.width(); let scale_y = canvas_height / rect.height(); let pixel_x = (relative_x * scale_x) as f64; let pixel_y = (relative_y * scale_y) as f64; // Get the 2D context and read the pixel data using JavaScript interop if let Ok(Some(ctx)) = canvas.get_context("2d") { let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into().unwrap(); // Use web_sys::CanvasRenderingContext2d::get_image_data with proper error handling match ctx.get_image_data(pixel_x, pixel_y, 1.0, 1.0) { Ok(image_data) => { // Get the pixel data as Clamped> let data = image_data.data(); // Alpha channel is the 4th value (index 3) if data.len() >= 4 { return data[3] > 0; } } Err(_) => { // Security error or other issue with getImageData - assume no hit return false; } } } false } /// 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(); }