Unified layout computation & cleanup
This commit is contained in:
parent
e8ca7c9a12
commit
ae210d5352
4 changed files with 307 additions and 193 deletions
|
|
@ -1,5 +1,6 @@
|
|||
//! Reusable UI components.
|
||||
|
||||
pub mod constants;
|
||||
pub mod avatar;
|
||||
pub mod avatar_editor;
|
||||
pub mod canvas_utils;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@ use uuid::Uuid;
|
|||
use chattyness_db::models::{ChannelMemberWithAvatar, EmotionState};
|
||||
|
||||
use super::chat_types::{ActiveBubble, emotion_bubble_colors};
|
||||
use super::constants::{
|
||||
BASE_TEXT_SCALE, BUBBLE_BORDER_RADIUS_BASE, BUBBLE_BORDER_WIDTH, BUBBLE_FONT_SIZE_BASE,
|
||||
BUBBLE_GAP_BASE, BUBBLE_LINE_HEIGHT, BUBBLE_MAX_WIDTH_BASE, BUBBLE_PADDING_BASE,
|
||||
BUBBLE_TAIL_SIZE_BASE, LABEL_FONT_SIZE_BASE, LABEL_Y_OFFSET_BASE, Z_SPEECH_BUBBLE,
|
||||
Z_USERNAME_LABEL,
|
||||
};
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
pub use super::canvas_utils::hit_test_canvas;
|
||||
|
|
@ -116,8 +122,9 @@ impl SceneTransform {
|
|||
}
|
||||
}
|
||||
|
||||
/// Base text size multiplier. Text at 100% slider = base_sizes * 1.4
|
||||
const BASE_TEXT_SCALE: f64 = 1.4;
|
||||
// =============================================================================
|
||||
// Content Bounds - Avatar grid content analysis
|
||||
// =============================================================================
|
||||
|
||||
/// Content bounds for a 3x3 avatar grid.
|
||||
/// Tracks which rows/columns contain actual content for centering calculations.
|
||||
|
|
@ -232,6 +239,10 @@ impl ContentBounds {
|
|||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Canvas Layout - Unified layout context
|
||||
// =============================================================================
|
||||
|
||||
/// Unified layout context for avatar canvas rendering.
|
||||
///
|
||||
/// This struct computes all derived layout values once from the inputs,
|
||||
|
|
@ -240,39 +251,39 @@ impl ContentBounds {
|
|||
/// - Avatar positioning within the canvas
|
||||
/// - Coordinate transformations between canvas-local and screen space
|
||||
/// - Username label and speech bubble positioning
|
||||
#[derive(Clone, Copy)]
|
||||
struct CanvasLayout {
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct CanvasLayout {
|
||||
// Core dimensions
|
||||
prop_size: f64,
|
||||
avatar_size: f64,
|
||||
pub prop_size: f64,
|
||||
pub avatar_size: f64,
|
||||
|
||||
// Content offset from grid center
|
||||
content_x_offset: f64,
|
||||
pub content_x_offset: f64,
|
||||
|
||||
// Text scaling
|
||||
text_scale: f64,
|
||||
pub text_scale: f64,
|
||||
|
||||
// Canvas dimensions
|
||||
canvas_width: f64,
|
||||
canvas_height: f64,
|
||||
pub canvas_width: f64,
|
||||
pub canvas_height: f64,
|
||||
|
||||
// Canvas position in screen space
|
||||
canvas_screen_x: f64,
|
||||
canvas_screen_y: f64,
|
||||
pub canvas_screen_x: f64,
|
||||
pub canvas_screen_y: f64,
|
||||
|
||||
// Avatar center within canvas (canvas-local coordinates)
|
||||
avatar_cx: f64,
|
||||
avatar_cy: f64,
|
||||
pub avatar_cx: f64,
|
||||
pub avatar_cy: f64,
|
||||
|
||||
// Content bounds info for positioning
|
||||
empty_top_rows: usize,
|
||||
empty_bottom_rows: usize,
|
||||
pub empty_top_rows: usize,
|
||||
pub 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,
|
||||
pub scene_min_x: f64,
|
||||
pub scene_max_x: f64,
|
||||
pub scene_min_y: f64,
|
||||
pub scene_max_y: f64,
|
||||
}
|
||||
|
||||
impl CanvasLayout {
|
||||
|
|
@ -338,19 +349,29 @@ impl CanvasLayout {
|
|||
}
|
||||
|
||||
/// Content center X in canvas-local coordinates.
|
||||
fn content_center_x(&self) -> f64 {
|
||||
pub fn content_center_x(&self) -> f64 {
|
||||
self.avatar_cx + self.content_x_offset
|
||||
}
|
||||
|
||||
/// Top of avatar in canvas-local coordinates.
|
||||
fn avatar_top_y(&self) -> f64 {
|
||||
pub fn avatar_top_y(&self) -> f64 {
|
||||
self.avatar_cy - self.avatar_size / 2.0
|
||||
}
|
||||
|
||||
/// Bottom of avatar in canvas-local coordinates.
|
||||
fn avatar_bottom_y(&self) -> f64 {
|
||||
pub fn avatar_bottom_y(&self) -> f64 {
|
||||
self.avatar_cy + self.avatar_size / 2.0
|
||||
}
|
||||
|
||||
/// Top of actual content (accounting for empty rows).
|
||||
pub fn content_top_y(&self) -> f64 {
|
||||
self.avatar_top_y() + self.empty_top_rows as f64 * self.prop_size
|
||||
}
|
||||
|
||||
/// Bottom of actual content (accounting for empty rows).
|
||||
pub fn content_bottom_y(&self) -> f64 {
|
||||
self.avatar_bottom_y() - self.empty_bottom_rows as f64 * self.prop_size
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a unique key for a member (for Leptos For keying).
|
||||
|
|
@ -359,13 +380,141 @@ pub fn member_key(m: &ChannelMemberWithAvatar) -> Uuid {
|
|||
m.member.user_id
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Bubble Position
|
||||
// =============================================================================
|
||||
|
||||
/// Position of speech bubble relative to avatar.
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
#[derive(Clone, Copy, PartialEq, Debug, Default)]
|
||||
enum BubblePosition {
|
||||
#[default]
|
||||
Above,
|
||||
Below,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Bubble Style Helper
|
||||
// =============================================================================
|
||||
|
||||
/// Parameters for bubble style generation.
|
||||
struct BubbleStyleParams {
|
||||
position_type: &'static str,
|
||||
left: f64,
|
||||
top: f64,
|
||||
width_style: String,
|
||||
opacity: f64,
|
||||
bg_color: &'static str,
|
||||
border_color: &'static str,
|
||||
text_color: &'static str,
|
||||
tail_offset: f64,
|
||||
text_scale: f64,
|
||||
}
|
||||
|
||||
/// Generate the CSS style string for a speech bubble.
|
||||
fn build_bubble_style(params: &BubbleStyleParams) -> String {
|
||||
let font_size = BUBBLE_FONT_SIZE_BASE * params.text_scale;
|
||||
let padding = BUBBLE_PADDING_BASE * params.text_scale;
|
||||
let tail_size = BUBBLE_TAIL_SIZE_BASE * params.text_scale;
|
||||
let border_radius = BUBBLE_BORDER_RADIUS_BASE * params.text_scale;
|
||||
|
||||
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: {}; \
|
||||
pointer-events: none;",
|
||||
params.position_type,
|
||||
params.left,
|
||||
params.top,
|
||||
params.width_style,
|
||||
params.opacity,
|
||||
params.bg_color,
|
||||
params.border_color,
|
||||
params.text_color,
|
||||
params.tail_offset,
|
||||
font_size,
|
||||
padding,
|
||||
tail_size,
|
||||
border_radius,
|
||||
Z_SPEECH_BUBBLE,
|
||||
)
|
||||
}
|
||||
|
||||
/// Calculate bubble position based on layout and measured dimensions.
|
||||
///
|
||||
/// Returns (left, top, tail_offset, position) in wrapper-relative coordinates,
|
||||
/// or None if not yet measured.
|
||||
fn calculate_bubble_position(
|
||||
layout: &CanvasLayout,
|
||||
bubble_width: f64,
|
||||
bubble_height: f64,
|
||||
) -> (f64, f64, f64, BubblePosition) {
|
||||
let tail_size = BUBBLE_TAIL_SIZE_BASE * layout.text_scale;
|
||||
let gap = BUBBLE_GAP_BASE * layout.text_scale;
|
||||
|
||||
// Avatar bounds in canvas/wrapper-local coordinates
|
||||
let avatar_center_x = layout.content_center_x();
|
||||
let avatar_top = layout.content_top_y();
|
||||
let avatar_bottom = layout.content_bottom_y();
|
||||
|
||||
// Convert to screen 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 screen 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).max(layout.scene_min_x));
|
||||
|
||||
// Vertical positioning - determine above or below
|
||||
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).max(layout.scene_min_y));
|
||||
|
||||
// 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 (where avatar is relative to bubble)
|
||||
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
|
||||
};
|
||||
|
||||
(bubble_left, bubble_top, tail_offset, position)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Avatar Component
|
||||
// =============================================================================
|
||||
|
||||
/// Individual avatar component.
|
||||
///
|
||||
/// Renders a single avatar with:
|
||||
|
|
@ -401,17 +550,16 @@ pub fn Avatar(
|
|||
// Disable pointer-events when fading (opacity < 1.0) so fading avatars aren't clickable
|
||||
let pointer_events = if opacity < 1.0 { "none" } else { "auto" };
|
||||
|
||||
// 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 || {
|
||||
// ==========================================================================
|
||||
// Unified layout computation - single Memo used by all parts of the component
|
||||
// ==========================================================================
|
||||
let layout_memo: Memo<CanvasLayout> = Memo::new(move |_| {
|
||||
let m = member.get();
|
||||
let ps = prop_size.get();
|
||||
let t = transform.get();
|
||||
|
|
@ -439,11 +587,13 @@ pub fn Avatar(
|
|||
avatar_screen_y,
|
||||
boundaries,
|
||||
)
|
||||
};
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Wrapper style (positions the entire avatar container)
|
||||
// ==========================================================================
|
||||
let wrapper_style = move || {
|
||||
let layout = compute_layout();
|
||||
let layout = layout_memo.get();
|
||||
format!(
|
||||
"position: absolute; \
|
||||
left: 0; top: 0; \
|
||||
|
|
@ -464,18 +614,15 @@ pub fn Avatar(
|
|||
)
|
||||
};
|
||||
|
||||
// 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;
|
||||
let layout = layout_memo.get();
|
||||
let font_size = LABEL_FONT_SIZE_BASE * 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;
|
||||
let label_y = layout.content_bottom_y() + LABEL_Y_OFFSET_BASE * layout.text_scale;
|
||||
|
||||
format!(
|
||||
"position: absolute; \
|
||||
|
|
@ -484,8 +631,8 @@ pub fn Avatar(
|
|||
transform: translateX(-50%); \
|
||||
font-size: {}px; \
|
||||
white-space: nowrap; \
|
||||
z-index: 99998;",
|
||||
label_x, label_y, font_size
|
||||
z-index: {};",
|
||||
label_x, label_y, font_size, Z_USERNAME_LABEL
|
||||
)
|
||||
};
|
||||
|
||||
|
|
@ -493,12 +640,11 @@ pub fn Avatar(
|
|||
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()
|
||||
};
|
||||
let data_member_id = move || member.get().member.user_id.to_string();
|
||||
|
||||
// Store references for the canvas drawing effect
|
||||
// ==========================================================================
|
||||
// Canvas drawing effect
|
||||
// ==========================================================================
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use std::cell::RefCell;
|
||||
|
|
@ -521,40 +667,12 @@ pub fn Avatar(
|
|||
|
||||
// Get current values from signals
|
||||
let m = member.get();
|
||||
|
||||
let ps = prop_size.get();
|
||||
let te = text_em_size.get();
|
||||
let layout = layout_memo.get();
|
||||
|
||||
let Some(canvas) = canvas_ref.get() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Calculate content bounds for the avatar
|
||||
let content_bounds = ContentBounds::from_layers(
|
||||
&m.avatar.skin_layer,
|
||||
&m.avatar.clothes_layer,
|
||||
&m.avatar.accessories_layer,
|
||||
&m.avatar.emotion_layer,
|
||||
);
|
||||
|
||||
// Use passed-in transform and screen bounds (computed once at scene level)
|
||||
let t = transform.get();
|
||||
let boundaries = screen_bounds.get();
|
||||
let (avatar_screen_x, avatar_screen_y) =
|
||||
t.to_screen(m.member.position_x, m.member.position_y);
|
||||
|
||||
let layout = CanvasLayout::new(
|
||||
&content_bounds,
|
||||
ps,
|
||||
te,
|
||||
avatar_screen_x,
|
||||
avatar_screen_y,
|
||||
boundaries,
|
||||
);
|
||||
|
||||
// Store layout for use by label and bubble
|
||||
set_layout_signal.set(Some(layout));
|
||||
|
||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||
|
||||
// Set canvas resolution from layout
|
||||
|
|
@ -705,6 +823,10 @@ pub fn Avatar(
|
|||
}
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Bubble measurement effect
|
||||
// ==========================================================================
|
||||
|
||||
// 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);
|
||||
|
|
@ -768,77 +890,10 @@ pub fn Avatar(
|
|||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
// ==========================================================================
|
||||
// Bubble visibility and style
|
||||
// ==========================================================================
|
||||
|
||||
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 {
|
||||
|
|
@ -851,11 +906,8 @@ pub fn Avatar(
|
|||
now < bubble.expires_at
|
||||
};
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
let is_bubble_visible = move || {
|
||||
active_bubble.map(|s| s.get().is_some()).unwrap_or(false)
|
||||
};
|
||||
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();
|
||||
|
|
@ -863,29 +915,25 @@ pub fn Avatar(
|
|||
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 layout = layout_memo.get();
|
||||
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 max_width = BUBBLE_MAX_WIDTH_BASE * layout.text_scale;
|
||||
let font_size = BUBBLE_FONT_SIZE_BASE * layout.text_scale;
|
||||
let padding = BUBBLE_PADDING_BASE * layout.text_scale;
|
||||
|
||||
let measured = bubble_is_measured.get();
|
||||
let m_height = bubble_measured_height.get();
|
||||
let m_width = bubble_measured_width.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 single_line_height =
|
||||
font_size * BUBBLE_LINE_HEIGHT + padding * 2.0 + BUBBLE_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 {
|
||||
|
|
@ -894,10 +942,12 @@ pub fn Avatar(
|
|||
|
||||
// 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() {
|
||||
if measured && m_width > 0.0 && m_height > 0.0 {
|
||||
let (left, top, tail, pos) =
|
||||
calculate_bubble_position(&layout, m_width, m_height);
|
||||
("absolute", left, top, tail, pos)
|
||||
} else {
|
||||
// Not yet measured - use fixed positioning off-screen
|
||||
// Not yet measured - position off-screen for measurement
|
||||
("fixed", -1000.0, -1000.0, 50.0, BubblePosition::Above)
|
||||
};
|
||||
|
||||
|
|
@ -907,36 +957,18 @@ pub fn Avatar(
|
|||
// 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;",
|
||||
build_bubble_style(&BubbleStyleParams {
|
||||
position_type,
|
||||
final_left,
|
||||
final_top,
|
||||
left: final_left,
|
||||
top: final_top,
|
||||
width_style,
|
||||
bubble_opacity,
|
||||
opacity: bubble_opacity,
|
||||
bg_color,
|
||||
border_color,
|
||||
text_color,
|
||||
tail_offset,
|
||||
font_size,
|
||||
padding,
|
||||
tail_size,
|
||||
8.0 * layout.text_scale,
|
||||
)
|
||||
text_scale: layout.text_scale,
|
||||
})
|
||||
};
|
||||
|
||||
let bubble_class = move || {
|
||||
|
|
@ -961,11 +993,15 @@ pub fn Avatar(
|
|||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
// ==========================================================================
|
||||
// View
|
||||
// ==========================================================================
|
||||
|
||||
view! {
|
||||
<div class="avatar-wrapper" style=wrapper_style data-member-id=data_member_id>
|
||||
<canvas
|
||||
node_ref=canvas_ref
|
||||
style=canvas_style
|
||||
style="width: 100%; height: 100%;"
|
||||
/>
|
||||
<div class="username-label" style=label_style>
|
||||
{display_name}
|
||||
|
|
|
|||
76
crates/chattyness-user-ui/src/components/constants.rs
Normal file
76
crates/chattyness-user-ui/src/components/constants.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
//! Shared constants for UI components.
|
||||
//!
|
||||
//! Centralizes magic numbers and configuration values used across multiple components.
|
||||
|
||||
// =============================================================================
|
||||
// Text Scaling
|
||||
// =============================================================================
|
||||
|
||||
/// Base text size multiplier. Text at 100% slider = base_sizes * 1.4
|
||||
pub const BASE_TEXT_SCALE: f64 = 1.4;
|
||||
|
||||
// =============================================================================
|
||||
// Z-Index Layers
|
||||
// =============================================================================
|
||||
|
||||
/// Z-index for the props container.
|
||||
pub const Z_PROPS: i32 = 1;
|
||||
|
||||
/// Z-index for individual loose props inside the container.
|
||||
pub const Z_LOOSE_PROP: i32 = 5;
|
||||
|
||||
/// Z-index for the avatars container.
|
||||
pub const Z_AVATARS_CONTAINER: i32 = 2;
|
||||
|
||||
/// Z-index for fading avatars (below active avatars).
|
||||
pub const Z_FADING_AVATAR: i32 = 5;
|
||||
|
||||
/// Base z-index for active avatars (actual z-index is this + sort order).
|
||||
pub const Z_AVATAR_BASE: i32 = 10;
|
||||
|
||||
/// Z-index for the click overlay (captures scene interactions).
|
||||
pub const Z_CLICK_OVERLAY: i32 = 100;
|
||||
|
||||
/// Z-index for username labels (above avatars, below bubbles).
|
||||
pub const Z_USERNAME_LABEL: i32 = 200;
|
||||
|
||||
/// Z-index for speech bubbles (topmost in scene).
|
||||
pub const Z_SPEECH_BUBBLE: i32 = 201;
|
||||
|
||||
// =============================================================================
|
||||
// Bubble Dimensions
|
||||
// =============================================================================
|
||||
|
||||
/// Maximum width of speech bubbles (before text scaling).
|
||||
pub const BUBBLE_MAX_WIDTH_BASE: f64 = 200.0;
|
||||
|
||||
/// Padding inside speech bubbles (before text scaling).
|
||||
pub const BUBBLE_PADDING_BASE: f64 = 8.0;
|
||||
|
||||
/// Size of the bubble tail/pointer (before text scaling).
|
||||
pub const BUBBLE_TAIL_SIZE_BASE: f64 = 8.0;
|
||||
|
||||
/// Gap between avatar and bubble (before text scaling).
|
||||
pub const BUBBLE_GAP_BASE: f64 = 5.0;
|
||||
|
||||
/// Border radius of speech bubbles (before text scaling).
|
||||
pub const BUBBLE_BORDER_RADIUS_BASE: f64 = 8.0;
|
||||
|
||||
/// Font size for bubble text (before text scaling).
|
||||
pub const BUBBLE_FONT_SIZE_BASE: f64 = 12.0;
|
||||
|
||||
/// CSS border width for bubbles.
|
||||
pub const BUBBLE_BORDER_WIDTH: f64 = 2.0;
|
||||
|
||||
/// Line height multiplier for bubble text.
|
||||
pub const BUBBLE_LINE_HEIGHT: f64 = 1.5;
|
||||
|
||||
// =============================================================================
|
||||
// Label Dimensions
|
||||
// =============================================================================
|
||||
|
||||
/// Font size for username labels (before text scaling).
|
||||
pub const LABEL_FONT_SIZE_BASE: f64 = 12.0;
|
||||
|
||||
/// Vertical offset for username label below avatar content (before text scaling).
|
||||
pub const LABEL_Y_OFFSET_BASE: f64 = 15.0;
|
||||
|
|
@ -23,6 +23,7 @@ use uuid::Uuid;
|
|||
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene};
|
||||
|
||||
use super::avatar::{Avatar, ScreenBounds, SceneTransform, member_key};
|
||||
use super::constants::{Z_AVATAR_BASE, Z_AVATARS_CONTAINER, Z_CLICK_OVERLAY, Z_FADING_AVATAR, Z_LOOSE_PROP, Z_PROPS};
|
||||
#[cfg(feature = "hydrate")]
|
||||
use super::canvas_utils::hit_test_canvas;
|
||||
use super::chat_types::ActiveBubble;
|
||||
|
|
@ -488,7 +489,7 @@ pub fn RealmSceneViewer(
|
|||
style=move || canvas_style(0)
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="props-container absolute inset-0" style="z-index: 1; pointer-events: none;">
|
||||
<div class="props-container absolute inset-0" style=format!("z-index: {}; pointer-events: none;", Z_PROPS)>
|
||||
<Show when=move || scales_ready.get() fallback=|| ()>
|
||||
{move || {
|
||||
loose_props.get().into_iter().map(|prop| {
|
||||
|
|
@ -501,14 +502,14 @@ pub fn RealmSceneViewer(
|
|||
prop=prop_signal
|
||||
transform=scene_transform
|
||||
base_prop_size=prop_size
|
||||
z_index=5
|
||||
z_index=Z_LOOSE_PROP
|
||||
/>
|
||||
}
|
||||
}).collect_view()
|
||||
}}
|
||||
</Show>
|
||||
</div>
|
||||
<div class="avatars-container absolute inset-0" style="z-index: 2; pointer-events: none; overflow: visible;">
|
||||
<div class="avatars-container absolute inset-0" style=format!("z-index: {}; pointer-events: none; overflow: visible;", Z_AVATARS_CONTAINER)>
|
||||
<Show when=move || scales_ready.get() fallback=|| ()>
|
||||
{move || {
|
||||
member_keys.get().into_iter().map(|key| {
|
||||
|
|
@ -516,7 +517,7 @@ pub fn RealmSceneViewer(
|
|||
members_by_key.get().get(&key).map(|(_, m)| m.clone()).expect("member key should exist")
|
||||
});
|
||||
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) + Z_AVATAR_BASE).unwrap_or(Z_AVATAR_BASE)
|
||||
});
|
||||
let z = z_index_signal.get_untracked();
|
||||
// Derive bubble signal for this member
|
||||
|
|
@ -557,7 +558,7 @@ pub fn RealmSceneViewer(
|
|||
member=member_signal
|
||||
transform=scene_transform
|
||||
prop_size=prop_size
|
||||
z_index=5
|
||||
z_index=Z_FADING_AVATAR
|
||||
text_em_size=text_em_size
|
||||
opacity=opacity
|
||||
screen_bounds=screen_bounds
|
||||
|
|
@ -570,7 +571,7 @@ pub fn RealmSceneViewer(
|
|||
</div>
|
||||
<div
|
||||
class="click-overlay absolute inset-0"
|
||||
style="z-index: 5; cursor: pointer;"
|
||||
style=format!("z-index: {}; cursor: pointer;", Z_CLICK_OVERLAY)
|
||||
aria-label=format!("Scene: {}", scene_name)
|
||||
role="img"
|
||||
on:click=move |ev| {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue