* make guest status a flag on users * add logout handlers * add logout notification for other users
1059 lines
36 KiB
Rust
1059 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, 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<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).
|
|
/// 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<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 != 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! {
|
|
<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();
|
|
}
|