fix: Move to use HTML for text
Previously we had the bubbles drawn on the avatar canvas. Now it's actually text, so is the label.
This commit is contained in:
parent
23374ee024
commit
66368fe274
6 changed files with 650 additions and 403 deletions
|
|
@ -5,4 +5,116 @@
|
||||||
* This file is imported after admin.css to allow user-specific overrides.
|
* This file is imported after admin.css to allow user-specific overrides.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* User-specific styles will be added here as needed */
|
/* =============================================================================
|
||||||
|
* Speech Bubble Component
|
||||||
|
* ============================================================================= */
|
||||||
|
|
||||||
|
.speech-bubble {
|
||||||
|
pointer-events: none;
|
||||||
|
font-family: sans-serif;
|
||||||
|
/* Padding MUST be here (not on .bubble-content) for -webkit-line-clamp to work in Chrome.
|
||||||
|
Chrome breaks line-clamp when padding is on the clamped element itself. */
|
||||||
|
background-color: var(--bubble-bg, #374151);
|
||||||
|
border: 2px solid var(--bubble-border, #4B5563);
|
||||||
|
padding: var(--padding, 8px);
|
||||||
|
border-radius: var(--border-radius, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-bubble .bubble-content {
|
||||||
|
/* WARNING: -webkit-line-clamp breaks if padding is on this element in Chrome.
|
||||||
|
Padding must be on the parent (.speech-bubble) instead. */
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--bubble-text, #F9FAFB);
|
||||||
|
font-size: var(--font-size, 12px);
|
||||||
|
line-height: 1.5;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-bubble .bubble-content.whisper {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tail pointing down (bubble is above avatar) */
|
||||||
|
.speech-bubble.tail-below::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: var(--tail-offset, 50%);
|
||||||
|
transform: translateX(-50%) translateY(100%);
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: var(--tail-size, 8px) solid transparent;
|
||||||
|
border-right: var(--tail-size, 8px) solid transparent;
|
||||||
|
border-top: var(--tail-size, 8px) solid var(--bubble-bg, #374151);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tail border (for outline effect) */
|
||||||
|
.speech-bubble.tail-below::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: var(--tail-offset, 50%);
|
||||||
|
transform: translateX(-50%) translateY(100%);
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: calc(var(--tail-size, 8px) + 2px) solid transparent;
|
||||||
|
border-right: calc(var(--tail-size, 8px) + 2px) solid transparent;
|
||||||
|
border-top: calc(var(--tail-size, 8px) + 2px) solid var(--bubble-border, #4B5563);
|
||||||
|
margin-left: 0;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tail pointing up (bubble is below avatar) */
|
||||||
|
.speech-bubble.tail-above::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: var(--tail-offset, 50%);
|
||||||
|
transform: translateX(-50%) translateY(-100%);
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: var(--tail-size, 8px) solid transparent;
|
||||||
|
border-right: var(--tail-size, 8px) solid transparent;
|
||||||
|
border-bottom: var(--tail-size, 8px) solid var(--bubble-bg, #374151);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tail border (for outline effect) */
|
||||||
|
.speech-bubble.tail-above::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: var(--tail-offset, 50%);
|
||||||
|
transform: translateX(-50%) translateY(-100%);
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: calc(var(--tail-size, 8px) + 2px) solid transparent;
|
||||||
|
border-right: calc(var(--tail-size, 8px) + 2px) solid transparent;
|
||||||
|
border-bottom: calc(var(--tail-size, 8px) + 2px) solid var(--bubble-border, #4B5563);
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
* Username Label Component
|
||||||
|
* ============================================================================= */
|
||||||
|
|
||||||
|
.username-label {
|
||||||
|
pointer-events: none;
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-size: var(--font-size, 12px);
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
/* Black outline via text-shadow (matches canvas 3px stroke) */
|
||||||
|
text-shadow:
|
||||||
|
-1.5px -1.5px 0 #000,
|
||||||
|
1.5px -1.5px 0 #000,
|
||||||
|
-1.5px 1.5px 0 #000,
|
||||||
|
1.5px 1.5px 0 #000,
|
||||||
|
0px -1.5px 0 #000,
|
||||||
|
0px 1.5px 0 #000,
|
||||||
|
-1.5px 0px 0 #000,
|
||||||
|
1.5px 0px 0 #000;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,9 @@ pub mod scene_list_popup;
|
||||||
pub mod scene_viewer;
|
pub mod scene_viewer;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod settings_popup;
|
pub mod settings_popup;
|
||||||
|
pub mod speech_bubble;
|
||||||
pub mod tabs;
|
pub mod tabs;
|
||||||
|
pub mod username_label;
|
||||||
pub mod reconnection_overlay;
|
pub mod reconnection_overlay;
|
||||||
pub mod ws_client;
|
pub mod ws_client;
|
||||||
|
|
||||||
|
|
@ -57,5 +59,7 @@ pub use scene_list_popup::*;
|
||||||
pub use scene_viewer::*;
|
pub use scene_viewer::*;
|
||||||
pub use settings::*;
|
pub use settings::*;
|
||||||
pub use settings_popup::*;
|
pub use settings_popup::*;
|
||||||
|
pub use speech_bubble::*;
|
||||||
pub use tabs::*;
|
pub use tabs::*;
|
||||||
|
pub use username_label::*;
|
||||||
pub use ws_client::*;
|
pub use ws_client::*;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
//! This enables efficient updates: position changes only update CSS (no redraw),
|
//! This enables efficient updates: position changes only update CSS (no redraw),
|
||||||
//! while appearance changes (emotion, skin) redraw only that avatar's canvas.
|
//! while appearance changes (emotion, skin) redraw only that avatar's canvas.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|
@ -13,33 +15,39 @@ use chattyness_db::models::{ChannelMemberWithAvatar, EmotionState};
|
||||||
pub use super::canvas_utils::hit_test_canvas;
|
pub use super::canvas_utils::hit_test_canvas;
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use super::canvas_utils::normalize_asset_path;
|
use super::canvas_utils::normalize_asset_path;
|
||||||
use super::chat_types::{ActiveBubble, emotion_bubble_colors};
|
|
||||||
|
// =============================================================================
|
||||||
|
// Avatar Bounds - Exported for use by SpeechBubble and other components
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Computed screen-space bounds for an avatar's visible content.
|
||||||
|
///
|
||||||
|
/// 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type alias for the shared avatar bounds store.
|
||||||
|
/// Maps user_id -> computed bounds.
|
||||||
|
pub type AvatarBoundsStore = RwSignal<HashMap<Uuid, AvatarBounds>>;
|
||||||
|
|
||||||
/// Base text size multiplier. Text at 100% slider = base_sizes * 1.4
|
/// Base text size multiplier. Text at 100% slider = base_sizes * 1.4
|
||||||
const BASE_TEXT_SCALE: f64 = 1.4;
|
const BASE_TEXT_SCALE: f64 = 1.4;
|
||||||
|
|
||||||
/// Estimate bubble height based on actual text content.
|
|
||||||
/// Returns the total height including padding, tail, and gap.
|
|
||||||
fn estimate_bubble_height(text: &str, text_scale: f64) -> f64 {
|
|
||||||
let max_bubble_width = 200.0 * text_scale;
|
|
||||||
let padding = 8.0 * text_scale;
|
|
||||||
let line_height = 16.0 * text_scale;
|
|
||||||
let tail_size = 8.0 * text_scale;
|
|
||||||
let gap = 5.0 * text_scale;
|
|
||||||
|
|
||||||
// Estimate chars per line: roughly 6 pixels per char at default scale
|
|
||||||
let chars_per_line = ((max_bubble_width - padding * 2.0) / (6.0 * text_scale)).floor() as usize;
|
|
||||||
let chars_per_line = chars_per_line.max(10); // minimum 10 chars per line
|
|
||||||
let char_count = text.len();
|
|
||||||
|
|
||||||
// Simple heuristic: estimate lines based on character count
|
|
||||||
let estimated_lines = ((char_count as f64) / (chars_per_line as f64)).ceil() as usize;
|
|
||||||
let estimated_lines = estimated_lines.clamp(1, 4); // 1-4 lines max
|
|
||||||
|
|
||||||
let bubble_height = (estimated_lines as f64) * line_height + padding * 2.0;
|
|
||||||
bubble_height + tail_size + gap
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Content bounds for a 3x3 avatar grid.
|
/// Content bounds for a 3x3 avatar grid.
|
||||||
/// Tracks which rows/columns contain actual content for centering calculations.
|
/// Tracks which rows/columns contain actual content for centering calculations.
|
||||||
struct ContentBounds {
|
struct ContentBounds {
|
||||||
|
|
@ -153,21 +161,25 @@ impl ContentBounds {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Computed boundaries for visual clamping in screen space.
|
/// Screen boundaries for visual clamping in screen space.
|
||||||
#[derive(Clone, Copy)]
|
///
|
||||||
struct ScreenBoundaries {
|
/// 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)
|
/// Left edge of drawable area (= offset_x)
|
||||||
min_x: f64,
|
pub min_x: f64,
|
||||||
/// Right edge (= offset_x + scene_width * scale_x)
|
/// Right edge (= offset_x + scene_width * scale_x)
|
||||||
max_x: f64,
|
pub max_x: f64,
|
||||||
/// Top edge (= offset_y)
|
/// Top edge (= offset_y)
|
||||||
min_y: f64,
|
pub min_y: f64,
|
||||||
/// Bottom edge (= offset_y + scene_height * scale_y)
|
/// Bottom edge (= offset_y + scene_height * scale_y)
|
||||||
max_y: f64,
|
pub max_y: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ScreenBoundaries {
|
impl ScreenBounds {
|
||||||
fn from_transform(
|
/// Create screen bounds from scene transform parameters.
|
||||||
|
pub fn from_transform(
|
||||||
scene_width: f64,
|
scene_width: f64,
|
||||||
scene_height: f64,
|
scene_height: f64,
|
||||||
scale_x: f64,
|
scale_x: f64,
|
||||||
|
|
@ -183,8 +195,8 @@ impl ScreenBoundaries {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clamp avatar center so visual bounds stay within screen boundaries.
|
/// Clamp a center point so content stays within screen boundaries.
|
||||||
fn clamp_avatar_center(
|
pub fn clamp_center(
|
||||||
&self,
|
&self,
|
||||||
center_x: f64,
|
center_x: f64,
|
||||||
center_y: f64,
|
center_y: f64,
|
||||||
|
|
@ -201,35 +213,6 @@ impl ScreenBoundaries {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Position of speech bubble relative to avatar.
|
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
|
||||||
enum BubblePosition {
|
|
||||||
/// Default: bubble above avatar
|
|
||||||
Above,
|
|
||||||
/// When near top edge: bubble below avatar
|
|
||||||
Below,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Determine bubble position based on available space above avatar.
|
|
||||||
fn determine_bubble_position(
|
|
||||||
avatar_screen_y: f64,
|
|
||||||
avatar_half_height: f64,
|
|
||||||
bubble_height: f64,
|
|
||||||
tail_size: f64,
|
|
||||||
gap: f64,
|
|
||||||
min_y: f64,
|
|
||||||
) -> BubblePosition {
|
|
||||||
let space_needed = bubble_height + tail_size + gap;
|
|
||||||
let avatar_top = avatar_screen_y - avatar_half_height;
|
|
||||||
let space_available = avatar_top - min_y;
|
|
||||||
|
|
||||||
if space_available < space_needed {
|
|
||||||
BubblePosition::Below
|
|
||||||
} else {
|
|
||||||
BubblePosition::Above
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unified layout context for avatar canvas rendering.
|
/// Unified layout context for avatar canvas rendering.
|
||||||
///
|
///
|
||||||
/// This struct computes all derived layout values once from the inputs,
|
/// This struct computes all derived layout values once from the inputs,
|
||||||
|
|
@ -237,12 +220,9 @@ fn determine_bubble_position(
|
||||||
/// - Canvas dimensions and position
|
/// - Canvas dimensions and position
|
||||||
/// - Avatar positioning within the canvas
|
/// - Avatar positioning within the canvas
|
||||||
/// - Coordinate transformations between canvas-local and screen space
|
/// - Coordinate transformations between canvas-local and screen space
|
||||||
/// - Bubble positioning and clamping
|
|
||||||
///
|
///
|
||||||
/// By centralizing these calculations, we avoid scattered, duplicated logic
|
/// Note: Speech bubbles are rendered separately as HTML elements (see speech_bubble.rs).
|
||||||
/// and ensure the style closure, Effect, and draw_bubble all use consistent values.
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
#[allow(dead_code)] // Some fields kept for potential future use
|
|
||||||
struct CanvasLayout {
|
struct CanvasLayout {
|
||||||
// Core dimensions
|
// Core dimensions
|
||||||
prop_size: f64,
|
prop_size: f64,
|
||||||
|
|
@ -250,11 +230,9 @@ struct CanvasLayout {
|
||||||
|
|
||||||
// Content offset from grid center
|
// Content offset from grid center
|
||||||
content_x_offset: f64,
|
content_x_offset: f64,
|
||||||
content_y_offset: f64,
|
|
||||||
|
|
||||||
// Text scaling
|
// Text scaling
|
||||||
text_scale: f64,
|
text_scale: f64,
|
||||||
bubble_max_width: f64,
|
|
||||||
|
|
||||||
// Canvas dimensions
|
// Canvas dimensions
|
||||||
canvas_width: f64,
|
canvas_width: f64,
|
||||||
|
|
@ -267,125 +245,61 @@ struct CanvasLayout {
|
||||||
// Avatar center within canvas (canvas-local coordinates)
|
// Avatar center within canvas (canvas-local coordinates)
|
||||||
avatar_cx: f64,
|
avatar_cx: f64,
|
||||||
avatar_cy: f64,
|
avatar_cy: f64,
|
||||||
|
|
||||||
// Scene boundaries for clamping
|
|
||||||
boundaries: ScreenBoundaries,
|
|
||||||
|
|
||||||
// Bubble state
|
|
||||||
bubble_position: BubblePosition,
|
|
||||||
bubble_height_reserved: f64,
|
|
||||||
|
|
||||||
// Content row info for positioning
|
|
||||||
empty_top_rows: usize,
|
|
||||||
empty_bottom_rows: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CanvasLayout {
|
impl CanvasLayout {
|
||||||
/// Create a new layout from all input parameters.
|
/// Create a new layout from input parameters.
|
||||||
fn new(
|
fn new(
|
||||||
content_bounds: &ContentBounds,
|
content_bounds: &ContentBounds,
|
||||||
prop_size: f64,
|
prop_size: f64,
|
||||||
text_em_size: f64,
|
text_em_size: f64,
|
||||||
avatar_screen_x: f64,
|
avatar_screen_x: f64,
|
||||||
avatar_screen_y: f64,
|
avatar_screen_y: f64,
|
||||||
boundaries: ScreenBoundaries,
|
boundaries: ScreenBounds,
|
||||||
has_bubble: bool,
|
|
||||||
bubble_text: Option<&str>,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let avatar_size = prop_size * 3.0;
|
let avatar_size = prop_size * 3.0;
|
||||||
let text_scale = text_em_size * BASE_TEXT_SCALE;
|
let text_scale = text_em_size * BASE_TEXT_SCALE;
|
||||||
let bubble_max_width = 200.0 * text_scale;
|
|
||||||
|
|
||||||
// Content offsets from grid center
|
// Content offsets from grid center
|
||||||
let content_x_offset = content_bounds.x_offset(prop_size);
|
let content_x_offset = content_bounds.x_offset(prop_size);
|
||||||
let content_y_offset = content_bounds.y_offset(prop_size);
|
let content_y_offset = content_bounds.y_offset(prop_size);
|
||||||
|
|
||||||
// Empty rows for positioning elements relative to content
|
|
||||||
let empty_top_rows = content_bounds.empty_top_rows();
|
|
||||||
let empty_bottom_rows = content_bounds.empty_bottom_rows();
|
|
||||||
|
|
||||||
// Content dimensions for clamping
|
// Content dimensions for clamping
|
||||||
let content_half_width = content_bounds.content_width(prop_size) / 2.0;
|
let content_half_width = content_bounds.content_width(prop_size) / 2.0;
|
||||||
let content_half_height = content_bounds.content_height(prop_size) / 2.0;
|
let content_half_height = content_bounds.content_height(prop_size) / 2.0;
|
||||||
|
|
||||||
// Clamp avatar so content stays within scene
|
// Clamp avatar so content stays within scene
|
||||||
let (clamped_x, clamped_y) = boundaries.clamp_avatar_center(
|
let (clamped_x, clamped_y) = boundaries.clamp_center(
|
||||||
avatar_screen_x,
|
avatar_screen_x,
|
||||||
avatar_screen_y,
|
avatar_screen_y,
|
||||||
content_half_width,
|
content_half_width,
|
||||||
content_half_height,
|
content_half_height,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate bubble height and position
|
// Canvas dimensions - sized for avatar grid only (name rendered via HTML)
|
||||||
let bubble_height_reserved = if has_bubble {
|
let canvas_width = avatar_size;
|
||||||
(4.0 * 16.0 + 16.0 + 8.0 + 5.0) * text_scale
|
let canvas_height = avatar_size;
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
let name_height = 20.0 * text_scale;
|
|
||||||
|
|
||||||
// Determine bubble position (above or below)
|
|
||||||
// Use actual content height, not full 3x3 grid size
|
|
||||||
let bubble_position = if has_bubble {
|
|
||||||
let estimated_height = bubble_text
|
|
||||||
.map(|t| estimate_bubble_height(t, text_scale))
|
|
||||||
.unwrap_or(0.0);
|
|
||||||
// clamped_y is the content center, so use content_half_height
|
|
||||||
// to find the actual top of the visible avatar content
|
|
||||||
determine_bubble_position(
|
|
||||||
clamped_y,
|
|
||||||
content_half_height,
|
|
||||||
estimated_height,
|
|
||||||
0.0,
|
|
||||||
0.0,
|
|
||||||
boundaries.min_y,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
BubblePosition::Above
|
|
||||||
};
|
|
||||||
|
|
||||||
// Canvas dimensions - wide enough to fit shifted bubble
|
|
||||||
let extra_margin = if has_bubble { bubble_max_width } else { 0.0 };
|
|
||||||
let canvas_width = avatar_size.max(bubble_max_width) + extra_margin;
|
|
||||||
let canvas_height = avatar_size + bubble_height_reserved + name_height;
|
|
||||||
|
|
||||||
// Canvas position in screen space
|
// Canvas position in screen space
|
||||||
// The avatar grid center maps to canvas_width/2, but we need to account
|
// Account for content offset so visible content aligns with clamped position
|
||||||
// for the content offset so the visible content aligns with clamped_x/y
|
let canvas_screen_x = clamped_x - avatar_size / 2.0 - content_x_offset;
|
||||||
let canvas_x = clamped_x - avatar_size / 2.0 - content_x_offset;
|
let canvas_screen_y = clamped_y - avatar_size / 2.0 - content_y_offset;
|
||||||
let canvas_screen_x = canvas_x - (canvas_width - avatar_size) / 2.0;
|
|
||||||
|
|
||||||
let canvas_y = clamped_y - avatar_size / 2.0 - content_y_offset;
|
|
||||||
let canvas_screen_y = match bubble_position {
|
|
||||||
BubblePosition::Above => canvas_y - bubble_height_reserved,
|
|
||||||
BubblePosition::Below => canvas_y,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Avatar center within canvas
|
// Avatar center within canvas
|
||||||
let avatar_cx = canvas_width / 2.0;
|
let avatar_cx = canvas_width / 2.0;
|
||||||
let avatar_cy = match bubble_position {
|
let avatar_cy = avatar_size / 2.0;
|
||||||
BubblePosition::Above => bubble_height_reserved + avatar_size / 2.0,
|
|
||||||
BubblePosition::Below => avatar_size / 2.0,
|
|
||||||
};
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
prop_size,
|
prop_size,
|
||||||
avatar_size,
|
avatar_size,
|
||||||
content_x_offset,
|
content_x_offset,
|
||||||
content_y_offset,
|
|
||||||
text_scale,
|
text_scale,
|
||||||
bubble_max_width,
|
|
||||||
canvas_width,
|
canvas_width,
|
||||||
canvas_height,
|
canvas_height,
|
||||||
canvas_screen_x,
|
canvas_screen_x,
|
||||||
canvas_screen_y,
|
canvas_screen_y,
|
||||||
avatar_cx,
|
avatar_cx,
|
||||||
avatar_cy,
|
avatar_cy,
|
||||||
boundaries,
|
|
||||||
bubble_position,
|
|
||||||
bubble_height_reserved,
|
|
||||||
empty_top_rows,
|
|
||||||
empty_bottom_rows,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -424,39 +338,6 @@ impl CanvasLayout {
|
||||||
fn avatar_bottom_y(&self) -> f64 {
|
fn avatar_bottom_y(&self) -> f64 {
|
||||||
self.avatar_cy + self.avatar_size / 2.0
|
self.avatar_cy + self.avatar_size / 2.0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert canvas-local X to screen X.
|
|
||||||
fn canvas_to_screen_x(&self, x: f64) -> f64 {
|
|
||||||
self.canvas_screen_x + x
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clamp a bubble's X position to stay within scene boundaries.
|
|
||||||
/// Takes and returns canvas-local coordinates.
|
|
||||||
fn clamp_bubble_x(&self, bubble_x: f64, bubble_width: f64) -> f64 {
|
|
||||||
// Convert to screen space
|
|
||||||
let screen_left = self.canvas_to_screen_x(bubble_x);
|
|
||||||
let screen_right = screen_left + bubble_width;
|
|
||||||
|
|
||||||
// Calculate shifts needed to stay within bounds
|
|
||||||
let shift_right = (self.boundaries.min_x - screen_left).max(0.0);
|
|
||||||
let shift_left = (screen_right - self.boundaries.max_x).max(0.0);
|
|
||||||
|
|
||||||
// Apply shift and clamp to canvas bounds
|
|
||||||
let shifted = bubble_x + shift_right - shift_left;
|
|
||||||
shifted.max(0.0).min(self.canvas_width - bubble_width)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adjustment for bubble Y position based on empty rows at top.
|
|
||||||
/// Returns the distance in pixels from grid top to content top.
|
|
||||||
fn content_top_adjustment(&self) -> f64 {
|
|
||||||
self.empty_top_rows as f64 * self.prop_size
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adjustment for name Y position based on empty rows at bottom.
|
|
||||||
/// Returns the distance in pixels from grid bottom to content bottom.
|
|
||||||
fn content_bottom_adjustment(&self) -> f64 {
|
|
||||||
self.empty_bottom_rows as f64 * self.prop_size
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a unique key for a member (for Leptos For keying).
|
/// Get a unique key for a member (for Leptos For keying).
|
||||||
|
|
@ -470,7 +351,10 @@ pub fn member_key(m: &ChannelMemberWithAvatar) -> Uuid {
|
||||||
/// Renders a single avatar with:
|
/// Renders a single avatar with:
|
||||||
/// - CSS transform for position (GPU-accelerated, no redraw on move)
|
/// - CSS transform for position (GPU-accelerated, no redraw on move)
|
||||||
/// - Canvas for avatar sprite (redraws only on appearance change)
|
/// - Canvas for avatar sprite (redraws only on appearance change)
|
||||||
/// - Optional speech bubble above the avatar
|
///
|
||||||
|
/// 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.
|
||||||
#[component]
|
#[component]
|
||||||
pub fn AvatarCanvas(
|
pub fn AvatarCanvas(
|
||||||
/// The member data for this avatar (as a signal for reactive updates).
|
/// The member data for this avatar (as a signal for reactive updates).
|
||||||
|
|
@ -487,9 +371,7 @@ pub fn AvatarCanvas(
|
||||||
prop_size: Signal<f64>,
|
prop_size: Signal<f64>,
|
||||||
/// Z-index for stacking order (higher = on top).
|
/// Z-index for stacking order (higher = on top).
|
||||||
z_index: i32,
|
z_index: i32,
|
||||||
/// Active speech bubble for this user (if any).
|
/// Text size multiplier for display names and badges.
|
||||||
active_bubble: Signal<Option<ActiveBubble>>,
|
|
||||||
/// Text size multiplier for display names, chat bubbles, and badges.
|
|
||||||
#[prop(default = 1.0.into())]
|
#[prop(default = 1.0.into())]
|
||||||
text_em_size: Signal<f64>,
|
text_em_size: Signal<f64>,
|
||||||
/// Opacity for fade-out animation (0.0 to 1.0, default 1.0).
|
/// Opacity for fade-out animation (0.0 to 1.0, default 1.0).
|
||||||
|
|
@ -501,6 +383,10 @@ pub fn AvatarCanvas(
|
||||||
/// Scene height in scene coordinates (for boundary calculations).
|
/// Scene height in scene coordinates (for boundary calculations).
|
||||||
#[prop(optional)]
|
#[prop(optional)]
|
||||||
scene_height: Option<Signal<f64>>,
|
scene_height: Option<Signal<f64>>,
|
||||||
|
/// Shared store for exporting computed avatar bounds.
|
||||||
|
/// If provided, this avatar will write its bounds to the store.
|
||||||
|
#[prop(optional)]
|
||||||
|
bounds_store: Option<AvatarBoundsStore>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
let canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
||||||
|
|
||||||
|
|
@ -517,7 +403,6 @@ pub fn AvatarCanvas(
|
||||||
let ox = offset_x.get();
|
let ox = offset_x.get();
|
||||||
let oy = offset_y.get();
|
let oy = offset_y.get();
|
||||||
let te = text_em_size.get();
|
let te = text_em_size.get();
|
||||||
let bubble = active_bubble.get();
|
|
||||||
|
|
||||||
// Calculate content bounds for centering on actual content
|
// Calculate content bounds for centering on actual content
|
||||||
let content_bounds = ContentBounds::from_layers(
|
let content_bounds = ContentBounds::from_layers(
|
||||||
|
|
@ -532,7 +417,7 @@ pub fn AvatarCanvas(
|
||||||
let sh = scene_height.map(|s| s.get()).unwrap_or(10000.0);
|
let sh = scene_height.map(|s| s.get()).unwrap_or(10000.0);
|
||||||
|
|
||||||
// Compute screen boundaries and avatar screen position
|
// Compute screen boundaries and avatar screen position
|
||||||
let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy);
|
let boundaries = ScreenBounds::from_transform(sw, sh, sx, sy, ox, oy);
|
||||||
let avatar_screen_x = m.member.position_x * sx + ox;
|
let avatar_screen_x = m.member.position_x * sx + ox;
|
||||||
let avatar_screen_y = m.member.position_y * sy + oy;
|
let avatar_screen_y = m.member.position_y * sy + oy;
|
||||||
|
|
||||||
|
|
@ -544,8 +429,6 @@ pub fn AvatarCanvas(
|
||||||
avatar_screen_x,
|
avatar_screen_x,
|
||||||
avatar_screen_y,
|
avatar_screen_y,
|
||||||
boundaries,
|
boundaries,
|
||||||
bubble.is_some(),
|
|
||||||
bubble.as_ref().map(|b| b.message.content.as_str()),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate CSS style from layout
|
// Generate CSS style from layout
|
||||||
|
|
@ -575,9 +458,9 @@ pub fn AvatarCanvas(
|
||||||
|
|
||||||
// Get current values from signals
|
// Get current values from signals
|
||||||
let m = member.get();
|
let m = member.get();
|
||||||
|
|
||||||
let ps = prop_size.get();
|
let ps = prop_size.get();
|
||||||
let te = text_em_size.get();
|
let te = text_em_size.get();
|
||||||
let bubble = active_bubble.get();
|
|
||||||
|
|
||||||
let Some(canvas) = canvas_ref.get() else {
|
let Some(canvas) = canvas_ref.get() else {
|
||||||
return;
|
return;
|
||||||
|
|
@ -600,7 +483,7 @@ pub fn AvatarCanvas(
|
||||||
let sh = scene_height.map(|s| s.get()).unwrap_or(10000.0);
|
let sh = scene_height.map(|s| s.get()).unwrap_or(10000.0);
|
||||||
|
|
||||||
// Create unified layout - same calculation as style closure
|
// Create unified layout - same calculation as style closure
|
||||||
let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy);
|
let boundaries = ScreenBounds::from_transform(sw, sh, sx, sy, ox, oy);
|
||||||
let avatar_screen_x = m.member.position_x * sx + ox;
|
let avatar_screen_x = m.member.position_x * sx + ox;
|
||||||
let avatar_screen_y = m.member.position_y * sy + oy;
|
let avatar_screen_y = m.member.position_y * sy + oy;
|
||||||
|
|
||||||
|
|
@ -611,10 +494,26 @@ pub fn AvatarCanvas(
|
||||||
avatar_screen_x,
|
avatar_screen_x,
|
||||||
avatar_screen_y,
|
avatar_screen_y,
|
||||||
boundaries,
|
boundaries,
|
||||||
bubble.is_some(),
|
|
||||||
bubble.as_ref().map(|b| b.message.content.as_str()),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||||
|
|
||||||
// Set canvas resolution from layout
|
// Set canvas resolution from layout
|
||||||
|
|
@ -764,30 +663,7 @@ pub fn AvatarCanvas(
|
||||||
let _ = ctx.fill_text(&format!("{}", current_emotion), badge_x, badge_y);
|
let _ = ctx.fill_text(&format!("{}", current_emotion), badge_x, badge_y);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw display name below avatar (with black outline for readability)
|
// Note: Display name and speech bubbles are now rendered separately as HTML elements
|
||||||
let name_x = layout.content_center_x();
|
|
||||||
let name_y = layout.avatar_bottom_y() - layout.content_bottom_adjustment()
|
|
||||||
+ 15.0 * layout.text_scale;
|
|
||||||
|
|
||||||
let display_name = &m.member.display_name;
|
|
||||||
ctx.set_font(&format!("{}px sans-serif", 12.0 * layout.text_scale));
|
|
||||||
ctx.set_text_align("center");
|
|
||||||
ctx.set_text_baseline("alphabetic");
|
|
||||||
// Black outline
|
|
||||||
ctx.set_stroke_style_str("#000");
|
|
||||||
ctx.set_line_width(3.0);
|
|
||||||
let _ = ctx.stroke_text(display_name, name_x, name_y);
|
|
||||||
// White fill
|
|
||||||
ctx.set_fill_style_str("#fff");
|
|
||||||
let _ = ctx.fill_text(display_name, name_x, name_y);
|
|
||||||
|
|
||||||
// Draw speech bubble if active
|
|
||||||
if let Some(ref b) = bubble {
|
|
||||||
let current_time = js_sys::Date::now() as i64;
|
|
||||||
if b.expires_at >= current_time {
|
|
||||||
draw_bubble_with_layout(&ctx, b, &layout, te);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -806,187 +682,5 @@ pub fn AvatarCanvas(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note: Speech bubble rendering functions have been removed.
|
||||||
/// Draw a speech bubble using the unified CanvasLayout.
|
// Bubbles are now rendered as separate HTML elements (see speech_bubble.rs).
|
||||||
///
|
|
||||||
/// This is the preferred method for drawing bubbles - it uses the layout's
|
|
||||||
/// coordinate transformation and clamping methods, ensuring consistency
|
|
||||||
/// with the canvas positioning.
|
|
||||||
#[cfg(feature = "hydrate")]
|
|
||||||
fn draw_bubble_with_layout(
|
|
||||||
ctx: &web_sys::CanvasRenderingContext2d,
|
|
||||||
bubble: &ActiveBubble,
|
|
||||||
layout: &CanvasLayout,
|
|
||||||
text_em_size: f64,
|
|
||||||
) {
|
|
||||||
let text_scale = text_em_size * BASE_TEXT_SCALE;
|
|
||||||
let max_bubble_width = 200.0 * text_scale;
|
|
||||||
let padding = 8.0 * text_scale;
|
|
||||||
let font_size = 12.0 * text_scale;
|
|
||||||
let line_height = 16.0 * text_scale;
|
|
||||||
let tail_size = 8.0 * text_scale;
|
|
||||||
let border_radius = 8.0 * text_scale;
|
|
||||||
let gap = 5.0 * text_scale;
|
|
||||||
|
|
||||||
let (bg_color, border_color, text_color) = emotion_bubble_colors(&bubble.message.emotion);
|
|
||||||
|
|
||||||
// Use italic font for whispers
|
|
||||||
let font_style = if bubble.message.is_whisper {
|
|
||||||
"italic "
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
};
|
|
||||||
|
|
||||||
// Measure and wrap text
|
|
||||||
ctx.set_font(&format!("{}{}px sans-serif", font_style, font_size));
|
|
||||||
let lines = wrap_text(ctx, &bubble.message.content, max_bubble_width - padding * 2.0);
|
|
||||||
|
|
||||||
// Calculate bubble dimensions
|
|
||||||
let bubble_width = lines
|
|
||||||
.iter()
|
|
||||||
.map(|line| ctx.measure_text(line).map(|m| m.width()).unwrap_or(0.0))
|
|
||||||
.fold(0.0_f64, |a, b| a.max(b))
|
|
||||||
+ padding * 2.0;
|
|
||||||
let bubble_width = bubble_width.max(60.0 * text_scale);
|
|
||||||
let bubble_height = (lines.len() as f64) * line_height + padding * 2.0;
|
|
||||||
|
|
||||||
// Get content center from layout
|
|
||||||
let content_center_x = layout.content_center_x();
|
|
||||||
|
|
||||||
// Calculate initial bubble X (centered on content)
|
|
||||||
let initial_bubble_x = content_center_x - bubble_width / 2.0;
|
|
||||||
|
|
||||||
// Use layout's clamping method - this handles coordinate transformation correctly
|
|
||||||
let bubble_x = layout.clamp_bubble_x(initial_bubble_x, bubble_width);
|
|
||||||
|
|
||||||
// Calculate tail center - point toward content center but stay within bubble bounds
|
|
||||||
let tail_center_x = content_center_x
|
|
||||||
.max(bubble_x + tail_size + border_radius)
|
|
||||||
.min(bubble_x + bubble_width - tail_size - border_radius);
|
|
||||||
|
|
||||||
// Calculate vertical position based on bubble position
|
|
||||||
let bubble_y = match layout.bubble_position {
|
|
||||||
BubblePosition::Above => {
|
|
||||||
// Position vertically closer to content when top rows are empty
|
|
||||||
let adjusted_top_y = layout.avatar_top_y() + layout.content_top_adjustment();
|
|
||||||
adjusted_top_y - bubble_height - tail_size - gap
|
|
||||||
}
|
|
||||||
BubblePosition::Below => {
|
|
||||||
// Position below avatar with gap for tail
|
|
||||||
layout.avatar_bottom_y() + tail_size + gap
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Draw bubble background
|
|
||||||
draw_rounded_rect(ctx, bubble_x, bubble_y, bubble_width, bubble_height, border_radius);
|
|
||||||
ctx.set_fill_style_str(bg_color);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.set_stroke_style_str(border_color);
|
|
||||||
ctx.set_line_width(2.0);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Draw tail pointing to content center
|
|
||||||
ctx.begin_path();
|
|
||||||
match layout.bubble_position {
|
|
||||||
BubblePosition::Above => {
|
|
||||||
// Tail points DOWN toward avatar
|
|
||||||
ctx.move_to(tail_center_x - tail_size, bubble_y + bubble_height);
|
|
||||||
ctx.line_to(tail_center_x, bubble_y + bubble_height + tail_size);
|
|
||||||
ctx.line_to(tail_center_x + tail_size, bubble_y + bubble_height);
|
|
||||||
}
|
|
||||||
BubblePosition::Below => {
|
|
||||||
// Tail points UP toward avatar
|
|
||||||
ctx.move_to(tail_center_x - tail_size, bubble_y);
|
|
||||||
ctx.line_to(tail_center_x, bubble_y - tail_size);
|
|
||||||
ctx.line_to(tail_center_x + tail_size, bubble_y);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.close_path();
|
|
||||||
ctx.set_fill_style_str(bg_color);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.set_stroke_style_str(border_color);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Draw text
|
|
||||||
ctx.set_font(&format!("{}{}px sans-serif", font_style, font_size));
|
|
||||||
ctx.set_fill_style_str(text_color);
|
|
||||||
ctx.set_text_align("left");
|
|
||||||
ctx.set_text_baseline("top");
|
|
||||||
for (i, line) in lines.iter().enumerate() {
|
|
||||||
let _ = ctx.fill_text(
|
|
||||||
line,
|
|
||||||
bubble_x + padding,
|
|
||||||
bubble_y + padding + (i as f64) * line_height,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wrap text to fit within max_width.
|
|
||||||
#[cfg(feature = "hydrate")]
|
|
||||||
fn wrap_text(ctx: &web_sys::CanvasRenderingContext2d, text: &str, max_width: f64) -> Vec<String> {
|
|
||||||
let words: Vec<&str> = text.split_whitespace().collect();
|
|
||||||
let mut lines = Vec::new();
|
|
||||||
let mut current_line = String::new();
|
|
||||||
|
|
||||||
for word in words {
|
|
||||||
let test_line = if current_line.is_empty() {
|
|
||||||
word.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{} {}", current_line, word)
|
|
||||||
};
|
|
||||||
|
|
||||||
let width = ctx
|
|
||||||
.measure_text(&test_line)
|
|
||||||
.map(|m| m.width())
|
|
||||||
.unwrap_or(0.0);
|
|
||||||
|
|
||||||
if width > max_width && !current_line.is_empty() {
|
|
||||||
lines.push(current_line);
|
|
||||||
current_line = word.to_string();
|
|
||||||
} else {
|
|
||||||
current_line = test_line;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !current_line.is_empty() {
|
|
||||||
lines.push(current_line);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limit to 4 lines
|
|
||||||
if lines.len() > 4 {
|
|
||||||
lines.truncate(3);
|
|
||||||
if let Some(last) = lines.last_mut() {
|
|
||||||
last.push_str("...");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if lines.is_empty() {
|
|
||||||
lines.push(text.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
lines
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// 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();
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,14 @@ use uuid::Uuid;
|
||||||
|
|
||||||
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene};
|
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene};
|
||||||
|
|
||||||
use super::avatar_canvas::{AvatarCanvas, member_key};
|
use super::avatar_canvas::{AvatarBoundsStore, AvatarCanvas, ScreenBounds, member_key};
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use super::canvas_utils::hit_test_canvas;
|
use super::canvas_utils::hit_test_canvas;
|
||||||
use super::chat_types::ActiveBubble;
|
use super::chat_types::ActiveBubble;
|
||||||
use super::context_menu::{ContextMenu, ContextMenuItem};
|
use super::context_menu::{ContextMenu, ContextMenuItem};
|
||||||
use super::loose_prop_canvas::LoosePropCanvas;
|
use super::loose_prop_canvas::LoosePropCanvas;
|
||||||
|
use super::speech_bubble::SpeechBubble;
|
||||||
|
use super::username_label::UsernameLabel;
|
||||||
use super::settings::{
|
use super::settings::{
|
||||||
BASE_AVATAR_SCALE, BASE_PROP_SIZE, REFERENCE_HEIGHT, REFERENCE_WIDTH, ViewerSettings,
|
BASE_AVATAR_SCALE, BASE_PROP_SIZE, REFERENCE_HEIGHT, REFERENCE_WIDTH, ViewerSettings,
|
||||||
calculate_min_zoom,
|
calculate_min_zoom,
|
||||||
|
|
@ -460,6 +462,34 @@ pub fn RealmSceneViewer(
|
||||||
sorted_members.get().iter().map(member_key).collect::<Vec<_>>()
|
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();
|
let scene_name = scene.name.clone();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
|
|
@ -504,7 +534,6 @@ pub fn RealmSceneViewer(
|
||||||
let z_index_signal = Signal::derive(move || {
|
let z_index_signal = Signal::derive(move || {
|
||||||
members_by_key.get().get(&key).map(|(idx, _)| (*idx as i32) + 10).unwrap_or(10)
|
members_by_key.get().get(&key).map(|(idx, _)| (*idx as i32) + 10).unwrap_or(10)
|
||||||
});
|
});
|
||||||
let bubble_signal = Signal::derive(move || active_bubbles.get().get(&key).cloned());
|
|
||||||
let z = z_index_signal.get_untracked();
|
let z = z_index_signal.get_untracked();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
|
|
@ -516,10 +545,10 @@ pub fn RealmSceneViewer(
|
||||||
offset_y=offset_y_signal
|
offset_y=offset_y_signal
|
||||||
prop_size=prop_size
|
prop_size=prop_size
|
||||||
z_index=z
|
z_index=z
|
||||||
active_bubble=bubble_signal
|
|
||||||
text_em_size=text_em_size
|
text_em_size=text_em_size
|
||||||
scene_width=scene_width_signal
|
scene_width=scene_width_signal
|
||||||
scene_height=scene_height_signal
|
scene_height=scene_height_signal
|
||||||
|
bounds_store=avatar_bounds_store
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}).collect_view()
|
}).collect_view()
|
||||||
|
|
@ -538,7 +567,7 @@ pub fn RealmSceneViewer(
|
||||||
if elapsed < fading.fade_duration {
|
if elapsed < fading.fade_duration {
|
||||||
let opacity = (1.0 - (elapsed as f64 / fading.fade_duration as f64)).max(0.0).min(1.0);
|
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() });
|
let member_signal = Signal::derive({ let m = fading.member.clone(); move || m.clone() });
|
||||||
let bubble_signal: Signal<Option<ActiveBubble>> = Signal::derive(|| None);
|
// Note: fading members don't need to update bounds store
|
||||||
Some(view! {
|
Some(view! {
|
||||||
<AvatarCanvas
|
<AvatarCanvas
|
||||||
member=member_signal
|
member=member_signal
|
||||||
|
|
@ -548,7 +577,6 @@ pub fn RealmSceneViewer(
|
||||||
offset_y=offset_y_signal
|
offset_y=offset_y_signal
|
||||||
prop_size=prop_size
|
prop_size=prop_size
|
||||||
z_index=5
|
z_index=5
|
||||||
active_bubble=bubble_signal
|
|
||||||
text_em_size=text_em_size
|
text_em_size=text_em_size
|
||||||
opacity=opacity
|
opacity=opacity
|
||||||
scene_width=scene_width_signal
|
scene_width=scene_width_signal
|
||||||
|
|
@ -560,9 +588,96 @@ pub fn RealmSceneViewer(
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="labels-container absolute inset-0" style="z-index: 3; pointer-events: none; overflow: visible;">
|
||||||
|
<Show when=move || scales_ready.get() fallback=|| ()>
|
||||||
|
// Debug dots at avatar bounds center
|
||||||
|
{move || {
|
||||||
|
members_by_key.get().into_iter().map(|(_, (_, m))| {
|
||||||
|
let user_id = m.member.user_id;
|
||||||
|
let dot_style = Memo::new(move |_| {
|
||||||
|
let bounds = avatar_bounds_store.get();
|
||||||
|
if let Some(ab) = bounds.get(&user_id) {
|
||||||
|
format!(
|
||||||
|
"position: absolute; left: {}px; top: {}px; \
|
||||||
|
width: 5px; height: 5px; background: red; \
|
||||||
|
border-radius: 50%; transform: translate(-50%, -50%); \
|
||||||
|
z-index: 99997;",
|
||||||
|
ab.content_center_x, ab.content_center_y
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
"display: none;".to_string()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
view! {
|
||||||
|
<div style=dot_style data-debug="bounds-center" data-user-id=user_id.to_string() />
|
||||||
|
}
|
||||||
|
}).collect_view()
|
||||||
|
}}
|
||||||
|
// 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
|
<div
|
||||||
class="click-overlay absolute inset-0"
|
class="click-overlay absolute inset-0"
|
||||||
style="z-index: 3; cursor: pointer;"
|
style="z-index: 5; cursor: pointer;"
|
||||||
aria-label=format!("Scene: {}", scene_name)
|
aria-label=format!("Scene: {}", scene_name)
|
||||||
role="img"
|
role="img"
|
||||||
on:click=move |ev| {
|
on:click=move |ev| {
|
||||||
|
|
|
||||||
248
crates/chattyness-user-ui/src/components/speech_bubble.rs
Normal file
248
crates/chattyness-user-ui/src/components/speech_bubble.rs
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
//! 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 message = bubble.message.clone();
|
||||||
|
let content = message.content.clone();
|
||||||
|
let emotion = message.emotion.clone();
|
||||||
|
let is_whisper = 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();
|
||||||
|
let position = measured_position.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
|
||||||
|
};
|
||||||
|
|
||||||
|
let transform_origin = match position {
|
||||||
|
BubblePosition::Above => format!("{}% 100%", tail_offset),
|
||||||
|
BubblePosition::Below => format!("{}% 0%", tail_offset),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start invisible until measured (prevents flash)
|
||||||
|
let opacity = if measured { 1.0 } else { 0.0 };
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"position: absolute; \
|
||||||
|
left: {}px; \
|
||||||
|
top: {}px; \
|
||||||
|
transform: none; \
|
||||||
|
transform-origin: {}; \
|
||||||
|
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,
|
||||||
|
transform_origin,
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
}
|
||||||
74
crates/chattyness-user-ui/src/components/username_label.rs
Normal file
74
crates/chattyness-user-ui/src/components/username_label.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
//! 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