remove old AvatarBoundsStore

This commit is contained in:
Evan Carroll 2026-01-26 22:34:06 -06:00
parent 912ceb7646
commit c434321376
5 changed files with 447 additions and 567 deletions

View file

@ -1,6 +1,6 @@
//! Reusable UI components.
pub mod avatar_canvas;
pub mod avatar;
pub mod avatar_editor;
pub mod canvas_utils;
pub mod avatar_store;
@ -26,13 +26,11 @@ pub mod scene_list_popup;
pub mod scene_viewer;
pub mod settings;
pub mod settings_popup;
pub mod speech_bubble;
pub mod tabs;
pub mod username_label;
pub mod reconnection_overlay;
pub mod ws_client;
pub use avatar_canvas::*;
pub use avatar::*;
pub use avatar_editor::*;
pub use avatar_store::*;
pub use canvas_utils::*;
@ -59,7 +57,5 @@ pub use scene_list_popup::*;
pub use scene_viewer::*;
pub use settings::*;
pub use settings_popup::*;
pub use speech_bubble::*;
pub use tabs::*;
pub use username_label::*;
pub use ws_client::*;

View file

@ -1,49 +1,79 @@
//! Individual avatar canvas component for per-user rendering.
//! Individual avatar component for per-user rendering.
//!
//! Each avatar gets its own wrapper div containing:
//! - A canvas element for the avatar sprite (positioned via CSS transforms)
//! - A username label (HTML div below the avatar)
//! - A speech bubble (HTML div, conditionally rendered when speaking)
//!
//! 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 std::collections::HashMap;
use leptos::prelude::*;
use uuid::Uuid;
use chattyness_db::models::{ChannelMemberWithAvatar, EmotionState};
use super::chat_types::{ActiveBubble, emotion_bubble_colors};
#[cfg(feature = "hydrate")]
pub use super::canvas_utils::hit_test_canvas;
#[cfg(feature = "hydrate")]
use super::canvas_utils::normalize_asset_path;
// =============================================================================
// Avatar Bounds - Exported for use by SpeechBubble and other components
// Screen Bounds - Exported for use by other components
// =============================================================================
/// Computed screen-space bounds for an avatar's visible content.
/// Screen boundaries for visual clamping in screen space.
///
/// This is computed by AvatarCanvas and exported via a shared store so that
/// other components (like SpeechBubble) can position themselves relative to
/// the avatar without duplicating the bounds calculation.
#[derive(Clone, Copy, Debug, Default)]
pub struct AvatarBounds {
/// X position of the content center in screen coordinates.
pub content_center_x: f64,
/// Y position of the content center in screen coordinates.
pub content_center_y: f64,
/// Half-width of the actual content area in pixels.
pub content_half_width: f64,
/// Half-height of the actual content area in pixels.
pub content_half_height: f64,
/// Top edge of the content in screen coordinates.
pub content_top_y: f64,
/// Bottom edge of the content in screen coordinates.
pub content_bottom_y: f64,
/// Used by Avatar for clamping avatar positions and bubble positions.
#[derive(Clone, Copy, Debug)]
pub struct ScreenBounds {
/// Left edge of drawable area (= offset_x)
pub min_x: f64,
/// Right edge (= offset_x + scene_width * scale_x)
pub max_x: f64,
/// Top edge (= offset_y)
pub min_y: f64,
/// Bottom edge (= offset_y + scene_height * scale_y)
pub max_y: f64,
}
/// Type alias for the shared avatar bounds store.
/// Maps user_id -> computed bounds.
pub type AvatarBoundsStore = RwSignal<HashMap<Uuid, AvatarBounds>>;
impl ScreenBounds {
/// Create screen bounds from scene transform parameters.
pub 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 a center point so content stays within screen boundaries.
pub fn clamp_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)
}
}
/// Base text size multiplier. Text at 100% slider = base_sizes * 1.4
const BASE_TEXT_SCALE: f64 = 1.4;
@ -161,58 +191,6 @@ impl ContentBounds {
}
}
/// Screen boundaries for visual clamping in screen space.
///
/// Used by both AvatarCanvas (for clamping avatar positions) and
/// SpeechBubble (for clamping bubble positions).
#[derive(Clone, Copy, Debug)]
pub struct ScreenBounds {
/// Left edge of drawable area (= offset_x)
pub min_x: f64,
/// Right edge (= offset_x + scene_width * scale_x)
pub max_x: f64,
/// Top edge (= offset_y)
pub min_y: f64,
/// Bottom edge (= offset_y + scene_height * scale_y)
pub max_y: f64,
}
impl ScreenBounds {
/// Create screen bounds from scene transform parameters.
pub 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 a center point so content stays within screen boundaries.
pub fn clamp_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)
}
}
/// Unified layout context for avatar canvas rendering.
///
/// This struct computes all derived layout values once from the inputs,
@ -220,8 +198,7 @@ impl ScreenBounds {
/// - Canvas dimensions and position
/// - Avatar positioning within the canvas
/// - Coordinate transformations between canvas-local and screen space
///
/// Note: Speech bubbles are rendered separately as HTML elements (see speech_bubble.rs).
/// - Username label and speech bubble positioning
#[derive(Clone, Copy)]
struct CanvasLayout {
// Core dimensions
@ -245,6 +222,16 @@ struct CanvasLayout {
// Avatar center within canvas (canvas-local coordinates)
avatar_cx: f64,
avatar_cy: f64,
// Content bounds info for positioning
empty_top_rows: usize,
empty_bottom_rows: usize,
// Scene bounds for bubble clamping (screen coordinates)
scene_min_x: f64,
scene_max_x: f64,
scene_min_y: f64,
scene_max_y: f64,
}
impl CanvasLayout {
@ -300,30 +287,15 @@ impl CanvasLayout {
canvas_screen_y,
avatar_cx,
avatar_cy,
empty_top_rows: content_bounds.empty_top_rows(),
empty_bottom_rows: content_bounds.empty_bottom_rows(),
scene_min_x: boundaries.min_x,
scene_max_x: boundaries.max_x,
scene_min_y: boundaries.min_y,
scene_max_y: boundaries.max_y,
}
}
/// 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
@ -346,17 +318,22 @@ pub fn member_key(m: &ChannelMemberWithAvatar) -> Uuid {
m.member.user_id
}
/// Individual avatar canvas component.
/// Position of speech bubble relative to avatar.
#[derive(Clone, Copy, PartialEq, Debug)]
enum BubblePosition {
Above,
Below,
}
/// Individual avatar 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)
///
/// Note: Speech bubbles are rendered separately as HTML elements for efficiency.
/// The avatar's computed bounds are written to `bounds_store` (if provided) so
/// that SpeechBubble and other components can position relative to the avatar.
/// - Username label as HTML div below the avatar
/// - Speech bubble as HTML div (conditionally rendered when speaking)
#[component]
pub fn AvatarCanvas(
pub fn Avatar(
/// The member data for this avatar (as a signal for reactive updates).
member: Signal<ChannelMemberWithAvatar>,
/// X scale factor for coordinate conversion.
@ -383,19 +360,27 @@ pub fn AvatarCanvas(
/// Scene height in scene coordinates (for boundary calculations).
#[prop(optional)]
scene_height: Option<Signal<f64>>,
/// Shared store for exporting computed avatar bounds.
/// If provided, this avatar will write its bounds to the store.
/// Active speech bubble for this avatar (None if not speaking).
#[prop(optional)]
bounds_store: Option<AvatarBoundsStore>,
active_bubble: Option<Signal<Option<ActiveBubble>>>,
) -> impl IntoView {
let canvas_ref = NodeRef::<leptos::html::Canvas>::new();
let bubble_ref = NodeRef::<leptos::html::Div>::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 || {
// Signal to store computed layout for use by label and bubble
let (layout_signal, set_layout_signal) = signal(None::<CanvasLayout>);
// Bubble measurement state - only SIZE is stored, position is calculated reactively
let (bubble_measured_width, set_bubble_measured_width) = signal(0.0_f64);
let (bubble_measured_height, set_bubble_measured_height) = signal(0.0_f64);
let (bubble_is_measured, set_bubble_is_measured) = signal(false);
let (bubble_position, set_bubble_position) = signal(BubblePosition::Above);
// Compute layout reactively
let compute_layout = move || {
let m = member.get();
let ps = prop_size.get();
let sx = scale_x.get();
@ -422,20 +407,74 @@ pub fn AvatarCanvas(
let avatar_screen_y = m.member.position_y * sy + oy;
// Create unified layout - all calculations happen in one place
let layout = CanvasLayout::new(
CanvasLayout::new(
&content_bounds,
ps,
te,
avatar_screen_x,
avatar_screen_y,
boundaries,
);
// Generate CSS style from layout
layout.css_style(z_index, pointer_events, opacity)
)
};
// Store references for the effect
// Wrapper style (positions the entire avatar container)
let wrapper_style = move || {
let layout = compute_layout();
format!(
"position: absolute; \
left: 0; top: 0; \
transform: translate({}px, {}px); \
z-index: {}; \
pointer-events: {}; \
width: {}px; \
height: {}px; \
opacity: {}; \
overflow: visible;",
layout.canvas_screen_x,
layout.canvas_screen_y,
z_index,
pointer_events,
layout.canvas_width,
layout.canvas_height,
opacity
)
};
// Canvas style (fills the wrapper)
let canvas_style = "width: 100%; height: 100%;";
// Label style (positioned relative to wrapper, below avatar)
let label_style = move || {
let layout = compute_layout();
let font_size = 12.0 * layout.text_scale;
// Position at content center X, below content bottom
let label_x = layout.content_center_x();
let label_y = layout.avatar_bottom_y()
- layout.empty_bottom_rows as f64 * layout.prop_size
+ 15.0 * layout.text_scale;
format!(
"position: absolute; \
left: {}px; \
top: {}px; \
transform: translateX(-50%); \
font-size: {}px; \
white-space: nowrap; \
z-index: 99998;",
label_x, label_y, font_size
)
};
// Get display name reactively
let display_name = move || member.get().member.display_name.clone();
// Compute data-member-id reactively
let data_member_id = move || {
let m = member.get();
m.member.user_id.to_string()
};
// Store references for the canvas drawing effect
#[cfg(feature = "hydrate")]
{
use std::cell::RefCell;
@ -496,23 +535,8 @@ pub fn AvatarCanvas(
boundaries,
);
// Write computed bounds to shared store (if provided)
// This allows SpeechBubble and other components to position relative to this avatar
if let Some(store) = bounds_store {
let bounds = AvatarBounds {
content_center_x: layout.canvas_screen_x + layout.content_center_x(),
content_center_y: layout.canvas_screen_y + layout.avatar_cy,
content_half_width: content_bounds.content_width(ps) / 2.0,
content_half_height: content_bounds.content_height(ps) / 2.0,
content_top_y: layout.canvas_screen_y + layout.avatar_top_y()
+ content_bounds.empty_top_rows() as f64 * ps,
content_bottom_y: layout.canvas_screen_y + layout.avatar_bottom_y()
- content_bounds.empty_bottom_rows() as f64 * ps,
};
store.update(|map| {
map.insert(m.member.user_id, bounds);
});
}
// Store layout for use by label and bubble
set_layout_signal.set(Some(layout));
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
@ -662,25 +686,287 @@ pub fn AvatarCanvas(
ctx.set_text_baseline("middle");
let _ = ctx.fill_text(&format!("{}", current_emotion), badge_x, badge_y);
}
});
// Note: Display name and speech bubbles are now rendered separately as HTML elements
// Track the message ID we've measured to detect new messages
let (last_measured_message_id, set_last_measured_message_id) =
signal(Option::<uuid::Uuid>::None);
// Effect to measure bubble SIZE only (runs once per message)
// Position is calculated reactively in bubble_style based on current layout
Effect::new(move |_| {
let Some(bubble_signal) = active_bubble else {
return;
};
let Some(bubble) = bubble_signal.get() else {
// Reset measurement state when bubble disappears
untrack(|| {
set_bubble_is_measured.set(false);
set_last_measured_message_id.set(None);
});
return;
};
// Check if this is a new message (different message_id)
let current_message_id = bubble.message.message_id;
let last_id = untrack(|| last_measured_message_id.get());
let is_new_message = last_id != Some(current_message_id);
// If already measured the SAME message, skip - prevents infinite loop
let is_measured = untrack(|| bubble_is_measured.get());
if is_measured && !is_new_message {
return;
}
// Reset measurement state for new message
if is_new_message {
untrack(|| {
set_bubble_is_measured.set(false);
set_last_measured_message_id.set(Some(current_message_id));
});
}
// Use request_animation_frame to measure after DOM updates
let bubble_ref_clone = bubble_ref.clone();
request_animation_frame(move || {
let Some(el) = bubble_ref_clone.get() else {
return;
};
let rect = el.get_bounding_client_rect();
let bubble_width = rect.width();
let bubble_height = rect.height();
if bubble_width <= 0.0 || bubble_height <= 0.0 {
return;
}
// Only store the measured SIZE - position is calculated reactively
untrack(|| {
set_bubble_measured_width.set(bubble_width);
set_bubble_measured_height.set(bubble_height);
set_bubble_is_measured.set(true);
});
});
});
}
// Compute data-member-id reactively
let data_member_id = move || {
let m = member.get();
m.member.user_id.to_string()
// Calculate bubble position reactively based on current layout
// This runs whenever the avatar moves, keeping the bubble following the avatar
let calc_bubble_position = move || -> Option<(f64, f64, f64, BubblePosition)> {
let layout = layout_signal.get()?;
let measured = bubble_is_measured.get();
if !measured {
return None;
}
let bubble_width = bubble_measured_width.get();
let bubble_height = bubble_measured_height.get();
if bubble_width <= 0.0 || bubble_height <= 0.0 {
return None;
}
let tail_size = 8.0 * layout.text_scale;
let gap = 5.0 * layout.text_scale;
// === AVATAR BOUNDS (in canvas/wrapper-local coordinates) ===
let avatar_center_x = layout.content_center_x();
let avatar_top =
layout.avatar_top_y() + layout.empty_top_rows as f64 * layout.prop_size;
let avatar_bottom =
layout.avatar_bottom_y() - layout.empty_bottom_rows as f64 * layout.prop_size;
// === CONVERT TO SCENE COORDINATES FOR CLAMPING ===
let avatar_screen_center_x = layout.canvas_screen_x + avatar_center_x;
let avatar_screen_top = layout.canvas_screen_y + avatar_top;
let avatar_screen_bottom = layout.canvas_screen_y + avatar_bottom;
// === HORIZONTAL POSITIONING (in scene coordinates) ===
let ideal_bubble_screen_left = avatar_screen_center_x - bubble_width / 2.0;
let clamped_bubble_screen_left = ideal_bubble_screen_left
.max(layout.scene_min_x)
.min(layout.scene_max_x - bubble_width);
// === VERTICAL POSITIONING ===
let space_above = avatar_screen_top - layout.scene_min_y;
let space_needed = bubble_height + tail_size + gap;
let position = if space_above >= space_needed {
BubblePosition::Above
} else {
BubblePosition::Below
};
let ideal_bubble_screen_top = match position {
BubblePosition::Above => avatar_screen_top - gap - tail_size - bubble_height,
BubblePosition::Below => avatar_screen_bottom + gap + tail_size,
};
let clamped_bubble_screen_top = ideal_bubble_screen_top
.max(layout.scene_min_y)
.min(layout.scene_max_y - bubble_height);
// === CONVERT BACK TO WRAPPER-RELATIVE COORDINATES ===
let bubble_left = clamped_bubble_screen_left - layout.canvas_screen_x;
let bubble_top = clamped_bubble_screen_top - layout.canvas_screen_y;
// === CALCULATE TAIL OFFSET ===
let tail_offset = if bubble_width > 0.0 {
let avatar_rel_to_bubble = avatar_center_x - bubble_left;
(avatar_rel_to_bubble / bubble_width * 100.0).clamp(10.0, 90.0)
} else {
50.0
};
Some((bubble_left, bubble_top, tail_offset, position))
};
// Bubble visibility check
#[cfg(feature = "hydrate")]
let is_bubble_visible = move || {
let Some(bubble_signal) = active_bubble else {
return false;
};
let Some(bubble) = bubble_signal.get() else {
return false;
};
let now = js_sys::Date::now() as i64;
now < bubble.expires_at
};
#[cfg(not(feature = "hydrate"))]
let is_bubble_visible = move || {
active_bubble.map(|s| s.get().is_some()).unwrap_or(false)
};
// Bubble style
let bubble_style = move || {
let Some(bubble_signal) = active_bubble else {
return "display: none;".to_string();
};
let Some(bubble) = bubble_signal.get() else {
return "display: none;".to_string();
};
let Some(layout) = layout_signal.get() else {
return "display: none;".to_string();
};
let (bg_color, border_color, text_color) =
emotion_bubble_colors(&bubble.message.emotion);
let max_width = 200.0 * layout.text_scale;
let padding = 8.0 * layout.text_scale;
let tail_size = 8.0 * layout.text_scale;
let font_size = 12.0 * layout.text_scale;
let border_width = 2.0; // CSS border width
let measured = bubble_is_measured.get();
let m_height = bubble_measured_height.get();
// Detect multi-line: if measured height exceeds what a single line would be
// Single line height = font-size * line-height + padding * 2 + border * 2
let single_line_height = font_size * 1.5 + padding * 2.0 + border_width * 2.0;
let is_multiline = measured && m_height > single_line_height * 1.3;
// Width strategy: fit-content for single line, explicit max-width for multi-line
// This ensures long text gets full width while short text shrinks to fit
let width_style = if is_multiline {
format!("width: {}px;", max_width)
} else {
format!("width: fit-content; max-width: {}px;", max_width)
};
// Get reactive position (recalculates when avatar moves)
let (position_type, final_left, final_top, tail_offset, position) =
if let Some((left, top, tail, pos)) = calc_bubble_position() {
("absolute", left, top, tail, pos)
} else {
// Not yet measured - use fixed positioning off-screen
("fixed", -1000.0, -1000.0, 50.0, BubblePosition::Above)
};
// Start invisible until measured (prevents flash)
let bubble_opacity = if measured { 1.0 } else { 0.0 };
// Store position for bubble_class to use
set_bubble_position.set(position);
format!(
"position: {}; \
left: {}px; \
top: {}px; \
{} \
opacity: {}; \
--bubble-bg: {}; \
--bubble-border: {}; \
--bubble-text: {}; \
--tail-offset: {}%; \
--font-size: {}px; \
--padding: {}px; \
--tail-size: {}px; \
--border-radius: {}px; \
z-index: 99999; \
pointer-events: none;",
position_type,
final_left,
final_top,
width_style,
bubble_opacity,
bg_color,
border_color,
text_color,
tail_offset,
font_size,
padding,
tail_size,
8.0 * layout.text_scale,
)
};
let bubble_class = move || {
let position = bubble_position.get();
match position {
BubblePosition::Above => "speech-bubble tail-below",
BubblePosition::Below => "speech-bubble tail-above",
}
};
let bubble_content = move || {
active_bubble
.and_then(|s| s.get())
.map(|b| b.message.content.clone())
.unwrap_or_default()
};
let bubble_is_whisper = move || {
active_bubble
.and_then(|s| s.get())
.map(|b| b.message.is_whisper)
.unwrap_or(false)
};
view! {
<canvas
node_ref=canvas_ref
style=style
data-member-id=data_member_id
/>
<div class="avatar-wrapper" style=wrapper_style data-member-id=data_member_id>
<canvas
node_ref=canvas_ref
style=canvas_style
/>
<div class="username-label" style=label_style>
{display_name}
</div>
<Show when=is_bubble_visible fallback=|| ()>
<div
node_ref=bubble_ref
class=bubble_class
style=bubble_style
>
<div
class="bubble-content"
class:whisper=bubble_is_whisper
>
{bubble_content}
</div>
</div>
</Show>
</div>
}
}
// Note: Speech bubble rendering functions have been removed.
// Bubbles are now rendered as separate HTML elements (see speech_bubble.rs).

View file

@ -22,14 +22,12 @@ use uuid::Uuid;
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene};
use super::avatar_canvas::{AvatarBoundsStore, AvatarCanvas, ScreenBounds, member_key};
use super::avatar::{Avatar, member_key};
#[cfg(feature = "hydrate")]
use super::canvas_utils::hit_test_canvas;
use super::chat_types::ActiveBubble;
use super::context_menu::{ContextMenu, ContextMenuItem};
use super::loose_prop_canvas::LoosePropCanvas;
use super::speech_bubble::SpeechBubble;
use super::username_label::UsernameLabel;
use super::settings::{
BASE_AVATAR_SCALE, BASE_PROP_SIZE, REFERENCE_HEIGHT, REFERENCE_WIDTH, ViewerSettings,
calculate_min_zoom,
@ -462,34 +460,6 @@ pub fn RealmSceneViewer(
sorted_members.get().iter().map(member_key).collect::<Vec<_>>()
});
// Shared store for avatar bounds - AvatarCanvas writes, SpeechBubble reads
let avatar_bounds_store: AvatarBoundsStore = RwSignal::new(HashMap::new());
// Clean up bounds store when members change (prevent memory leak)
Effect::new(move |_| {
let current_member_ids: std::collections::HashSet<_> = members
.get()
.iter()
.map(|m| m.member.user_id)
.collect();
avatar_bounds_store.update(|map| {
map.retain(|id, _| current_member_ids.contains(id));
});
});
// Scene bounds for clamping bubbles - computed once outside render loop
let scene_bounds_signal = Signal::derive(move || {
ScreenBounds::from_transform(
scene_width_signal.get(),
scene_height_signal.get(),
scale_x_signal.get(),
scale_y_signal.get(),
offset_x_signal.get(),
offset_y_signal.get(),
)
});
let scene_name = scene.name.clone();
view! {
@ -524,7 +494,7 @@ pub fn RealmSceneViewer(
}}
</Show>
</div>
<div class="avatars-container absolute inset-0" style="z-index: 2; pointer-events: none;">
<div class="avatars-container absolute inset-0" style="z-index: 2; pointer-events: none; overflow: visible;">
<Show when=move || scales_ready.get() fallback=|| ()>
{move || {
member_keys.get().into_iter().map(|key| {
@ -535,9 +505,13 @@ pub fn RealmSceneViewer(
members_by_key.get().get(&key).map(|(idx, _)| (*idx as i32) + 10).unwrap_or(10)
});
let z = z_index_signal.get_untracked();
// Derive bubble signal for this member
let bubble_signal = Signal::derive(move || {
active_bubbles.get().get(&key).cloned()
});
view! {
<AvatarCanvas
<Avatar
member=member_signal
scale_x=scale_x_signal
scale_y=scale_y_signal
@ -548,7 +522,7 @@ pub fn RealmSceneViewer(
text_em_size=text_em_size
scene_width=scene_width_signal
scene_height=scene_height_signal
bounds_store=avatar_bounds_store
active_bubble=bubble_signal
/>
}
}).collect_view()
@ -567,9 +541,9 @@ pub fn RealmSceneViewer(
if elapsed < fading.fade_duration {
let opacity = (1.0 - (elapsed as f64 / fading.fade_duration as f64)).max(0.0).min(1.0);
let member_signal = Signal::derive({ let m = fading.member.clone(); move || m.clone() });
// Note: fading members don't need to update bounds store
// Fading members don't show bubbles
Some(view! {
<AvatarCanvas
<Avatar
member=member_signal
scale_x=scale_x_signal
scale_y=scale_y_signal
@ -588,70 +562,6 @@ pub fn RealmSceneViewer(
}}
</Show>
</div>
<div class="labels-container absolute inset-0" style="z-index: 3; pointer-events: none; overflow: visible;">
<Show when=move || scales_ready.get() fallback=|| ()>
// Active members
{move || {
members_by_key.get().into_iter().map(|(_, (_, m))| {
let user_id = m.member.user_id;
let display_name = m.member.display_name.clone();
view! {
<UsernameLabel
user_id=user_id
display_name=display_name
avatar_bounds_store=avatar_bounds_store
text_em_size=text_em_size
/>
}
}).collect_view()
}}
// Fading members
{move || {
let Some(fading_signal) = fading_members else {
return Vec::new().into_iter().collect_view();
};
#[cfg(feature = "hydrate")]
let now = js_sys::Date::now() as i64;
#[cfg(not(feature = "hydrate"))]
let now = 0i64;
fading_signal.get().into_iter().filter_map(|fading| {
let elapsed = now - fading.fade_start;
if elapsed < fading.fade_duration {
let opacity = (1.0 - (elapsed as f64 / fading.fade_duration as f64)).max(0.0).min(1.0);
let user_id = fading.member.member.user_id;
let display_name = fading.member.member.display_name.clone();
Some(view! {
<UsernameLabel
user_id=user_id
display_name=display_name
avatar_bounds_store=avatar_bounds_store
text_em_size=text_em_size
opacity=opacity
/>
})
} else { None }
}).collect_view()
}}
</Show>
</div>
<div class="bubbles-container absolute inset-0" style="z-index: 4; pointer-events: none; overflow: visible;">
<Show when=move || scales_ready.get() fallback=|| ()>
{move || {
active_bubbles.get().into_iter().map(|(user_id, bubble)| {
view! {
<SpeechBubble
user_id=user_id
bubble=bubble
avatar_bounds_store=avatar_bounds_store
bounds=scene_bounds_signal
text_em_size=text_em_size
/>
}
}).collect_view()
}}
</Show>
</div>
<div
class="click-overlay absolute inset-0"
style="z-index: 5; cursor: pointer;"

View file

@ -1,238 +0,0 @@
//! HTML-based speech bubble component.
//!
//! Renders chat bubbles as HTML/CSS elements instead of canvas.
//! Uses measure-after-render to ensure bubbles stay within bounds.
use leptos::prelude::*;
use uuid::Uuid;
use super::avatar_canvas::{AvatarBounds, AvatarBoundsStore, ScreenBounds};
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;
/// Position of speech bubble relative to avatar.
#[derive(Clone, Copy, PartialEq, Debug)]
enum BubblePosition {
Above,
Below,
}
/// Individual speech bubble component.
///
/// Positions bubble centered on avatar, then measures and adjusts
/// after render to keep within bounds.
#[component]
pub fn SpeechBubble(
user_id: Uuid,
bubble: ActiveBubble,
avatar_bounds_store: AvatarBoundsStore,
bounds: Signal<ScreenBounds>,
#[prop(default = 1.0.into())]
text_em_size: Signal<f64>,
) -> impl IntoView {
let content = bubble.message.content.clone();
let emotion = bubble.message.emotion.clone();
let is_whisper = bubble.message.is_whisper;
let expires_at = bubble.expires_at;
let (bg_color, border_color, text_color) = emotion_bubble_colors(&emotion);
// Reference to the bubble element for measuring
let bubble_ref = NodeRef::<leptos::html::Div>::new();
// After measuring: store the computed position and dimensions
let (measured_left, set_measured_left) = signal(0.0_f64);
let (measured_width, set_measured_width) = signal(0.0_f64);
let (measured_top, set_measured_top) = signal(0.0_f64);
let (is_measured, set_is_measured) = signal(false);
// Track the measured position (above or below)
let (measured_position, set_measured_position) = signal(BubblePosition::Above);
// Compute base layout values (position will be determined after measuring)
let base_layout = Memo::new(move |_| {
let te = text_em_size.get();
let text_scale = te * BASE_TEXT_SCALE;
let max_width = 200.0 * text_scale;
let padding = 8.0 * text_scale;
let tail_size = 8.0 * text_scale;
let gap = 5.0 * text_scale;
let b = bounds.get();
let avatar_bounds = avatar_bounds_store
.get()
.get(&user_id)
.copied()
.unwrap_or_default();
let ax = avatar_bounds.content_center_x;
let content_top = avatar_bounds.content_top_y;
let content_bottom = avatar_bounds.content_bottom_y;
// Check if avatar has rendered
let visible = !(ax == 0.0 && content_top == 0.0);
// Container-relative coordinates
let container_x = ax - b.min_x;
let container_top = content_top - b.min_y;
let container_bottom = content_bottom - b.min_y;
let container_width = b.max_x - b.min_x;
let container_height = b.max_y - b.min_y;
(visible, container_x, container_top, container_bottom, container_width, container_height, max_width, text_scale, padding, tail_size, gap)
});
// Effect to measure bubble and calculate final position
#[cfg(feature = "hydrate")]
Effect::new(move |_| {
let (visible, container_x, container_top, container_bottom, container_width, container_height, _, _, _, tail_size, gap) = base_layout.get();
if !visible {
return;
}
if let Some(el) = bubble_ref.get() {
// Get actual bubble dimensions
let rect = el.get_bounding_client_rect();
let bubble_width = rect.width();
let bubble_height = rect.height();
if bubble_width > 0.0 && bubble_height > 0.0 {
// Calculate ideal left edge (centered on avatar)
let ideal_left = container_x - bubble_width / 2.0;
// Clamp to keep bubble within container horizontally
let clamped_left = ideal_left
.max(0.0)
.min((container_width - bubble_width).max(0.0));
// Determine position based on actual measured height
// Space needed above = bubble_height + tail_size + gap
let space_needed = bubble_height + tail_size + gap;
let position = if container_top >= space_needed {
BubblePosition::Above
} else {
BubblePosition::Below
};
// Calculate vertical position based on determined position
let ideal_top = match position {
BubblePosition::Above => container_top - gap - tail_size - bubble_height,
BubblePosition::Below => container_bottom + gap + tail_size,
};
// Clamp to keep bubble within container vertically
let clamped_top = ideal_top
.max(0.0)
.min((container_height - bubble_height).max(0.0));
set_measured_left.set(clamped_left);
set_measured_width.set(bubble_width);
set_measured_top.set(clamped_top);
set_measured_position.set(position);
set_is_measured.set(true);
}
}
});
// Generate style
let style = move || {
let (visible, container_x, _, _, _, _, max_width, text_scale, padding, tail_size, _) = base_layout.get();
if !visible {
return "display: none;".to_string();
}
let measured = is_measured.get();
let m_left = measured_left.get();
let m_width = measured_width.get();
let m_top = measured_top.get();
// Before measurement: position at left=0 so bubble can expand to full width
// After measurement: use calculated positions directly (no transforms)
let (final_left, final_top) = if measured {
(m_left, m_top)
} else {
// Position at left edge initially to allow full-width measurement
// The bubble is invisible during this phase anyway
(0.0, 0.0)
};
// Tail offset: where is avatar relative to bubble left edge?
let tail_offset = if measured && m_width > 0.0 {
// avatar_x (container_x) relative to bubble left, as percentage of bubble width
let avatar_offset = container_x - m_left;
(avatar_offset / m_width * 100.0).clamp(10.0, 90.0)
} else {
50.0
};
// Start invisible until measured (prevents flash)
let opacity = if measured { 1.0 } else { 0.0 };
format!(
"position: absolute; \
left: {}px; \
top: {}px; \
width: fit-content; \
max-width: {}px; \
opacity: {}; \
--bubble-bg: {}; \
--bubble-border: {}; \
--bubble-text: {}; \
--tail-offset: {}%; \
--font-size: {}px; \
--padding: {}px; \
--tail-size: {}px; \
--border-radius: {}px; \
z-index: 99999;",
final_left,
final_top,
max_width,
opacity,
bg_color,
border_color,
text_color,
tail_offset,
12.0 * text_scale,
padding,
tail_size,
8.0 * text_scale,
)
};
let tail_class = move || {
let position = measured_position.get();
match position {
BubblePosition::Above => "speech-bubble tail-below",
BubblePosition::Below => "speech-bubble tail-above",
}
};
#[cfg(feature = "hydrate")]
let is_visible = move || {
let now = js_sys::Date::now() as i64;
now < expires_at
};
#[cfg(not(feature = "hydrate"))]
let is_visible = move || true;
view! {
<Show when=is_visible fallback=|| ()>
<div
node_ref=bubble_ref
class=tail_class
style=style
data-user-id=user_id.to_string()
>
<div
class="bubble-content"
class:whisper=is_whisper
>
{content.clone()}
</div>
</div>
</Show>
}
}

View file

@ -1,74 +0,0 @@
//! HTML-based username label component.
//!
//! Renders display names as HTML/CSS elements instead of canvas,
//! allowing independent updates without triggering avatar redraws.
use leptos::prelude::*;
use uuid::Uuid;
use super::avatar_canvas::AvatarBoundsStore;
/// Base text size multiplier. Text at 100% slider = base_sizes * 1.4
const BASE_TEXT_SCALE: f64 = 1.4;
/// Individual username label component.
///
/// Renders as HTML/CSS for efficient updates independent of avatar canvas.
/// Reads avatar position from the shared bounds store written by AvatarCanvas.
#[component]
pub fn UsernameLabel(
/// The user ID this label belongs to (for reading bounds from store).
user_id: Uuid,
/// The display name to show.
display_name: String,
/// Shared store containing avatar bounds (written by AvatarCanvas).
avatar_bounds_store: AvatarBoundsStore,
/// Text size multiplier.
#[prop(default = 1.0.into())]
text_em_size: Signal<f64>,
/// Optional opacity for fading members.
#[prop(default = 1.0)]
opacity: f64,
) -> impl IntoView {
// Compute style based on avatar bounds
let style = Memo::new(move |_| {
let te = text_em_size.get();
let text_scale = te * BASE_TEXT_SCALE;
let font_size = 12.0 * text_scale;
let avatar_bounds = avatar_bounds_store
.get()
.get(&user_id)
.copied()
.unwrap_or_default();
// If bounds are all zero, avatar hasn't rendered yet
if avatar_bounds.content_center_x == 0.0 && avatar_bounds.content_top_y == 0.0 {
return "display: none;".to_string();
}
let x = avatar_bounds.content_center_x;
let y = avatar_bounds.content_bottom_y + 15.0 * text_scale;
format!(
"position: absolute; \
left: {}px; \
top: {}px; \
transform: translateX(-50%); \
--font-size: {}px; \
opacity: {}; \
z-index: 99998;",
x, y, font_size, opacity
)
});
view! {
<div
class="username-label"
style=style
data-user-id=user_id.to_string()
>
{display_name}
</div>
}
}