fix: visual clipping of avatar and speach bubbles
This commit is contained in:
parent
22cc0fdc38
commit
fe1c1d3655
2 changed files with 301 additions and 16 deletions
|
|
@ -14,6 +14,28 @@ use super::chat_types::{emotion_bubble_colors, ActiveBubble};
|
||||||
/// 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 {
|
||||||
|
|
@ -86,6 +108,89 @@ impl ContentBounds {
|
||||||
fn empty_top_rows(&self) -> usize {
|
fn empty_top_rows(&self) -> usize {
|
||||||
self.min_row
|
self.min_row
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Width of actual content in pixels.
|
||||||
|
fn content_width(&self, cell_size: f64) -> f64 {
|
||||||
|
(self.max_col - self.min_col + 1) as f64 * cell_size
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Height of actual content in pixels.
|
||||||
|
fn content_height(&self, cell_size: f64) -> f64 {
|
||||||
|
(self.max_row - self.min_row + 1) as f64 * cell_size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computed boundaries for visual clamping in screen space.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct ScreenBoundaries {
|
||||||
|
/// Left edge of drawable area (= offset_x)
|
||||||
|
min_x: f64,
|
||||||
|
/// Right edge (= offset_x + scene_width * scale_x)
|
||||||
|
max_x: f64,
|
||||||
|
/// Top edge (= offset_y)
|
||||||
|
min_y: f64,
|
||||||
|
/// Bottom edge (= offset_y + scene_height * scale_y)
|
||||||
|
max_y: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScreenBoundaries {
|
||||||
|
fn from_transform(
|
||||||
|
scene_width: f64,
|
||||||
|
scene_height: f64,
|
||||||
|
scale_x: f64,
|
||||||
|
scale_y: f64,
|
||||||
|
offset_x: f64,
|
||||||
|
offset_y: f64,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
min_x: offset_x,
|
||||||
|
max_x: offset_x + (scene_width * scale_x),
|
||||||
|
min_y: offset_y,
|
||||||
|
max_y: offset_y + (scene_height * scale_y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamp avatar center so visual bounds stay within screen boundaries.
|
||||||
|
fn clamp_avatar_center(
|
||||||
|
&self,
|
||||||
|
center_x: f64,
|
||||||
|
center_y: f64,
|
||||||
|
half_width: f64,
|
||||||
|
half_height: f64,
|
||||||
|
) -> (f64, f64) {
|
||||||
|
let clamped_x = center_x.max(self.min_x + half_width).min(self.max_x - half_width);
|
||||||
|
let clamped_y = center_y.max(self.min_y + half_height).min(self.max_y - half_height);
|
||||||
|
(clamped_x, clamped_y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a unique key for a member (for Leptos For keying).
|
/// Get a unique key for a member (for Leptos For keying).
|
||||||
|
|
@ -123,6 +228,12 @@ pub fn AvatarCanvas(
|
||||||
/// 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).
|
||||||
#[prop(default = 1.0)]
|
#[prop(default = 1.0)]
|
||||||
opacity: f64,
|
opacity: f64,
|
||||||
|
/// Scene width in scene coordinates (for boundary calculations).
|
||||||
|
#[prop(optional)]
|
||||||
|
scene_width: Option<Signal<f64>>,
|
||||||
|
/// Scene height in scene coordinates (for boundary calculations).
|
||||||
|
#[prop(optional)]
|
||||||
|
scene_height: Option<Signal<f64>>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
let canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
||||||
|
|
||||||
|
|
@ -156,9 +267,31 @@ pub fn AvatarCanvas(
|
||||||
// Avatar is a 3x3 grid of props, each prop is prop_size
|
// Avatar is a 3x3 grid of props, each prop is prop_size
|
||||||
let avatar_size = ps * 3.0;
|
let avatar_size = ps * 3.0;
|
||||||
|
|
||||||
// Calculate canvas position from scene coordinates, adjusted for content bounds
|
// Get scene dimensions (use large defaults if not provided)
|
||||||
let canvas_x = m.member.position_x * sx + ox - avatar_size / 2.0 - x_content_offset;
|
let sw = scene_width.map(|s| s.get()).unwrap_or(10000.0);
|
||||||
let canvas_y = m.member.position_y * sy + oy - avatar_size / 2.0 - y_content_offset;
|
let sh = scene_height.map(|s| s.get()).unwrap_or(10000.0);
|
||||||
|
|
||||||
|
// Compute screen boundaries for avatar clamping
|
||||||
|
let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy);
|
||||||
|
|
||||||
|
// Calculate raw avatar screen position
|
||||||
|
let avatar_screen_x = m.member.position_x * sx + ox;
|
||||||
|
let avatar_screen_y = m.member.position_y * sy + oy;
|
||||||
|
|
||||||
|
// Clamp avatar center so visual bounds stay within screen boundaries
|
||||||
|
// Use actual content extent rather than full 3x3 grid
|
||||||
|
let content_half_width = content_bounds.content_width(ps) / 2.0;
|
||||||
|
let content_half_height = content_bounds.content_height(ps) / 2.0;
|
||||||
|
let (clamped_x, clamped_y) = boundaries.clamp_avatar_center(
|
||||||
|
avatar_screen_x,
|
||||||
|
avatar_screen_y,
|
||||||
|
content_half_width,
|
||||||
|
content_half_height,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate canvas position from clamped screen coordinates, adjusted for content bounds
|
||||||
|
let canvas_x = clamped_x - avatar_size / 2.0 - x_content_offset;
|
||||||
|
let canvas_y = clamped_y - avatar_size / 2.0 - y_content_offset;
|
||||||
|
|
||||||
// Fixed text dimensions (independent of prop_size/zoom)
|
// Fixed text dimensions (independent of prop_size/zoom)
|
||||||
let text_scale = te * BASE_TEXT_SCALE;
|
let text_scale = te * BASE_TEXT_SCALE;
|
||||||
|
|
@ -170,12 +303,40 @@ pub fn AvatarCanvas(
|
||||||
let fixed_name_height = 20.0 * text_scale;
|
let fixed_name_height = 20.0 * text_scale;
|
||||||
let fixed_text_width = 200.0 * text_scale;
|
let fixed_text_width = 200.0 * text_scale;
|
||||||
|
|
||||||
// Canvas must fit both avatar AND fixed-size text
|
// Determine bubble position based on available space above avatar
|
||||||
|
// This must match the logic in the Effect that draws the bubble
|
||||||
|
let bubble_position = if bubble.is_some() {
|
||||||
|
// Use clamped avatar screen position for bubble calculation
|
||||||
|
let avatar_half_height = avatar_size / 2.0 + y_content_offset;
|
||||||
|
|
||||||
|
// Calculate bubble height using actual content (includes tail + gap)
|
||||||
|
let estimated_bubble_height = bubble.as_ref()
|
||||||
|
.map(|b| estimate_bubble_height(&b.message.content, text_scale))
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
|
||||||
|
determine_bubble_position(
|
||||||
|
clamped_y,
|
||||||
|
avatar_half_height,
|
||||||
|
estimated_bubble_height,
|
||||||
|
0.0, // Already included in estimate_bubble_height
|
||||||
|
0.0, // Already included in estimate_bubble_height
|
||||||
|
boundaries.min_y,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
BubblePosition::Above // Default when no bubble
|
||||||
|
};
|
||||||
|
|
||||||
|
// Canvas must fit avatar, text, AND bubble (positioned based on bubble location)
|
||||||
let canvas_width = avatar_size.max(fixed_text_width);
|
let canvas_width = avatar_size.max(fixed_text_width);
|
||||||
let canvas_height = avatar_size + fixed_bubble_height + fixed_name_height;
|
let canvas_height = avatar_size + fixed_bubble_height + fixed_name_height;
|
||||||
|
|
||||||
// Adjust position to account for extra space above avatar
|
// Adjust position based on bubble position
|
||||||
let adjusted_y = canvas_y - fixed_bubble_height;
|
// When bubble is above: offset canvas upward to make room at top
|
||||||
|
// When bubble is below: no upward offset, bubble goes below avatar
|
||||||
|
let adjusted_y = match bubble_position {
|
||||||
|
BubblePosition::Above => canvas_y - fixed_bubble_height,
|
||||||
|
BubblePosition::Below => canvas_y,
|
||||||
|
};
|
||||||
|
|
||||||
format!(
|
format!(
|
||||||
"position: absolute; \
|
"position: absolute; \
|
||||||
|
|
@ -245,6 +406,42 @@ pub fn AvatarCanvas(
|
||||||
let fixed_name_height = 20.0 * text_scale;
|
let fixed_name_height = 20.0 * text_scale;
|
||||||
let fixed_text_width = 200.0 * text_scale;
|
let fixed_text_width = 200.0 * text_scale;
|
||||||
|
|
||||||
|
// Determine bubble position early so we can position the avatar correctly
|
||||||
|
let y_content_offset = content_bounds.y_offset(ps);
|
||||||
|
let bubble_position = if bubble.is_some() {
|
||||||
|
let sx = scale_x.get();
|
||||||
|
let sy = scale_y.get();
|
||||||
|
let ox = offset_x.get();
|
||||||
|
let oy = offset_y.get();
|
||||||
|
|
||||||
|
// Get scene dimensions (use large defaults if not provided)
|
||||||
|
let sw = scene_width.map(|s| s.get()).unwrap_or(10000.0);
|
||||||
|
let sh = scene_height.map(|s| s.get()).unwrap_or(10000.0);
|
||||||
|
|
||||||
|
// Compute screen boundaries
|
||||||
|
let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy);
|
||||||
|
|
||||||
|
// Calculate avatar's screen position
|
||||||
|
let avatar_screen_y = m.member.position_y * sy + oy;
|
||||||
|
let avatar_half_height = avatar_size / 2.0 + y_content_offset;
|
||||||
|
|
||||||
|
// Calculate bubble height using actual content (includes tail + gap)
|
||||||
|
let estimated_bubble_height = bubble.as_ref()
|
||||||
|
.map(|b| estimate_bubble_height(&b.message.content, text_scale))
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
|
||||||
|
determine_bubble_position(
|
||||||
|
avatar_screen_y,
|
||||||
|
avatar_half_height,
|
||||||
|
estimated_bubble_height,
|
||||||
|
0.0, // Already included in estimate_bubble_height
|
||||||
|
0.0, // Already included in estimate_bubble_height
|
||||||
|
boundaries.min_y,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
BubblePosition::Above
|
||||||
|
};
|
||||||
|
|
||||||
let canvas_width = avatar_size.max(fixed_text_width);
|
let canvas_width = avatar_size.max(fixed_text_width);
|
||||||
let canvas_height = avatar_size + fixed_bubble_height + fixed_name_height;
|
let canvas_height = avatar_size + fixed_bubble_height + fixed_name_height;
|
||||||
|
|
||||||
|
|
@ -263,8 +460,13 @@ pub fn AvatarCanvas(
|
||||||
ctx.clear_rect(0.0, 0.0, canvas_width, canvas_height);
|
ctx.clear_rect(0.0, 0.0, canvas_width, canvas_height);
|
||||||
|
|
||||||
// Avatar center position within the canvas
|
// Avatar center position within the canvas
|
||||||
|
// When bubble is above: avatar is below the bubble space
|
||||||
|
// When bubble is below: avatar is at the top, bubble space is below
|
||||||
let avatar_cx = canvas_width / 2.0;
|
let avatar_cx = canvas_width / 2.0;
|
||||||
let avatar_cy = fixed_bubble_height + avatar_size / 2.0;
|
let avatar_cy = match bubble_position {
|
||||||
|
BubblePosition::Above => fixed_bubble_height + avatar_size / 2.0,
|
||||||
|
BubblePosition::Below => avatar_size / 2.0,
|
||||||
|
};
|
||||||
|
|
||||||
// Helper to load and draw an image
|
// Helper to load and draw an image
|
||||||
// Images are cached; when loaded, triggers a redraw via signal
|
// Images are cached; when loaded, triggers a redraw via signal
|
||||||
|
|
@ -393,14 +595,32 @@ pub fn AvatarCanvas(
|
||||||
if b.expires_at >= current_time {
|
if b.expires_at >= current_time {
|
||||||
let content_x_offset = content_bounds.x_offset(cell_size);
|
let content_x_offset = content_bounds.x_offset(cell_size);
|
||||||
let content_top_adjustment = content_bounds.empty_top_rows() as f64 * cell_size;
|
let content_top_adjustment = content_bounds.empty_top_rows() as f64 * cell_size;
|
||||||
|
|
||||||
|
// Get screen boundaries for horizontal clamping
|
||||||
|
let sx = scale_x.get();
|
||||||
|
let sy = scale_y.get();
|
||||||
|
let ox = offset_x.get();
|
||||||
|
let oy = offset_y.get();
|
||||||
|
let sw = scene_width.map(|s| s.get()).unwrap_or(10000.0);
|
||||||
|
let sh = scene_height.map(|s| s.get()).unwrap_or(10000.0);
|
||||||
|
let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy);
|
||||||
|
|
||||||
|
// Avatar top and bottom Y within the canvas
|
||||||
|
let avatar_top_y = avatar_cy - avatar_size / 2.0;
|
||||||
|
let avatar_bottom_y = avatar_cy + avatar_size / 2.0;
|
||||||
|
|
||||||
|
// Use the pre-calculated bubble_position from earlier
|
||||||
draw_bubble(
|
draw_bubble(
|
||||||
&ctx,
|
&ctx,
|
||||||
b,
|
b,
|
||||||
avatar_cx,
|
avatar_cx,
|
||||||
avatar_cy - avatar_size / 2.0,
|
avatar_top_y,
|
||||||
|
avatar_bottom_y,
|
||||||
content_x_offset,
|
content_x_offset,
|
||||||
content_top_adjustment,
|
content_top_adjustment,
|
||||||
te,
|
te,
|
||||||
|
bubble_position,
|
||||||
|
Some(&boundaries),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -434,16 +654,31 @@ fn normalize_asset_path(path: &str) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draw a speech bubble above the avatar.
|
/// Draw a speech bubble relative to the avatar with boundary awareness.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `ctx` - Canvas rendering context
|
||||||
|
/// * `bubble` - The active bubble data
|
||||||
|
/// * `center_x` - Avatar center X in canvas coordinates
|
||||||
|
/// * `top_y` - Avatar top edge Y in canvas coordinates
|
||||||
|
/// * `bottom_y` - Avatar bottom edge Y in canvas coordinates
|
||||||
|
/// * `content_x_offset` - X offset to center on content
|
||||||
|
/// * `content_top_adjustment` - Y adjustment for empty top rows
|
||||||
|
/// * `text_em_size` - Text size multiplier
|
||||||
|
/// * `position` - Whether to render above or below the avatar
|
||||||
|
/// * `boundaries` - Screen boundaries for horizontal clamping (optional)
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
fn draw_bubble(
|
fn draw_bubble(
|
||||||
ctx: &web_sys::CanvasRenderingContext2d,
|
ctx: &web_sys::CanvasRenderingContext2d,
|
||||||
bubble: &ActiveBubble,
|
bubble: &ActiveBubble,
|
||||||
center_x: f64,
|
center_x: f64,
|
||||||
top_y: f64,
|
top_y: f64,
|
||||||
|
bottom_y: f64,
|
||||||
content_x_offset: f64,
|
content_x_offset: f64,
|
||||||
content_top_adjustment: f64,
|
content_top_adjustment: f64,
|
||||||
text_em_size: f64,
|
text_em_size: f64,
|
||||||
|
position: BubblePosition,
|
||||||
|
boundaries: Option<&ScreenBoundaries>,
|
||||||
) {
|
) {
|
||||||
// Text scale independent of zoom - only affected by user's text_em_size setting
|
// Text scale independent of zoom - only affected by user's text_em_size setting
|
||||||
let text_scale = text_em_size * BASE_TEXT_SCALE;
|
let text_scale = text_em_size * BASE_TEXT_SCALE;
|
||||||
|
|
@ -453,6 +688,7 @@ fn draw_bubble(
|
||||||
let line_height = 16.0 * text_scale;
|
let line_height = 16.0 * text_scale;
|
||||||
let tail_size = 8.0 * text_scale;
|
let tail_size = 8.0 * text_scale;
|
||||||
let border_radius = 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);
|
let (bg_color, border_color, text_color) = emotion_bubble_colors(&bubble.message.emotion);
|
||||||
|
|
||||||
|
|
@ -474,11 +710,41 @@ fn draw_bubble(
|
||||||
|
|
||||||
// Center bubble horizontally on content (not grid center)
|
// Center bubble horizontally on content (not grid center)
|
||||||
let content_center_x = center_x + content_x_offset;
|
let content_center_x = center_x + content_x_offset;
|
||||||
let bubble_x = content_center_x - bubble_width / 2.0;
|
|
||||||
|
|
||||||
|
// Calculate initial bubble X position (centered on content)
|
||||||
|
let mut bubble_x = content_center_x - bubble_width / 2.0;
|
||||||
|
|
||||||
|
// Clamp bubble horizontally to stay within drawable area
|
||||||
|
if let Some(bounds) = boundaries {
|
||||||
|
let bubble_left = bubble_x;
|
||||||
|
let bubble_right = bubble_x + bubble_width;
|
||||||
|
|
||||||
|
if bubble_left < bounds.min_x {
|
||||||
|
// Shift right to stay within left edge
|
||||||
|
bubble_x = bounds.min_x;
|
||||||
|
} else if bubble_right > bounds.max_x {
|
||||||
|
// Shift left to stay within right edge
|
||||||
|
bubble_x = bounds.max_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 position {
|
||||||
|
BubblePosition::Above => {
|
||||||
// Position vertically closer to content when top rows are empty
|
// Position vertically closer to content when top rows are empty
|
||||||
let adjusted_top_y = top_y + content_top_adjustment;
|
let adjusted_top_y = top_y + content_top_adjustment;
|
||||||
let bubble_y = adjusted_top_y - bubble_height - tail_size - 5.0 * text_scale;
|
adjusted_top_y - bubble_height - tail_size - gap
|
||||||
|
}
|
||||||
|
BubblePosition::Below => {
|
||||||
|
// Position below avatar with gap for tail
|
||||||
|
bottom_y + tail_size + gap
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Draw bubble background
|
// Draw bubble background
|
||||||
draw_rounded_rect(ctx, bubble_x, bubble_y, bubble_width, bubble_height, border_radius);
|
draw_rounded_rect(ctx, bubble_x, bubble_y, bubble_width, bubble_height, border_radius);
|
||||||
|
|
@ -490,9 +756,20 @@ fn draw_bubble(
|
||||||
|
|
||||||
// Draw tail pointing to content center
|
// Draw tail pointing to content center
|
||||||
ctx.begin_path();
|
ctx.begin_path();
|
||||||
ctx.move_to(content_center_x - tail_size, bubble_y + bubble_height);
|
match position {
|
||||||
ctx.line_to(content_center_x, bubble_y + bubble_height + tail_size);
|
BubblePosition::Above => {
|
||||||
ctx.line_to(content_center_x + tail_size, bubble_y + bubble_height);
|
// 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.close_path();
|
||||||
ctx.set_fill_style_str(bg_color);
|
ctx.set_fill_style_str(bg_color);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
|
||||||
|
|
@ -854,6 +854,10 @@ pub fn RealmSceneViewer(
|
||||||
let offset_x_signal = Signal::derive(move || offset_x.get());
|
let offset_x_signal = Signal::derive(move || offset_x.get());
|
||||||
let offset_y_signal = Signal::derive(move || offset_y.get());
|
let offset_y_signal = Signal::derive(move || offset_y.get());
|
||||||
|
|
||||||
|
// Create signals for scene dimensions to pass to AvatarCanvas for boundary awareness
|
||||||
|
let scene_width_signal = Signal::derive(move || scene_width_f);
|
||||||
|
let scene_height_signal = Signal::derive(move || scene_height_f);
|
||||||
|
|
||||||
// Create a map of members by key for efficient lookup
|
// Create a map of members by key for efficient lookup
|
||||||
let members_by_key = Signal::derive(move || {
|
let members_by_key = Signal::derive(move || {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
@ -942,6 +946,8 @@ pub fn RealmSceneViewer(
|
||||||
z_index=z
|
z_index=z
|
||||||
active_bubble=bubble_signal
|
active_bubble=bubble_signal
|
||||||
text_em_size=text_em_size
|
text_em_size=text_em_size
|
||||||
|
scene_width=scene_width_signal
|
||||||
|
scene_height=scene_height_signal
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}).collect_view()
|
}).collect_view()
|
||||||
|
|
@ -982,6 +988,8 @@ pub fn RealmSceneViewer(
|
||||||
active_bubble=bubble_signal
|
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_height=scene_height_signal
|
||||||
/>
|
/>
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue