diff --git a/crates/chattyness-user-ui/src/components.rs b/crates/chattyness-user-ui/src/components.rs index 6529dd6..c36b2e9 100644 --- a/crates/chattyness-user-ui/src/components.rs +++ b/crates/chattyness-user-ui/src/components.rs @@ -1,5 +1,6 @@ //! Reusable UI components. +pub mod avatar_canvas; pub mod chat; pub mod chat_types; pub mod editor; @@ -12,6 +13,7 @@ pub mod settings; pub mod settings_popup; pub mod ws_client; +pub use avatar_canvas::*; pub use chat::*; pub use chat_types::*; pub use editor::*; diff --git a/crates/chattyness-user-ui/src/components/avatar_canvas.rs b/crates/chattyness-user-ui/src/components/avatar_canvas.rs new file mode 100644 index 0000000..01fc83f --- /dev/null +++ b/crates/chattyness-user-ui/src/components/avatar_canvas.rs @@ -0,0 +1,372 @@ +//! 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::{emotion_bubble_colors, ActiveBubble}; +use super::settings::BASE_PROP_SIZE; + +/// 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. + member: ChannelMemberWithAvatar, + /// X scale factor for coordinate conversion. + scale_x: f64, + /// Y scale factor for coordinate conversion. + scale_y: f64, + /// X offset for coordinate conversion. + offset_x: f64, + /// Y offset for coordinate conversion. + offset_y: f64, + /// Size of the avatar in pixels. + prop_size: f64, + /// Z-index for stacking order (higher = on top). + z_index: i32, + /// Active speech bubble for this user (if any). + active_bubble: Option, +) -> impl IntoView { + let canvas_ref = NodeRef::::new(); + + // Clone data for use in closures + let skin_layer = member.avatar.skin_layer.clone(); + let emotion_layer = member.avatar.emotion_layer.clone(); + let display_name = member.member.display_name.clone(); + let current_emotion = member.member.current_emotion; + + // Calculate canvas position from scene coordinates + let canvas_x = member.member.position_x * scale_x + offset_x - prop_size / 2.0; + let canvas_y = member.member.position_y * scale_y + offset_y - prop_size; + + // Calculate canvas size (extra height for bubble and name) + let bubble_extra = if active_bubble.is_some() { prop_size * 1.5 } else { 0.0 }; + let name_extra = 20.0; + let canvas_width = prop_size.max(200.0); // Wide enough for bubble + let canvas_height = prop_size + bubble_extra + name_extra; + + // Adjust position to account for extra space above avatar + let adjusted_y = canvas_y - bubble_extra; + + // CSS positioning via transform (GPU-accelerated) + let style = format!( + "position: absolute; \ + left: 0; top: 0; \ + transform: translate({}px, {}px); \ + z-index: {}; \ + pointer-events: auto; \ + width: {}px; \ + height: {}px;", + canvas_x - (canvas_width - prop_size) / 2.0, + adjusted_y, + z_index, + canvas_width, + canvas_height + ); + + // Store references for the effect + #[cfg(feature = "hydrate")] + { + use std::cell::RefCell; + use std::collections::HashMap; + use std::rc::Rc; + use wasm_bindgen::closure::Closure; + use wasm_bindgen::JsCast; + + // 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); + + // Clone values for the effect + let skin_layer_clone = skin_layer.clone(); + let emotion_layer_clone = emotion_layer.clone(); + let display_name_clone = display_name.clone(); + let active_bubble_clone = active_bubble.clone(); + + // 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(); + let Some(canvas) = canvas_ref.get() else { + return; + }; + + 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 + let avatar_cx = canvas_width / 2.0; + let avatar_cy = bubble_extra + prop_size / 2.0; + + // Draw placeholder circle + ctx.begin_path(); + let _ = ctx.arc( + avatar_cx, + avatar_cy, + prop_size / 2.0, + 0.0, + std::f64::consts::PI * 2.0, + ); + ctx.set_fill_style_str("#6366f1"); + ctx.fill(); + + // 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 skin layer (position 4 = center) + if let Some(ref skin_path) = skin_layer_clone[4] { + draw_image(skin_path, &image_cache, &ctx, avatar_cx, avatar_cy, prop_size); + } + + // Draw emotion overlay (position 4 = center) + if let Some(ref emotion_path) = emotion_layer_clone[4] { + draw_image(emotion_path, &image_cache, &ctx, avatar_cx, avatar_cy, prop_size); + } + + // Scale factor for text/badges + let text_scale = prop_size / BASE_PROP_SIZE; + + // Draw emotion badge if non-neutral + if current_emotion > 0 { + let badge_size = 16.0 * text_scale; + let badge_x = avatar_cx + prop_size / 2.0 - badge_size / 2.0; + let badge_y = avatar_cy - prop_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); + } + + // Draw display name below avatar + ctx.set_fill_style_str("#fff"); + ctx.set_font(&format!("{}px sans-serif", 12.0 * text_scale)); + ctx.set_text_align("center"); + ctx.set_text_baseline("alphabetic"); + let _ = ctx.fill_text(&display_name_clone, avatar_cx, avatar_cy + prop_size / 2.0 + 15.0 * text_scale); + + // Draw speech bubble if active + if let Some(ref bubble) = active_bubble_clone { + let current_time = js_sys::Date::now() as i64; + if bubble.expires_at >= current_time { + draw_bubble(&ctx, bubble, avatar_cx, avatar_cy - prop_size / 2.0, prop_size); + } + } + }); + } + + 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 above the avatar. +#[cfg(feature = "hydrate")] +fn draw_bubble( + ctx: &web_sys::CanvasRenderingContext2d, + bubble: &ActiveBubble, + center_x: f64, + top_y: f64, + prop_size: f64, +) { + let text_scale = prop_size / BASE_PROP_SIZE; + 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 (bg_color, border_color, text_color) = emotion_bubble_colors(&bubble.message.emotion); + + // Measure and wrap text + ctx.set_font(&format!("{}px sans-serif", 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; + + // Position bubble above avatar + let bubble_x = center_x - bubble_width / 2.0; + let bubble_y = top_y - bubble_height - tail_size - 5.0 * text_scale; + + // 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 + ctx.begin_path(); + ctx.move_to(center_x - tail_size, bubble_y + bubble_height); + ctx.line_to(center_x, bubble_y + bubble_height + tail_size); + ctx.line_to(center_x + tail_size, bubble_y + bubble_height); + 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_fill_style_str(text_color); + ctx.set_text_align("left"); + ctx.set_text_baseline("top"); + for (i, line) in lines.iter().enumerate() { + let _ = ctx.fill_text(line, bubble_x + padding, bubble_y + padding + (i as f64) * line_height); + } +} + +/// Wrap text to fit within max_width. +#[cfg(feature = "hydrate")] +fn wrap_text(ctx: &web_sys::CanvasRenderingContext2d, text: &str, max_width: f64) -> Vec { + let words: Vec<&str> = text.split_whitespace().collect(); + let mut lines = Vec::new(); + let mut current_line = String::new(); + + for word in words { + let test_line = if current_line.is_empty() { + word.to_string() + } else { + format!("{} {}", current_line, word) + }; + + let width = ctx.measure_text(&test_line).map(|m| m.width()).unwrap_or(0.0); + + if width > max_width && !current_line.is_empty() { + lines.push(current_line); + current_line = word.to_string(); + } else { + current_line = test_line; + } + } + + if !current_line.is_empty() { + lines.push(current_line); + } + + // Limit to 4 lines + if lines.len() > 4 { + lines.truncate(3); + if let Some(last) = lines.last_mut() { + last.push_str("..."); + } + } + + if lines.is_empty() { + lines.push(text.to_string()); + } + + lines +} + +/// Draw a rounded rectangle path. +#[cfg(feature = "hydrate")] +fn draw_rounded_rect( + ctx: &web_sys::CanvasRenderingContext2d, + x: f64, + y: f64, + width: f64, + height: f64, + radius: f64, +) { + ctx.begin_path(); + ctx.move_to(x + radius, y); + ctx.line_to(x + width - radius, y); + ctx.quadratic_curve_to(x + width, y, x + width, y + radius); + ctx.line_to(x + width, y + height - radius); + ctx.quadratic_curve_to(x + width, y + height, x + width - radius, y + height); + ctx.line_to(x + radius, y + height); + ctx.quadratic_curve_to(x, y + height, x, y + height - radius); + ctx.line_to(x, y + radius); + ctx.quadratic_curve_to(x, y, x + radius, y); + ctx.close_path(); +} diff --git a/crates/chattyness-user-ui/src/components/scene_viewer.rs b/crates/chattyness-user-ui/src/components/scene_viewer.rs index f1223ac..88be1e0 100644 --- a/crates/chattyness-user-ui/src/components/scene_viewer.rs +++ b/crates/chattyness-user-ui/src/components/scene_viewer.rs @@ -15,7 +15,8 @@ use uuid::Uuid; use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene}; -use super::chat_types::{emotion_bubble_colors, ActiveBubble}; +use super::avatar_canvas::{member_key, AvatarCanvas}; +use super::chat_types::ActiveBubble; use super::settings::{ calculate_min_zoom, ViewerSettings, BASE_PROP_SIZE, REFERENCE_HEIGHT, REFERENCE_WIDTH, }; @@ -117,10 +118,7 @@ pub fn RealmSceneViewer( } }); - let enlarge_props = Signal::derive(move || { - let s = settings.get(); - !s.panning_enabled && s.enlarge_props - }); + let enlarge_props = Signal::derive(move || settings.get().enlarge_props); let bg_color = scene .background_color @@ -132,10 +130,10 @@ pub fn RealmSceneViewer( #[allow(unused_variables)] let image_path = scene.background_image_path.clone().unwrap_or_default(); - // Three separate canvas refs for layered rendering + // Canvas refs for background and props layers + // Avatar layer now uses individual canvas elements per user let bg_canvas_ref = NodeRef::::new(); let props_canvas_ref = NodeRef::::new(); - let avatar_canvas_ref = NodeRef::::new(); // Outer container ref for middle-mouse drag scrolling let outer_container_ref = NodeRef::::new(); @@ -149,21 +147,20 @@ pub fn RealmSceneViewer( // Signal to track when scale factors have been properly calculated let (scales_ready, set_scales_ready) = signal(false); - // Handle canvas click for movement or prop pickup (on avatar canvas - topmost layer) + // Handle overlay click for movement or prop pickup + // TODO: Add hit-testing for avatar clicks #[cfg(feature = "hydrate")] - let on_canvas_click = { + let on_overlay_click = { let on_move = on_move.clone(); let on_prop_click = on_prop_click.clone(); move |ev: web_sys::MouseEvent| { - let Some(canvas) = avatar_canvas_ref.get() else { - return; - }; + // Get click position relative to the target element + let target = ev.current_target().unwrap(); + let element: web_sys::HtmlElement = target.dyn_into().unwrap(); + let rect = element.get_bounding_client_rect(); - let canvas_el: &web_sys::HtmlCanvasElement = &canvas; - let rect = canvas_el.get_bounding_client_rect(); - - let canvas_x = ev.client_x() as f64 - rect.left(); - let canvas_y = ev.client_y() as f64 - rect.top(); + let click_x = ev.client_x() as f64 - rect.left(); + let click_y = ev.client_y() as f64 - rect.top(); let sx = scale_x.get_value(); let sy = scale_y.get_value(); @@ -171,8 +168,8 @@ pub fn RealmSceneViewer( let oy = offset_y.get_value(); if sx > 0.0 && sy > 0.0 { - let scene_x = (canvas_x - ox) / sx; - let scene_y = (canvas_y - oy) / sy; + let scene_x = (click_x - ox) / sx; + let scene_y = (click_y - oy) / sy; let scene_x = scene_x.max(0.0).min(scene_width as f64); let scene_y = scene_y.max(0.0).min(scene_height as f64); @@ -408,85 +405,6 @@ pub fn RealmSceneViewer( draw_bg.forget(); }); - // ========================================================= - // Avatar Effect - runs when members, bubbles, or settings change - // ========================================================= - Effect::new(move |_| { - // Track signals - this Effect reruns when any changes - let current_members = members.get(); - let current_bubbles = active_bubbles.get(); - let current_pan_mode = is_pan_mode.get(); - let current_zoom = zoom_level.get(); - let current_enlarge = enlarge_props.get(); - - // Skip drawing if scale factors haven't been calculated yet - if !scales_ready.get() { - return; - } - - let Some(canvas) = avatar_canvas_ref.get() else { - return; - }; - - let canvas_el: &web_sys::HtmlCanvasElement = &canvas; - let canvas_el = canvas_el.clone(); - - let draw_avatars_closure = Closure::once(Box::new(move || { - let canvas_width = canvas_el.width(); - let canvas_height = canvas_el.height(); - - if canvas_width == 0 || canvas_height == 0 { - return; - } - - if let Ok(Some(ctx)) = canvas_el.get_context("2d") { - let ctx: web_sys::CanvasRenderingContext2d = - ctx.dyn_into::().unwrap(); - - // Clear with transparency (not fill - keeps canvas transparent) - ctx.clear_rect(0.0, 0.0, canvas_width as f64, canvas_height as f64); - - // Get stored scale factors - let sx = scale_x.get_value(); - let sy = scale_y.get_value(); - let ox = offset_x.get_value(); - let oy = offset_y.get_value(); - - // Calculate prop size based on mode - let prop_size = calculate_prop_size( - current_pan_mode, - current_zoom, - current_enlarge, - sx, - sy, - scene_width_f, - scene_height_f, - ); - - // Draw avatars first - draw_avatars(&ctx, ¤t_members, sx, sy, ox, oy, prop_size); - - // Draw speech bubbles on top - let current_time = js_sys::Date::now() as i64; - draw_speech_bubbles( - &ctx, - ¤t_members, - ¤t_bubbles, - sx, - sy, - ox, - oy, - current_time, - prop_size, - ); - } - }) as Box); - - let window = web_sys::window().unwrap(); - let _ = window.request_animation_frame(draw_avatars_closure.as_ref().unchecked_ref()); - draw_avatars_closure.forget(); - }); - // ========================================================= // Props Effect - runs when loose_props or settings change // ========================================================= @@ -575,14 +493,7 @@ pub fn RealmSceneViewer( canvas_el.set_height(canvas_height); } } - - if let Some(canvas) = avatar_canvas_ref.get() { - let canvas_el: &web_sys::HtmlCanvasElement = &canvas; - if canvas_el.width() != canvas_width || canvas_el.height() != canvas_height { - canvas_el.set_width(canvas_width); - canvas_el.set_height(canvas_height); - } - } + // Note: Avatar canvases are now individual elements that manage their own sizes } else { // Fit mode: sync props and avatar canvases to background canvas size if let Some(bg_canvas) = bg_canvas_ref.get() { @@ -598,14 +509,7 @@ pub fn RealmSceneViewer( canvas_el.set_height(canvas_height); } } - - if let Some(canvas) = avatar_canvas_ref.get() { - let canvas_el: &web_sys::HtmlCanvasElement = &canvas; - if canvas_el.width() != canvas_width || canvas_el.height() != canvas_height { - canvas_el.set_width(canvas_width); - canvas_el.set_height(canvas_height); - } - } + // Note: Avatar canvases are now individual elements that manage their own sizes } } } @@ -831,6 +735,40 @@ pub fn RealmSceneViewer( } }; + // Sorted members signal for z-ordering (most recently joined = highest z-index) + let sorted_members = Signal::derive(move || { + let mut m = members.get(); + // Sort by joined_at descending - most recent joins on top + m.sort_by(|a, b| b.member.joined_at.cmp(&a.member.joined_at)); + m + }); + + // Calculate prop size based on current settings + let prop_size = Signal::derive(move || { + let current_pan_mode = is_pan_mode.get(); + let current_zoom = zoom_level.get(); + let current_enlarge = enlarge_props.get(); + let sx = scale_x.get_value(); + let sy = scale_y.get_value(); + + // Reference scale factor for "enlarge props" mode + let ref_scale = (scene_width_f / REFERENCE_WIDTH).max(scene_height_f / REFERENCE_HEIGHT); + + if current_pan_mode { + if current_enlarge { + BASE_PROP_SIZE * ref_scale * current_zoom + } else { + BASE_PROP_SIZE * current_zoom + } + } else if current_enlarge { + BASE_PROP_SIZE * ref_scale * sx.min(sy) + } else { + BASE_PROP_SIZE * sx.min(sy) + } + }); + + let scene_name = scene.name.clone(); + view! {
+ // Click overlay - captures clicks for movement and hit-testing +
} @@ -877,7 +853,8 @@ use wasm_bindgen::JsCast; /// Calculate prop/avatar size based on current rendering mode. /// -/// - Pan mode: BASE_PROP_SIZE * zoom_level +/// - Pan mode without enlarge: BASE_PROP_SIZE * zoom_level +/// - Pan mode with enlarge: BASE_PROP_SIZE * reference_scale * zoom_level /// - Fit mode with enlarge: Reference scaling based on 1920x1080 /// - Fit mode without enlarge: BASE_PROP_SIZE * min(scale_x, scale_y) #[cfg(feature = "hydrate")] @@ -890,12 +867,17 @@ fn calculate_prop_size( scene_width: f64, scene_height: f64, ) -> f64 { + // Reference scale factor for "enlarge props" mode + let ref_scale = (scene_width / REFERENCE_WIDTH).max(scene_height / REFERENCE_HEIGHT); + if pan_mode { - // Pan mode: base size * zoom - BASE_PROP_SIZE * zoom_level + if enlarge_props { + BASE_PROP_SIZE * ref_scale * zoom_level + } else { + BASE_PROP_SIZE * zoom_level + } } else if enlarge_props { // Reference scaling: scale props relative to 1920x1080 reference - let ref_scale = (scene_width / REFERENCE_WIDTH).max(scene_height / REFERENCE_HEIGHT); BASE_PROP_SIZE * ref_scale * scale_x.min(scale_y) } else { // Default: base size scaled to viewport @@ -913,266 +895,6 @@ fn normalize_asset_path(path: &str) -> String { } } -#[cfg(feature = "hydrate")] -fn draw_avatars( - ctx: &web_sys::CanvasRenderingContext2d, - members: &[ChannelMemberWithAvatar], - scale_x: f64, - scale_y: f64, - offset_x: f64, - offset_y: f64, - prop_size: f64, -) { - let avatar_size = prop_size; - - for member in members { - let x = member.member.position_x * scale_x + offset_x; - let y = member.member.position_y * scale_y + offset_y; - - // Draw avatar placeholder circle - ctx.begin_path(); - let _ = ctx.arc(x, y - avatar_size / 2.0, avatar_size / 2.0, 0.0, std::f64::consts::PI * 2.0); - ctx.set_fill_style_str("#6366f1"); - ctx.fill(); - - // Draw skin layer sprite if available - if let Some(ref skin_path) = member.avatar.skin_layer[4] { - let img = web_sys::HtmlImageElement::new().unwrap(); - let img_clone = img.clone(); - let ctx_clone = ctx.clone(); - let draw_x = x; - let draw_y = y - avatar_size; - let size = avatar_size; - - let onload = wasm_bindgen::closure::Closure::once(Box::new(move || { - let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh( - &img_clone, draw_x - size / 2.0, draw_y, size, size, - ); - }) as Box); - - img.set_onload(Some(onload.as_ref().unchecked_ref())); - onload.forget(); - img.set_src(&normalize_asset_path(skin_path)); - } - - // Draw emotion overlay if available - if let Some(ref emotion_path) = member.avatar.emotion_layer[4] { - let img = web_sys::HtmlImageElement::new().unwrap(); - let img_clone = img.clone(); - let ctx_clone = ctx.clone(); - let draw_x = x; - let draw_y = y - avatar_size; - let size = avatar_size; - - let onload = wasm_bindgen::closure::Closure::once(Box::new(move || { - let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh( - &img_clone, draw_x - size / 2.0, draw_y, size, size, - ); - }) as Box); - - img.set_onload(Some(onload.as_ref().unchecked_ref())); - onload.forget(); - img.set_src(&normalize_asset_path(emotion_path)); - } - - // Scale factor for text/badges relative to avatar size - let text_scale = avatar_size / BASE_PROP_SIZE; - - // Draw emotion indicator on avatar - let emotion = member.member.current_emotion; - if emotion > 0 { - // Draw emotion number in a small badge - let badge_size = 16.0 * text_scale; - let badge_x = x + avatar_size / 2.0 - badge_size / 2.0; - let badge_y = y - avatar_size - badge_size / 2.0; - - // Badge background - 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"); // Amber color for emotion badge - ctx.fill(); - - // Emotion number - 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!("{}", emotion), badge_x, badge_y); - } - - // Draw display name - ctx.set_fill_style_str("#fff"); - ctx.set_font(&format!("{}px sans-serif", 12.0 * text_scale)); - ctx.set_text_align("center"); - ctx.set_text_baseline("alphabetic"); - let _ = ctx.fill_text(&member.member.display_name, x, y + 10.0 * text_scale); - } -} - -/// Draw speech bubbles above avatars. -#[cfg(feature = "hydrate")] -fn draw_speech_bubbles( - ctx: &web_sys::CanvasRenderingContext2d, - members: &[ChannelMemberWithAvatar], - bubbles: &HashMap<(Option, Option), ActiveBubble>, - scale_x: f64, - scale_y: f64, - offset_x: f64, - offset_y: f64, - current_time_ms: i64, - prop_size: f64, -) { - let avatar_size = prop_size; - let text_scale = avatar_size / BASE_PROP_SIZE; - 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; - - for member in members { - let key = (member.member.user_id, member.member.guest_session_id); - - if let Some(bubble) = bubbles.get(&key) { - // Skip expired bubbles - if bubble.expires_at < current_time_ms { - continue; - } - - // Use member's CURRENT position, not message position - let x = member.member.position_x * scale_x + offset_x; - let y = member.member.position_y * scale_y + offset_y; - - // Get emotion colors - let (bg_color, border_color, text_color) = - emotion_bubble_colors(&bubble.message.emotion); - - // Measure and wrap text - ctx.set_font(&format!("{}px sans-serif", 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: &String| -> f64 { - ctx.measure_text(line) - .map(|m: web_sys::TextMetrics| m.width()) - .unwrap_or(0.0) - }) - .fold(0.0_f64, |a: f64, b: f64| a.max(b)) - + padding * 2.0; - let bubble_width = bubble_width.max(60.0 * text_scale); // Minimum width - let bubble_height = (lines.len() as f64) * line_height + padding * 2.0; - - // Position bubble above avatar - let bubble_x = x - bubble_width / 2.0; - let bubble_y = y - avatar_size - bubble_height - tail_size - 5.0 * text_scale; - - // Draw bubble background with rounded corners - 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 (triangle pointing down) - ctx.begin_path(); - ctx.move_to(x - tail_size, bubble_y + bubble_height); - ctx.line_to(x, bubble_y + bubble_height + tail_size); - ctx.line_to(x + tail_size, bubble_y + bubble_height); - 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_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: web_sys::TextMetrics| 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 max - if lines.len() > 4 { - lines.truncate(3); - if let Some(last) = lines.last_mut() { - last.push_str("..."); - } - } - - // Handle empty text - if lines.is_empty() { - lines.push(text.to_string()); - } - - lines -} - -/// Draw a rounded rectangle path. -#[cfg(feature = "hydrate")] -fn draw_rounded_rect( - ctx: &web_sys::CanvasRenderingContext2d, - x: f64, - y: f64, - width: f64, - height: f64, - radius: f64, -) { - ctx.begin_path(); - ctx.move_to(x + radius, y); - ctx.line_to(x + width - radius, y); - ctx.arc_to(x + width, y, x + width, y + radius, radius).ok(); - ctx.line_to(x + width, y + height - radius); - ctx.arc_to(x + width, y + height, x + width - radius, y + height, radius).ok(); - ctx.line_to(x + radius, y + height); - ctx.arc_to(x, y + height, x, y + height - radius, radius).ok(); - ctx.line_to(x, y + radius); - ctx.arc_to(x, y, x + radius, y, radius).ok(); - ctx.close_path(); -} - /// Draw loose props on the props canvas layer. #[cfg(feature = "hydrate")] fn draw_loose_props( diff --git a/crates/chattyness-user-ui/src/components/settings.rs b/crates/chattyness-user-ui/src/components/settings.rs index 8e97777..991ccc2 100644 --- a/crates/chattyness-user-ui/src/components/settings.rs +++ b/crates/chattyness-user-ui/src/components/settings.rs @@ -63,7 +63,7 @@ pub struct ViewerSettings { pub zoom_level: f64, /// When true, props use reference scaling based on 1920x1080. - /// Only applicable when `panning_enabled` is false. + /// Applies in both fit mode and pan mode. pub enlarge_props: bool, /// Saved horizontal scroll position for pan mode. @@ -78,7 +78,7 @@ impl Default for ViewerSettings { Self { panning_enabled: false, zoom_level: 1.0, - enlarge_props: false, + enlarge_props: true, scroll_x: 0.0, scroll_y: 0.0, } @@ -132,18 +132,23 @@ impl ViewerSettings { /// Calculate the effective prop size based on current settings. /// - /// In pan mode, returns base size * zoom level. + /// In pan mode without enlarge, returns base size * zoom level. + /// In pan mode with enlarge, returns base size * reference_scale * zoom level. /// In fit mode with enlarge_props, returns size adjusted for reference resolution. /// Otherwise returns base size (caller should multiply by canvas scale). pub fn calculate_prop_size(&self, scene_width: f64, scene_height: f64) -> f64 { + // Reference scale factor for "enlarge props" mode + let ref_scale = (scene_width / REFERENCE_WIDTH).max(scene_height / REFERENCE_HEIGHT); + if self.panning_enabled { - // Pan mode: base size * zoom - BASE_PROP_SIZE * self.zoom_level + if self.enlarge_props { + BASE_PROP_SIZE * ref_scale * self.zoom_level + } else { + BASE_PROP_SIZE * self.zoom_level + } } else if self.enlarge_props { // Reference scaling: ensure minimum size based on 1920x1080 - let scale_w = scene_width / REFERENCE_WIDTH; - let scale_h = scene_height / REFERENCE_HEIGHT; - BASE_PROP_SIZE * scale_w.max(scale_h) + BASE_PROP_SIZE * ref_scale } else { // Default: base size (will be scaled by canvas scale factor) BASE_PROP_SIZE diff --git a/crates/chattyness-user-ui/src/components/settings_popup.rs b/crates/chattyness-user-ui/src/components/settings_popup.rs index 8b969a6..9a84ff5 100644 --- a/crates/chattyness-user-ui/src/components/settings_popup.rs +++ b/crates/chattyness-user-ui/src/components/settings_popup.rs @@ -164,7 +164,7 @@ pub fn SettingsPopup( // Zoom controls (only when panning enabled) -
+
@@ -173,7 +173,7 @@ pub fn SettingsPopup( type="button" class="px-3 py-1 bg-gray-700 hover:bg-gray-600 text-white rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed" on:click=on_zoom_decrease - disabled=move || zoom.get() <= effective_min_zoom.get() + disabled={move || zoom.get() <= effective_min_zoom.get()} aria-label="Zoom out" > "-" @@ -192,7 +192,7 @@ pub fn SettingsPopup( type="button" class="px-3 py-1 bg-gray-700 hover:bg-gray-600 text-white rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed" on:click=on_zoom_increase - disabled=move || zoom.get() >= ZOOM_MAX + disabled={move || zoom.get() >= ZOOM_MAX} aria-label="Zoom in" > "+" @@ -201,15 +201,13 @@ pub fn SettingsPopup(
- // Enlarge props toggle (only when panning disabled) - - - + // Enlarge props toggle (always visible) +
// Keyboard shortcuts help