chattyness/crates/chattyness-user-ui/src/components/avatar_canvas.rs

1062 lines
36 KiB
Rust

//! 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<String>; 9],
clothes: &[Option<String>; 9],
accessories: &[Option<String>; 9],
emotion: &[Option<String>; 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).
pub fn member_key(m: &ChannelMemberWithAvatar) -> (Option<Uuid>, Option<Uuid>) {
(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<ChannelMemberWithAvatar>,
/// X scale factor for coordinate conversion.
scale_x: Signal<f64>,
/// Y scale factor for coordinate conversion.
scale_y: Signal<f64>,
/// X offset for coordinate conversion.
offset_x: Signal<f64>,
/// Y offset for coordinate conversion.
offset_y: Signal<f64>,
/// Size of the avatar in pixels.
prop_size: Signal<f64>,
/// Z-index for stacking order (higher = on top).
z_index: i32,
/// Active speech bubble for this user (if any).
active_bubble: Signal<Option<ActiveBubble>>,
/// Text size multiplier for display names, chat bubbles, and badges.
#[prop(default = 1.0.into())]
text_em_size: Signal<f64>,
/// 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<Signal<f64>>,
/// Scene height in scene coordinates (for boundary calculations).
#[prop(optional)]
scene_height: Option<Signal<f64>>,
) -> impl IntoView {
let canvas_ref = NodeRef::<leptos::html::Canvas>::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<RefCell<HashMap<String, web_sys::HtmlImageElement>>> =
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<RefCell<HashMap<String, web_sys::HtmlImageElement>>>,
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<dyn FnOnce()>);
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 > 0 {
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
.map(|u| u.to_string())
.or_else(|| m.member.guest_session_id.map(|g| g.to_string()))
.unwrap_or_default()
};
view! {
<canvas
node_ref=canvas_ref
style=style
data-member-id=data_member_id
/>
}
}
/// 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<String> {
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<Vec<u8>>
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();
}