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.
|
//! Reusable UI components.
|
||||||
|
|
||||||
|
pub mod constants;
|
||||||
pub mod avatar;
|
pub mod avatar;
|
||||||
pub mod avatar_editor;
|
pub mod avatar_editor;
|
||||||
pub mod canvas_utils;
|
pub mod canvas_utils;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,12 @@ use uuid::Uuid;
|
||||||
use chattyness_db::models::{ChannelMemberWithAvatar, EmotionState};
|
use chattyness_db::models::{ChannelMemberWithAvatar, EmotionState};
|
||||||
|
|
||||||
use super::chat_types::{ActiveBubble, emotion_bubble_colors};
|
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")]
|
#[cfg(feature = "hydrate")]
|
||||||
pub use super::canvas_utils::hit_test_canvas;
|
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.
|
/// 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.
|
||||||
|
|
@ -232,6 +239,10 @@ impl ContentBounds {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Canvas Layout - Unified layout context
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
/// 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,
|
||||||
|
|
@ -240,39 +251,39 @@ impl ContentBounds {
|
||||||
/// - 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
|
||||||
/// - Username label and speech bubble positioning
|
/// - Username label and speech bubble positioning
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
struct CanvasLayout {
|
pub struct CanvasLayout {
|
||||||
// Core dimensions
|
// Core dimensions
|
||||||
prop_size: f64,
|
pub prop_size: f64,
|
||||||
avatar_size: f64,
|
pub avatar_size: f64,
|
||||||
|
|
||||||
// Content offset from grid center
|
// Content offset from grid center
|
||||||
content_x_offset: f64,
|
pub content_x_offset: f64,
|
||||||
|
|
||||||
// Text scaling
|
// Text scaling
|
||||||
text_scale: f64,
|
pub text_scale: f64,
|
||||||
|
|
||||||
// Canvas dimensions
|
// Canvas dimensions
|
||||||
canvas_width: f64,
|
pub canvas_width: f64,
|
||||||
canvas_height: f64,
|
pub canvas_height: f64,
|
||||||
|
|
||||||
// Canvas position in screen space
|
// Canvas position in screen space
|
||||||
canvas_screen_x: f64,
|
pub canvas_screen_x: f64,
|
||||||
canvas_screen_y: f64,
|
pub canvas_screen_y: f64,
|
||||||
|
|
||||||
// Avatar center within canvas (canvas-local coordinates)
|
// Avatar center within canvas (canvas-local coordinates)
|
||||||
avatar_cx: f64,
|
pub avatar_cx: f64,
|
||||||
avatar_cy: f64,
|
pub avatar_cy: f64,
|
||||||
|
|
||||||
// Content bounds info for positioning
|
// Content bounds info for positioning
|
||||||
empty_top_rows: usize,
|
pub empty_top_rows: usize,
|
||||||
empty_bottom_rows: usize,
|
pub empty_bottom_rows: usize,
|
||||||
|
|
||||||
// Scene bounds for bubble clamping (screen coordinates)
|
// Scene bounds for bubble clamping (screen coordinates)
|
||||||
scene_min_x: f64,
|
pub scene_min_x: f64,
|
||||||
scene_max_x: f64,
|
pub scene_max_x: f64,
|
||||||
scene_min_y: f64,
|
pub scene_min_y: f64,
|
||||||
scene_max_y: f64,
|
pub scene_max_y: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CanvasLayout {
|
impl CanvasLayout {
|
||||||
|
|
@ -338,19 +349,29 @@ impl CanvasLayout {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Content center X in canvas-local coordinates.
|
/// 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
|
self.avatar_cx + self.content_x_offset
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Top of avatar in canvas-local coordinates.
|
/// 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
|
self.avatar_cy - self.avatar_size / 2.0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bottom of avatar in canvas-local coordinates.
|
/// 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
|
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).
|
/// 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
|
m.member.user_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Bubble Position
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
/// Position of speech bubble relative to avatar.
|
/// Position of speech bubble relative to avatar.
|
||||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
#[derive(Clone, Copy, PartialEq, Debug, Default)]
|
||||||
enum BubblePosition {
|
enum BubblePosition {
|
||||||
|
#[default]
|
||||||
Above,
|
Above,
|
||||||
Below,
|
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.
|
/// Individual avatar component.
|
||||||
///
|
///
|
||||||
/// Renders a single avatar with:
|
/// 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
|
// Disable pointer-events when fading (opacity < 1.0) so fading avatars aren't clickable
|
||||||
let pointer_events = if opacity < 1.0 { "none" } else { "auto" };
|
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
|
// 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_width, set_bubble_measured_width) = signal(0.0_f64);
|
||||||
let (bubble_measured_height, set_bubble_measured_height) = 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_is_measured, set_bubble_is_measured) = signal(false);
|
||||||
let (bubble_position, set_bubble_position) = signal(BubblePosition::Above);
|
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 m = member.get();
|
||||||
let ps = prop_size.get();
|
let ps = prop_size.get();
|
||||||
let t = transform.get();
|
let t = transform.get();
|
||||||
|
|
@ -439,11 +587,13 @@ pub fn Avatar(
|
||||||
avatar_screen_y,
|
avatar_screen_y,
|
||||||
boundaries,
|
boundaries,
|
||||||
)
|
)
|
||||||
};
|
});
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
// Wrapper style (positions the entire avatar container)
|
// Wrapper style (positions the entire avatar container)
|
||||||
|
// ==========================================================================
|
||||||
let wrapper_style = move || {
|
let wrapper_style = move || {
|
||||||
let layout = compute_layout();
|
let layout = layout_memo.get();
|
||||||
format!(
|
format!(
|
||||||
"position: absolute; \
|
"position: absolute; \
|
||||||
left: 0; top: 0; \
|
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)
|
// Label style (positioned relative to wrapper, below avatar)
|
||||||
|
// ==========================================================================
|
||||||
let label_style = move || {
|
let label_style = move || {
|
||||||
let layout = compute_layout();
|
let layout = layout_memo.get();
|
||||||
let font_size = 12.0 * layout.text_scale;
|
let font_size = LABEL_FONT_SIZE_BASE * layout.text_scale;
|
||||||
// Position at content center X, below content bottom
|
// Position at content center X, below content bottom
|
||||||
let label_x = layout.content_center_x();
|
let label_x = layout.content_center_x();
|
||||||
let label_y = layout.avatar_bottom_y()
|
let label_y = layout.content_bottom_y() + LABEL_Y_OFFSET_BASE * layout.text_scale;
|
||||||
- layout.empty_bottom_rows as f64 * layout.prop_size
|
|
||||||
+ 15.0 * layout.text_scale;
|
|
||||||
|
|
||||||
format!(
|
format!(
|
||||||
"position: absolute; \
|
"position: absolute; \
|
||||||
|
|
@ -484,8 +631,8 @@ pub fn Avatar(
|
||||||
transform: translateX(-50%); \
|
transform: translateX(-50%); \
|
||||||
font-size: {}px; \
|
font-size: {}px; \
|
||||||
white-space: nowrap; \
|
white-space: nowrap; \
|
||||||
z-index: 99998;",
|
z-index: {};",
|
||||||
label_x, label_y, font_size
|
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();
|
let display_name = move || member.get().member.display_name.clone();
|
||||||
|
|
||||||
// Compute data-member-id reactively
|
// Compute data-member-id reactively
|
||||||
let data_member_id = move || {
|
let data_member_id = move || member.get().member.user_id.to_string();
|
||||||
let m = member.get();
|
|
||||||
m.member.user_id.to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Store references for the canvas drawing effect
|
// ==========================================================================
|
||||||
|
// Canvas drawing effect
|
||||||
|
// ==========================================================================
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
{
|
{
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
|
@ -521,40 +667,12 @@ pub fn Avatar(
|
||||||
|
|
||||||
// Get current values from signals
|
// Get current values from signals
|
||||||
let m = member.get();
|
let m = member.get();
|
||||||
|
let layout = layout_memo.get();
|
||||||
let ps = prop_size.get();
|
|
||||||
let te = text_em_size.get();
|
|
||||||
|
|
||||||
let Some(canvas) = canvas_ref.get() else {
|
let Some(canvas) = canvas_ref.get() else {
|
||||||
return;
|
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;
|
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||||
|
|
||||||
// Set canvas resolution from layout
|
// 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
|
// Track the message ID we've measured to detect new messages
|
||||||
let (last_measured_message_id, set_last_measured_message_id) =
|
let (last_measured_message_id, set_last_measured_message_id) =
|
||||||
signal(Option::<uuid::Uuid>::None);
|
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
|
// Bubble visibility and style
|
||||||
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")]
|
#[cfg(feature = "hydrate")]
|
||||||
let is_bubble_visible = move || {
|
let is_bubble_visible = move || {
|
||||||
let Some(bubble_signal) = active_bubble else {
|
let Some(bubble_signal) = active_bubble else {
|
||||||
|
|
@ -851,11 +906,8 @@ pub fn Avatar(
|
||||||
now < bubble.expires_at
|
now < bubble.expires_at
|
||||||
};
|
};
|
||||||
#[cfg(not(feature = "hydrate"))]
|
#[cfg(not(feature = "hydrate"))]
|
||||||
let is_bubble_visible = move || {
|
let is_bubble_visible = move || active_bubble.map(|s| s.get().is_some()).unwrap_or(false);
|
||||||
active_bubble.map(|s| s.get().is_some()).unwrap_or(false)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Bubble style
|
|
||||||
let bubble_style = move || {
|
let bubble_style = move || {
|
||||||
let Some(bubble_signal) = active_bubble else {
|
let Some(bubble_signal) = active_bubble else {
|
||||||
return "display: none;".to_string();
|
return "display: none;".to_string();
|
||||||
|
|
@ -863,29 +915,25 @@ pub fn Avatar(
|
||||||
let Some(bubble) = bubble_signal.get() else {
|
let Some(bubble) = bubble_signal.get() else {
|
||||||
return "display: none;".to_string();
|
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) =
|
let (bg_color, border_color, text_color) =
|
||||||
emotion_bubble_colors(&bubble.message.emotion);
|
emotion_bubble_colors(&bubble.message.emotion);
|
||||||
|
|
||||||
let max_width = 200.0 * layout.text_scale;
|
let max_width = BUBBLE_MAX_WIDTH_BASE * layout.text_scale;
|
||||||
let padding = 8.0 * layout.text_scale;
|
let font_size = BUBBLE_FONT_SIZE_BASE * layout.text_scale;
|
||||||
let tail_size = 8.0 * layout.text_scale;
|
let padding = BUBBLE_PADDING_BASE * 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 measured = bubble_is_measured.get();
|
||||||
let m_height = bubble_measured_height.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
|
// 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 =
|
||||||
let single_line_height = font_size * 1.5 + padding * 2.0 + border_width * 2.0;
|
font_size * BUBBLE_LINE_HEIGHT + padding * 2.0 + BUBBLE_BORDER_WIDTH * 2.0;
|
||||||
let is_multiline = measured && m_height > single_line_height * 1.3;
|
let is_multiline = measured && m_height > single_line_height * 1.3;
|
||||||
|
|
||||||
// Width strategy: fit-content for single line, explicit max-width for multi-line
|
// 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 {
|
let width_style = if is_multiline {
|
||||||
format!("width: {}px;", max_width)
|
format!("width: {}px;", max_width)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -894,10 +942,12 @@ pub fn Avatar(
|
||||||
|
|
||||||
// Get reactive position (recalculates when avatar moves)
|
// Get reactive position (recalculates when avatar moves)
|
||||||
let (position_type, final_left, final_top, tail_offset, position) =
|
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)
|
("absolute", left, top, tail, pos)
|
||||||
} else {
|
} 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)
|
("fixed", -1000.0, -1000.0, 50.0, BubblePosition::Above)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -907,36 +957,18 @@ pub fn Avatar(
|
||||||
// Store position for bubble_class to use
|
// Store position for bubble_class to use
|
||||||
set_bubble_position.set(position);
|
set_bubble_position.set(position);
|
||||||
|
|
||||||
format!(
|
build_bubble_style(&BubbleStyleParams {
|
||||||
"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,
|
position_type,
|
||||||
final_left,
|
left: final_left,
|
||||||
final_top,
|
top: final_top,
|
||||||
width_style,
|
width_style,
|
||||||
bubble_opacity,
|
opacity: bubble_opacity,
|
||||||
bg_color,
|
bg_color,
|
||||||
border_color,
|
border_color,
|
||||||
text_color,
|
text_color,
|
||||||
tail_offset,
|
tail_offset,
|
||||||
font_size,
|
text_scale: layout.text_scale,
|
||||||
padding,
|
})
|
||||||
tail_size,
|
|
||||||
8.0 * layout.text_scale,
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let bubble_class = move || {
|
let bubble_class = move || {
|
||||||
|
|
@ -961,11 +993,15 @@ pub fn Avatar(
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// View
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="avatar-wrapper" style=wrapper_style data-member-id=data_member_id>
|
<div class="avatar-wrapper" style=wrapper_style data-member-id=data_member_id>
|
||||||
<canvas
|
<canvas
|
||||||
node_ref=canvas_ref
|
node_ref=canvas_ref
|
||||||
style=canvas_style
|
style="width: 100%; height: 100%;"
|
||||||
/>
|
/>
|
||||||
<div class="username-label" style=label_style>
|
<div class="username-label" style=label_style>
|
||||||
{display_name}
|
{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 chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene};
|
||||||
|
|
||||||
use super::avatar::{Avatar, ScreenBounds, SceneTransform, member_key};
|
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")]
|
#[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;
|
||||||
|
|
@ -488,7 +489,7 @@ pub fn RealmSceneViewer(
|
||||||
style=move || canvas_style(0)
|
style=move || canvas_style(0)
|
||||||
aria-hidden="true"
|
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=|| ()>
|
<Show when=move || scales_ready.get() fallback=|| ()>
|
||||||
{move || {
|
{move || {
|
||||||
loose_props.get().into_iter().map(|prop| {
|
loose_props.get().into_iter().map(|prop| {
|
||||||
|
|
@ -501,14 +502,14 @@ pub fn RealmSceneViewer(
|
||||||
prop=prop_signal
|
prop=prop_signal
|
||||||
transform=scene_transform
|
transform=scene_transform
|
||||||
base_prop_size=prop_size
|
base_prop_size=prop_size
|
||||||
z_index=5
|
z_index=Z_LOOSE_PROP
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}).collect_view()
|
}).collect_view()
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</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=|| ()>
|
<Show when=move || scales_ready.get() fallback=|| ()>
|
||||||
{move || {
|
{move || {
|
||||||
member_keys.get().into_iter().map(|key| {
|
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")
|
members_by_key.get().get(&key).map(|(_, m)| m.clone()).expect("member key should exist")
|
||||||
});
|
});
|
||||||
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) + Z_AVATAR_BASE).unwrap_or(Z_AVATAR_BASE)
|
||||||
});
|
});
|
||||||
let z = z_index_signal.get_untracked();
|
let z = z_index_signal.get_untracked();
|
||||||
// Derive bubble signal for this member
|
// Derive bubble signal for this member
|
||||||
|
|
@ -557,7 +558,7 @@ pub fn RealmSceneViewer(
|
||||||
member=member_signal
|
member=member_signal
|
||||||
transform=scene_transform
|
transform=scene_transform
|
||||||
prop_size=prop_size
|
prop_size=prop_size
|
||||||
z_index=5
|
z_index=Z_FADING_AVATAR
|
||||||
text_em_size=text_em_size
|
text_em_size=text_em_size
|
||||||
opacity=opacity
|
opacity=opacity
|
||||||
screen_bounds=screen_bounds
|
screen_bounds=screen_bounds
|
||||||
|
|
@ -570,7 +571,7 @@ pub fn RealmSceneViewer(
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="click-overlay absolute inset-0"
|
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)
|
aria-label=format!("Scene: {}", scene_name)
|
||||||
role="img"
|
role="img"
|
||||||
on:click=move |ev| {
|
on:click=move |ev| {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue