feat: make canvas refresh more efficient
This commit is contained in:
parent
b430c80000
commit
8447fdef5d
5 changed files with 507 additions and 408 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
//! Reusable UI components.
|
//! Reusable UI components.
|
||||||
|
|
||||||
|
pub mod avatar_canvas;
|
||||||
pub mod chat;
|
pub mod chat;
|
||||||
pub mod chat_types;
|
pub mod chat_types;
|
||||||
pub mod editor;
|
pub mod editor;
|
||||||
|
|
@ -12,6 +13,7 @@ pub mod settings;
|
||||||
pub mod settings_popup;
|
pub mod settings_popup;
|
||||||
pub mod ws_client;
|
pub mod ws_client;
|
||||||
|
|
||||||
|
pub use avatar_canvas::*;
|
||||||
pub use chat::*;
|
pub use chat::*;
|
||||||
pub use chat_types::*;
|
pub use chat_types::*;
|
||||||
pub use editor::*;
|
pub use editor::*;
|
||||||
|
|
|
||||||
372
crates/chattyness-user-ui/src/components/avatar_canvas.rs
Normal file
372
crates/chattyness-user-ui/src/components/avatar_canvas.rs
Normal file
|
|
@ -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<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.
|
||||||
|
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<ActiveBubble>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let canvas_ref = NodeRef::<leptos::html::Canvas>::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<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);
|
||||||
|
|
||||||
|
// 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<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 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! {
|
||||||
|
<canvas
|
||||||
|
node_ref=canvas_ref
|
||||||
|
style=style
|
||||||
|
data-member-id=member.member.user_id.map(|u| u.to_string()).or_else(|| member.member.guest_session_id.map(|g| g.to_string())).unwrap_or_default()
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,8 @@ use uuid::Uuid;
|
||||||
|
|
||||||
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene};
|
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::{
|
use super::settings::{
|
||||||
calculate_min_zoom, ViewerSettings, BASE_PROP_SIZE, REFERENCE_HEIGHT, REFERENCE_WIDTH,
|
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 enlarge_props = Signal::derive(move || settings.get().enlarge_props);
|
||||||
let s = settings.get();
|
|
||||||
!s.panning_enabled && s.enlarge_props
|
|
||||||
});
|
|
||||||
|
|
||||||
let bg_color = scene
|
let bg_color = scene
|
||||||
.background_color
|
.background_color
|
||||||
|
|
@ -132,10 +130,10 @@ pub fn RealmSceneViewer(
|
||||||
#[allow(unused_variables)]
|
#[allow(unused_variables)]
|
||||||
let image_path = scene.background_image_path.clone().unwrap_or_default();
|
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::<leptos::html::Canvas>::new();
|
let bg_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
||||||
let props_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
let props_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
||||||
let avatar_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
|
||||||
|
|
||||||
// Outer container ref for middle-mouse drag scrolling
|
// Outer container ref for middle-mouse drag scrolling
|
||||||
let outer_container_ref = NodeRef::<leptos::html::Div>::new();
|
let outer_container_ref = NodeRef::<leptos::html::Div>::new();
|
||||||
|
|
@ -149,21 +147,20 @@ pub fn RealmSceneViewer(
|
||||||
// Signal to track when scale factors have been properly calculated
|
// Signal to track when scale factors have been properly calculated
|
||||||
let (scales_ready, set_scales_ready) = signal(false);
|
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")]
|
#[cfg(feature = "hydrate")]
|
||||||
let on_canvas_click = {
|
let on_overlay_click = {
|
||||||
let on_move = on_move.clone();
|
let on_move = on_move.clone();
|
||||||
let on_prop_click = on_prop_click.clone();
|
let on_prop_click = on_prop_click.clone();
|
||||||
move |ev: web_sys::MouseEvent| {
|
move |ev: web_sys::MouseEvent| {
|
||||||
let Some(canvas) = avatar_canvas_ref.get() else {
|
// Get click position relative to the target element
|
||||||
return;
|
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 click_x = ev.client_x() as f64 - rect.left();
|
||||||
let rect = canvas_el.get_bounding_client_rect();
|
let click_y = ev.client_y() as f64 - rect.top();
|
||||||
|
|
||||||
let canvas_x = ev.client_x() as f64 - rect.left();
|
|
||||||
let canvas_y = ev.client_y() as f64 - rect.top();
|
|
||||||
|
|
||||||
let sx = scale_x.get_value();
|
let sx = scale_x.get_value();
|
||||||
let sy = scale_y.get_value();
|
let sy = scale_y.get_value();
|
||||||
|
|
@ -171,8 +168,8 @@ pub fn RealmSceneViewer(
|
||||||
let oy = offset_y.get_value();
|
let oy = offset_y.get_value();
|
||||||
|
|
||||||
if sx > 0.0 && sy > 0.0 {
|
if sx > 0.0 && sy > 0.0 {
|
||||||
let scene_x = (canvas_x - ox) / sx;
|
let scene_x = (click_x - ox) / sx;
|
||||||
let scene_y = (canvas_y - oy) / sy;
|
let scene_y = (click_y - oy) / sy;
|
||||||
|
|
||||||
let scene_x = scene_x.max(0.0).min(scene_width as f64);
|
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);
|
let scene_y = scene_y.max(0.0).min(scene_height as f64);
|
||||||
|
|
@ -408,85 +405,6 @@ pub fn RealmSceneViewer(
|
||||||
draw_bg.forget();
|
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::<web_sys::CanvasRenderingContext2d>().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<dyn FnOnce()>);
|
|
||||||
|
|
||||||
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
|
// Props Effect - runs when loose_props or settings change
|
||||||
// =========================================================
|
// =========================================================
|
||||||
|
|
@ -575,14 +493,7 @@ pub fn RealmSceneViewer(
|
||||||
canvas_el.set_height(canvas_height);
|
canvas_el.set_height(canvas_height);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Note: Avatar canvases are now individual elements that manage their own sizes
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Fit mode: sync props and avatar canvases to background canvas size
|
// Fit mode: sync props and avatar canvases to background canvas size
|
||||||
if let Some(bg_canvas) = bg_canvas_ref.get() {
|
if let Some(bg_canvas) = bg_canvas_ref.get() {
|
||||||
|
|
@ -598,14 +509,7 @@ pub fn RealmSceneViewer(
|
||||||
canvas_el.set_height(canvas_height);
|
canvas_el.set_height(canvas_height);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Note: Avatar canvases are now individual elements that manage their own sizes
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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! {
|
view! {
|
||||||
<div node_ref=outer_container_ref class=outer_container_class style=outer_container_style on:wheel=handle_wheel>
|
<div node_ref=outer_container_ref class=outer_container_class style=outer_container_style on:wheel=handle_wheel>
|
||||||
<div
|
<div
|
||||||
|
|
@ -851,22 +789,60 @@ pub fn RealmSceneViewer(
|
||||||
style=move || canvas_style(1)
|
style=move || canvas_style(1)
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
// Avatar layer - dynamic, transparent background
|
// Avatars container - individual canvases per user
|
||||||
<canvas
|
<div
|
||||||
node_ref=avatar_canvas_ref
|
class="avatars-container absolute inset-0"
|
||||||
class=canvas_class
|
style="z-index: 2; pointer-events: none;"
|
||||||
style=move || canvas_style(2)
|
>
|
||||||
aria-label=format!("Scene: {}", scene.name)
|
{move || {
|
||||||
|
// Wait for scale factors to be calculated before rendering avatars
|
||||||
|
if !scales_ready.get() {
|
||||||
|
return Vec::new().into_iter().collect_view();
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_bubbles = active_bubbles.get();
|
||||||
|
let sx = scale_x.get_value();
|
||||||
|
let sy = scale_y.get_value();
|
||||||
|
let ox = offset_x.get_value();
|
||||||
|
let oy = offset_y.get_value();
|
||||||
|
let ps = prop_size.get();
|
||||||
|
|
||||||
|
sorted_members.get()
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, member)| {
|
||||||
|
let key = member_key(&member);
|
||||||
|
let bubble = current_bubbles.get(&key).cloned();
|
||||||
|
let z = (idx as i32) + 10;
|
||||||
|
view! {
|
||||||
|
<AvatarCanvas
|
||||||
|
member=member
|
||||||
|
scale_x=sx
|
||||||
|
scale_y=sy
|
||||||
|
offset_x=ox
|
||||||
|
offset_y=oy
|
||||||
|
prop_size=ps
|
||||||
|
z_index=z
|
||||||
|
active_bubble=bubble
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect_view()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
// Click overlay - captures clicks for movement and hit-testing
|
||||||
|
<div
|
||||||
|
class="click-overlay absolute inset-0"
|
||||||
|
style="z-index: 3; cursor: pointer;"
|
||||||
|
aria-label=format!("Scene: {}", scene_name)
|
||||||
role="img"
|
role="img"
|
||||||
on:click=move |ev| {
|
on:click=move |ev| {
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
on_canvas_click(ev);
|
on_overlay_click(ev);
|
||||||
#[cfg(not(feature = "hydrate"))]
|
#[cfg(not(feature = "hydrate"))]
|
||||||
let _ = ev;
|
let _ = ev;
|
||||||
}
|
}
|
||||||
>
|
/>
|
||||||
{format!("Scene: {}", scene.name)}
|
|
||||||
</canvas>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
@ -877,7 +853,8 @@ use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
/// Calculate prop/avatar size based on current rendering mode.
|
/// 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 with enlarge: Reference scaling based on 1920x1080
|
||||||
/// - Fit mode without enlarge: BASE_PROP_SIZE * min(scale_x, scale_y)
|
/// - Fit mode without enlarge: BASE_PROP_SIZE * min(scale_x, scale_y)
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
|
|
@ -890,12 +867,17 @@ fn calculate_prop_size(
|
||||||
scene_width: f64,
|
scene_width: f64,
|
||||||
scene_height: f64,
|
scene_height: f64,
|
||||||
) -> f64 {
|
) -> f64 {
|
||||||
|
// Reference scale factor for "enlarge props" mode
|
||||||
|
let ref_scale = (scene_width / REFERENCE_WIDTH).max(scene_height / REFERENCE_HEIGHT);
|
||||||
|
|
||||||
if pan_mode {
|
if pan_mode {
|
||||||
// Pan mode: base size * zoom
|
if enlarge_props {
|
||||||
BASE_PROP_SIZE * zoom_level
|
BASE_PROP_SIZE * ref_scale * zoom_level
|
||||||
|
} else {
|
||||||
|
BASE_PROP_SIZE * zoom_level
|
||||||
|
}
|
||||||
} else if enlarge_props {
|
} else if enlarge_props {
|
||||||
// Reference scaling: scale props relative to 1920x1080 reference
|
// 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)
|
BASE_PROP_SIZE * ref_scale * scale_x.min(scale_y)
|
||||||
} else {
|
} else {
|
||||||
// Default: base size scaled to viewport
|
// 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<dyn FnOnce()>);
|
|
||||||
|
|
||||||
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<dyn FnOnce()>);
|
|
||||||
|
|
||||||
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<Uuid>, Option<Uuid>), 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<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: 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.
|
/// Draw loose props on the props canvas layer.
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
fn draw_loose_props(
|
fn draw_loose_props(
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ pub struct ViewerSettings {
|
||||||
pub zoom_level: f64,
|
pub zoom_level: f64,
|
||||||
|
|
||||||
/// When true, props use reference scaling based on 1920x1080.
|
/// 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,
|
pub enlarge_props: bool,
|
||||||
|
|
||||||
/// Saved horizontal scroll position for pan mode.
|
/// Saved horizontal scroll position for pan mode.
|
||||||
|
|
@ -78,7 +78,7 @@ impl Default for ViewerSettings {
|
||||||
Self {
|
Self {
|
||||||
panning_enabled: false,
|
panning_enabled: false,
|
||||||
zoom_level: 1.0,
|
zoom_level: 1.0,
|
||||||
enlarge_props: false,
|
enlarge_props: true,
|
||||||
scroll_x: 0.0,
|
scroll_x: 0.0,
|
||||||
scroll_y: 0.0,
|
scroll_y: 0.0,
|
||||||
}
|
}
|
||||||
|
|
@ -132,18 +132,23 @@ impl ViewerSettings {
|
||||||
|
|
||||||
/// Calculate the effective prop size based on current settings.
|
/// 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.
|
/// In fit mode with enlarge_props, returns size adjusted for reference resolution.
|
||||||
/// Otherwise returns base size (caller should multiply by canvas scale).
|
/// Otherwise returns base size (caller should multiply by canvas scale).
|
||||||
pub fn calculate_prop_size(&self, scene_width: f64, scene_height: f64) -> f64 {
|
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 {
|
if self.panning_enabled {
|
||||||
// Pan mode: base size * zoom
|
if self.enlarge_props {
|
||||||
BASE_PROP_SIZE * self.zoom_level
|
BASE_PROP_SIZE * ref_scale * self.zoom_level
|
||||||
|
} else {
|
||||||
|
BASE_PROP_SIZE * self.zoom_level
|
||||||
|
}
|
||||||
} else if self.enlarge_props {
|
} else if self.enlarge_props {
|
||||||
// Reference scaling: ensure minimum size based on 1920x1080
|
// Reference scaling: ensure minimum size based on 1920x1080
|
||||||
let scale_w = scene_width / REFERENCE_WIDTH;
|
BASE_PROP_SIZE * ref_scale
|
||||||
let scale_h = scene_height / REFERENCE_HEIGHT;
|
|
||||||
BASE_PROP_SIZE * scale_w.max(scale_h)
|
|
||||||
} else {
|
} else {
|
||||||
// Default: base size (will be scaled by canvas scale factor)
|
// Default: base size (will be scaled by canvas scale factor)
|
||||||
BASE_PROP_SIZE
|
BASE_PROP_SIZE
|
||||||
|
|
|
||||||
|
|
@ -164,7 +164,7 @@ pub fn SettingsPopup(
|
||||||
|
|
||||||
// Zoom controls (only when panning enabled)
|
// Zoom controls (only when panning enabled)
|
||||||
<Show when=move || panning.get()>
|
<Show when=move || panning.get()>
|
||||||
<div class="pl-4 border-l-2 border-gray-600 space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="block text-white font-medium">
|
<label class="block text-white font-medium">
|
||||||
"Zoom: " {move || format!("{}%", (zoom.get() * 100.0) as i32)}
|
"Zoom: " {move || format!("{}%", (zoom.get() * 100.0) as i32)}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -173,7 +173,7 @@ pub fn SettingsPopup(
|
||||||
type="button"
|
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"
|
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
|
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"
|
aria-label="Zoom out"
|
||||||
>
|
>
|
||||||
"-"
|
"-"
|
||||||
|
|
@ -192,7 +192,7 @@ pub fn SettingsPopup(
|
||||||
type="button"
|
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"
|
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
|
on:click=on_zoom_increase
|
||||||
disabled=move || zoom.get() >= ZOOM_MAX
|
disabled={move || zoom.get() >= ZOOM_MAX}
|
||||||
aria-label="Zoom in"
|
aria-label="Zoom in"
|
||||||
>
|
>
|
||||||
"+"
|
"+"
|
||||||
|
|
@ -201,15 +201,13 @@ pub fn SettingsPopup(
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
// Enlarge props toggle (only when panning disabled)
|
// Enlarge props toggle (always visible)
|
||||||
<Show when=move || !panning.get()>
|
<SettingsToggle
|
||||||
<SettingsToggle
|
label="Enlarge Props"
|
||||||
label="Enlarge Props"
|
description="Scale props relative to 1920x1080 for consistent size"
|
||||||
description="Scale props relative to 1920x1080 for consistent size"
|
checked=enlarge
|
||||||
checked=enlarge
|
on_change=on_enlarge_toggle
|
||||||
on_change=on_enlarge_toggle
|
/>
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Keyboard shortcuts help
|
// Keyboard shortcuts help
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue