//! 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; 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 } } /// Get a unique key for a member (for Leptos For keying). pub fn member_key(m: &ChannelMemberWithAvatar) -> (Option, Option) { (m.member.user_id, m.member.guest_session_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 offsets from grid center to content center let x_content_offset = content_bounds.x_offset(ps); let y_content_offset = content_bounds.y_offset(ps); // Avatar is a 3x3 grid of props, each prop is prop_size let avatar_size = ps * 3.0; // 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 for avatar clamping let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy); // Calculate raw avatar screen position let avatar_screen_x = m.member.position_x * sx + ox; let avatar_screen_y = m.member.position_y * sy + oy; // Clamp avatar center so visual bounds stay within screen boundaries // Use actual content extent rather than full 3x3 grid let content_half_width = content_bounds.content_width(ps) / 2.0; let content_half_height = content_bounds.content_height(ps) / 2.0; let (clamped_x, clamped_y) = boundaries.clamp_avatar_center( avatar_screen_x, avatar_screen_y, content_half_width, content_half_height, ); // Calculate canvas position from clamped screen coordinates, adjusted for content bounds let canvas_x = clamped_x - avatar_size / 2.0 - x_content_offset; let canvas_y = clamped_y - avatar_size / 2.0 - y_content_offset; // Fixed text dimensions (independent of prop_size/zoom) let text_scale = te * BASE_TEXT_SCALE; let fixed_bubble_height = if bubble.is_some() { (4.0 * 16.0 + 16.0 + 8.0 + 5.0) * text_scale } else { 0.0 }; let fixed_name_height = 20.0 * text_scale; let fixed_text_width = 200.0 * text_scale; // Determine bubble position based on available space above avatar // This must match the logic in the Effect that draws the bubble let bubble_position = if bubble.is_some() { // Use clamped avatar screen position for bubble calculation let avatar_half_height = avatar_size / 2.0 + y_content_offset; // Calculate bubble height using actual content (includes tail + gap) let estimated_bubble_height = bubble .as_ref() .map(|b| estimate_bubble_height(&b.message.content, text_scale)) .unwrap_or(0.0); determine_bubble_position( clamped_y, avatar_half_height, estimated_bubble_height, 0.0, // Already included in estimate_bubble_height 0.0, // Already included in estimate_bubble_height boundaries.min_y, ) } else { BubblePosition::Above // Default when no bubble }; // Canvas must fit avatar, text, AND bubble (positioned based on bubble location) let canvas_width = avatar_size.max(fixed_text_width); let canvas_height = avatar_size + fixed_bubble_height + fixed_name_height; // Adjust position based on bubble position // When bubble is above: offset canvas upward to make room at top // When bubble is below: no upward offset, bubble goes below avatar let adjusted_y = match bubble_position { BubblePosition::Above => canvas_y - fixed_bubble_height, BubblePosition::Below => canvas_y, }; format!( "position: absolute; \ left: 0; top: 0; \ transform: translate({}px, {}px); \ z-index: {}; \ pointer-events: {}; \ width: {}px; \ height: {}px; \ opacity: {};", canvas_x - (canvas_width - avatar_size) / 2.0, adjusted_y, z_index, pointer_events, canvas_width, canvas_height, 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 dimensions (same as in style closure) let content_bounds = ContentBounds::from_layers( &m.avatar.skin_layer, &m.avatar.clothes_layer, &m.avatar.accessories_layer, &m.avatar.emotion_layer, ); let avatar_size = ps * 3.0; let text_scale = te * BASE_TEXT_SCALE; let fixed_bubble_height = if bubble.is_some() { (4.0 * 16.0 + 16.0 + 8.0 + 5.0) * text_scale } else { 0.0 }; let fixed_name_height = 20.0 * text_scale; let fixed_text_width = 200.0 * text_scale; // Determine bubble position early so we can position the avatar correctly let y_content_offset = content_bounds.y_offset(ps); let bubble_position = if bubble.is_some() { let sx = scale_x.get(); let sy = scale_y.get(); let ox = offset_x.get(); let oy = offset_y.get(); // 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 let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy); // Calculate avatar's screen position let avatar_screen_y = m.member.position_y * sy + oy; let avatar_half_height = avatar_size / 2.0 + y_content_offset; // Calculate bubble height using actual content (includes tail + gap) let estimated_bubble_height = bubble .as_ref() .map(|b| estimate_bubble_height(&b.message.content, text_scale)) .unwrap_or(0.0); determine_bubble_position( avatar_screen_y, avatar_half_height, estimated_bubble_height, 0.0, // Already included in estimate_bubble_height 0.0, // Already included in estimate_bubble_height boundaries.min_y, ) } else { BubblePosition::Above }; let canvas_width = avatar_size.max(fixed_text_width); let canvas_height = avatar_size + fixed_bubble_height + fixed_name_height; let canvas_el: &web_sys::HtmlCanvasElement = &canvas; // Set canvas resolution canvas_el.set_width(canvas_width as u32); canvas_el.set_height(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, canvas_width, canvas_height); // Avatar center position within the canvas // When bubble is above: avatar is below the bubble space // When bubble is below: avatar is at the top, bubble space is below let avatar_cx = canvas_width / 2.0; let avatar_cy = match bubble_position { BubblePosition::Above => fixed_bubble_height + avatar_size / 2.0, BubblePosition::Below => avatar_size / 2.0, }; // 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 = ps; let grid_origin_x = avatar_cx - avatar_size / 2.0; let grid_origin_y = avatar_cy - 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 > 0 { let badge_size = 16.0 * text_scale; let badge_x = avatar_cx + avatar_size / 2.0 - badge_size / 2.0; let badge_y = avatar_cy - 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 * text_scale)); ctx.set_text_align("center"); ctx.set_text_baseline("middle"); let _ = ctx.fill_text(&format!("{}", current_emotion), badge_x, badge_y); } // Calculate content bounds for name positioning let name_x = avatar_cx + content_bounds.x_offset(cell_size); let empty_bottom_rows = content_bounds.empty_bottom_rows(); // Draw display name below avatar (with black outline for readability) let display_name = &m.member.display_name; ctx.set_font(&format!("{}px sans-serif", 12.0 * text_scale)); ctx.set_text_align("center"); ctx.set_text_baseline("alphabetic"); let name_y = avatar_cy + avatar_size / 2.0 - (empty_bottom_rows as f64 * cell_size) + 15.0 * text_scale; // 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 { let content_x_offset = content_bounds.x_offset(cell_size); let content_top_adjustment = content_bounds.empty_top_rows() as f64 * cell_size; // Get screen boundaries for horizontal clamping 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); let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy); // Avatar top and bottom Y within the canvas let avatar_top_y = avatar_cy - avatar_size / 2.0; let avatar_bottom_y = avatar_cy + avatar_size / 2.0; // Use the pre-calculated bubble_position from earlier draw_bubble( &ctx, b, avatar_cx, avatar_top_y, avatar_bottom_y, content_x_offset, content_top_adjustment, te, bubble_position, Some(&boundaries), ); } } }); } // Compute data-member-id reactively let data_member_id = move || { let m = member.get(); m.member .user_id .map(|u| u.to_string()) .or_else(|| m.member.guest_session_id.map(|g| g.to_string())) .unwrap_or_default() }; 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 relative to the avatar with boundary awareness. /// /// # Arguments /// * `ctx` - Canvas rendering context /// * `bubble` - The active bubble data /// * `center_x` - Avatar center X in canvas coordinates /// * `top_y` - Avatar top edge Y in canvas coordinates /// * `bottom_y` - Avatar bottom edge Y in canvas coordinates /// * `content_x_offset` - X offset to center on content /// * `content_top_adjustment` - Y adjustment for empty top rows /// * `text_em_size` - Text size multiplier /// * `position` - Whether to render above or below the avatar /// * `boundaries` - Screen boundaries for horizontal clamping (optional) #[cfg(feature = "hydrate")] fn draw_bubble( ctx: &web_sys::CanvasRenderingContext2d, bubble: &ActiveBubble, center_x: f64, top_y: f64, bottom_y: f64, content_x_offset: f64, content_top_adjustment: f64, text_em_size: f64, position: BubblePosition, boundaries: Option<&ScreenBoundaries>, ) { // Text scale independent of zoom - only affected by user's text_em_size setting 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; // Center bubble horizontally on content (not grid center) let content_center_x = center_x + content_x_offset; // Calculate initial bubble X position (centered on content) let mut bubble_x = content_center_x - bubble_width / 2.0; // Clamp bubble horizontally to stay within drawable area if let Some(bounds) = boundaries { let bubble_left = bubble_x; let bubble_right = bubble_x + bubble_width; if bubble_left < bounds.min_x { // Shift right to stay within left edge bubble_x = bounds.min_x; } else if bubble_right > bounds.max_x { // Shift left to stay within right edge bubble_x = bounds.max_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 position { BubblePosition::Above => { // Position vertically closer to content when top rows are empty let adjusted_top_y = top_y + content_top_adjustment; adjusted_top_y - bubble_height - tail_size - gap } BubblePosition::Below => { // Position below avatar with gap for tail 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 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 (re-set font in case canvas state changed) 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(); }