remove old AvatarBoundsStore
This commit is contained in:
parent
912ceb7646
commit
c434321376
5 changed files with 447 additions and 567 deletions
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
// Compute data-member-id reactively
|
||||
let data_member_id = move || {
|
||||
let m = member.get();
|
||||
m.member.user_id.to_string()
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 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! {
|
||||
<div class="avatar-wrapper" style=wrapper_style data-member-id=data_member_id>
|
||||
<canvas
|
||||
node_ref=canvas_ref
|
||||
style=style
|
||||
data-member-id=data_member_id
|
||||
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).
|
||||
|
|
@ -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;"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue