fix: finally got prop scaling, text scaling, canvas scaling, and bubble drawing to work
This commit is contained in:
parent
af1c767f5f
commit
44b322371c
1 changed files with 298 additions and 244 deletions
|
|
@ -226,6 +226,235 @@ fn determine_bubble_position(
|
|||
}
|
||||
}
|
||||
|
||||
/// Unified layout context for avatar canvas rendering.
|
||||
///
|
||||
/// This struct computes all derived layout values once from the inputs,
|
||||
/// providing a single source of truth for:
|
||||
/// - Canvas dimensions and position
|
||||
/// - Avatar positioning within the canvas
|
||||
/// - Coordinate transformations between canvas-local and screen space
|
||||
/// - Bubble positioning and clamping
|
||||
///
|
||||
/// By centralizing these calculations, we avoid scattered, duplicated logic
|
||||
/// and ensure the style closure, Effect, and draw_bubble all use consistent values.
|
||||
#[derive(Clone, Copy)]
|
||||
#[allow(dead_code)] // Some fields kept for potential future use
|
||||
struct CanvasLayout {
|
||||
// Core dimensions
|
||||
prop_size: f64,
|
||||
avatar_size: f64,
|
||||
|
||||
// Content offset from grid center
|
||||
content_x_offset: f64,
|
||||
content_y_offset: f64,
|
||||
|
||||
// Text scaling
|
||||
text_scale: f64,
|
||||
bubble_max_width: f64,
|
||||
|
||||
// Canvas dimensions
|
||||
canvas_width: f64,
|
||||
canvas_height: f64,
|
||||
|
||||
// Canvas position in screen space
|
||||
canvas_screen_x: f64,
|
||||
canvas_screen_y: f64,
|
||||
|
||||
// Avatar center within canvas (canvas-local coordinates)
|
||||
avatar_cx: 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 {
|
||||
/// Create a new layout from all input parameters.
|
||||
fn new(
|
||||
content_bounds: &ContentBounds,
|
||||
prop_size: f64,
|
||||
text_em_size: f64,
|
||||
avatar_screen_x: f64,
|
||||
avatar_screen_y: f64,
|
||||
boundaries: ScreenBoundaries,
|
||||
has_bubble: bool,
|
||||
bubble_text: Option<&str>,
|
||||
) -> Self {
|
||||
let avatar_size = prop_size * 3.0;
|
||||
let text_scale = text_em_size * BASE_TEXT_SCALE;
|
||||
let bubble_max_width = 200.0 * text_scale;
|
||||
|
||||
// Content offsets from grid center
|
||||
let content_x_offset = content_bounds.x_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
|
||||
let content_half_width = content_bounds.content_width(prop_size) / 2.0;
|
||||
let content_half_height = content_bounds.content_height(prop_size) / 2.0;
|
||||
|
||||
// Clamp avatar so content stays within scene
|
||||
let (clamped_x, clamped_y) = boundaries.clamp_avatar_center(
|
||||
avatar_screen_x,
|
||||
avatar_screen_y,
|
||||
content_half_width,
|
||||
content_half_height,
|
||||
);
|
||||
|
||||
// Calculate bubble height and position
|
||||
let bubble_height_reserved = if has_bubble {
|
||||
(4.0 * 16.0 + 16.0 + 8.0 + 5.0) * text_scale
|
||||
} 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
|
||||
// The avatar grid center maps to canvas_width/2, but we need to account
|
||||
// for the content offset so the visible content aligns with clamped_x/y
|
||||
let canvas_x = clamped_x - avatar_size / 2.0 - content_x_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
|
||||
let avatar_cx = canvas_width / 2.0;
|
||||
let avatar_cy = match bubble_position {
|
||||
BubblePosition::Above => bubble_height_reserved + avatar_size / 2.0,
|
||||
BubblePosition::Below => avatar_size / 2.0,
|
||||
};
|
||||
|
||||
Self {
|
||||
prop_size,
|
||||
avatar_size,
|
||||
content_x_offset,
|
||||
content_y_offset,
|
||||
text_scale,
|
||||
bubble_max_width,
|
||||
canvas_width,
|
||||
canvas_height,
|
||||
canvas_screen_x,
|
||||
canvas_screen_y,
|
||||
avatar_cx,
|
||||
avatar_cy,
|
||||
boundaries,
|
||||
bubble_position,
|
||||
bubble_height_reserved,
|
||||
empty_top_rows,
|
||||
empty_bottom_rows,
|
||||
}
|
||||
}
|
||||
|
||||
/// CSS style string for positioning the canvas element.
|
||||
fn css_style(&self, z_index: i32, pointer_events: &str, opacity: f64) -> String {
|
||||
format!(
|
||||
"position: absolute; \
|
||||
left: 0; top: 0; \
|
||||
transform: translate({}px, {}px); \
|
||||
z-index: {}; \
|
||||
pointer-events: {}; \
|
||||
width: {}px; \
|
||||
height: {}px; \
|
||||
opacity: {};",
|
||||
self.canvas_screen_x,
|
||||
self.canvas_screen_y,
|
||||
z_index,
|
||||
pointer_events,
|
||||
self.canvas_width,
|
||||
self.canvas_height,
|
||||
opacity
|
||||
)
|
||||
}
|
||||
|
||||
/// Content center X in canvas-local coordinates.
|
||||
fn content_center_x(&self) -> f64 {
|
||||
self.avatar_cx + self.content_x_offset
|
||||
}
|
||||
|
||||
/// Top of avatar in canvas-local coordinates.
|
||||
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 {
|
||||
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).
|
||||
pub fn member_key(m: &ChannelMemberWithAvatar) -> (Option<Uuid>, Option<Uuid>) {
|
||||
(m.member.user_id, m.member.guest_session_id)
|
||||
|
|
@ -293,102 +522,29 @@ pub fn AvatarCanvas(
|
|||
&m.avatar.emotion_layer,
|
||||
);
|
||||
|
||||
// Get offsets from grid center to content center
|
||||
let x_content_offset = content_bounds.x_offset(ps);
|
||||
let y_content_offset = content_bounds.y_offset(ps);
|
||||
|
||||
// Avatar is a 3x3 grid of props, each prop is prop_size
|
||||
let avatar_size = ps * 3.0;
|
||||
|
||||
// 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 for avatar clamping
|
||||
// Compute screen boundaries and avatar screen position
|
||||
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(
|
||||
// Create unified layout - all calculations happen in one place
|
||||
let layout = CanvasLayout::new(
|
||||
&content_bounds,
|
||||
ps,
|
||||
te,
|
||||
avatar_screen_x,
|
||||
avatar_screen_y,
|
||||
content_half_width,
|
||||
content_half_height,
|
||||
boundaries,
|
||||
bubble.is_some(),
|
||||
bubble.as_ref().map(|b| b.message.content.as_str()),
|
||||
);
|
||||
|
||||
// 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)
|
||||
let text_scale = te * BASE_TEXT_SCALE;
|
||||
let fixed_bubble_height = if bubble.is_some() {
|
||||
(4.0 * 16.0 + 16.0 + 8.0 + 5.0) * text_scale
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let fixed_name_height = 20.0 * text_scale;
|
||||
let fixed_text_width = 200.0 * text_scale;
|
||||
|
||||
// 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_height = avatar_size + fixed_bubble_height + fixed_name_height;
|
||||
|
||||
// Adjust position based on bubble position
|
||||
// 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!(
|
||||
"position: absolute; \
|
||||
left: 0; top: 0; \
|
||||
transform: translate({}px, {}px); \
|
||||
z-index: {}; \
|
||||
pointer-events: {}; \
|
||||
width: {}px; \
|
||||
height: {}px; \
|
||||
opacity: {};",
|
||||
canvas_x - (canvas_width - avatar_size) / 2.0,
|
||||
adjusted_y,
|
||||
z_index,
|
||||
pointer_events,
|
||||
canvas_width,
|
||||
canvas_height,
|
||||
opacity
|
||||
)
|
||||
// Generate CSS style from layout
|
||||
layout.css_style(z_index, pointer_events, opacity)
|
||||
};
|
||||
|
||||
// Store references for the effect
|
||||
|
|
@ -422,7 +578,7 @@ pub fn AvatarCanvas(
|
|||
return;
|
||||
};
|
||||
|
||||
// Calculate dimensions (same as in style closure)
|
||||
// Calculate content bounds for the avatar
|
||||
let content_bounds = ContentBounds::from_layers(
|
||||
&m.avatar.skin_layer,
|
||||
&m.avatar.clothes_layer,
|
||||
|
|
@ -430,61 +586,35 @@ pub fn AvatarCanvas(
|
|||
&m.avatar.emotion_layer,
|
||||
);
|
||||
|
||||
let avatar_size = ps * 3.0;
|
||||
let text_scale = te * BASE_TEXT_SCALE;
|
||||
let fixed_bubble_height = if bubble.is_some() {
|
||||
(4.0 * 16.0 + 16.0 + 8.0 + 5.0) * text_scale
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let fixed_name_height = 20.0 * text_scale;
|
||||
let fixed_text_width = 200.0 * text_scale;
|
||||
// Get scene dimensions and transform parameters
|
||||
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);
|
||||
|
||||
// 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();
|
||||
// Create unified layout - same calculation as style closure
|
||||
let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy);
|
||||
let avatar_screen_x = m.member.position_x * sx + ox;
|
||||
let avatar_screen_y = m.member.position_y * sy + oy;
|
||||
|
||||
// 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_height = avatar_size + fixed_bubble_height + fixed_name_height;
|
||||
let layout = CanvasLayout::new(
|
||||
&content_bounds,
|
||||
ps,
|
||||
te,
|
||||
avatar_screen_x,
|
||||
avatar_screen_y,
|
||||
boundaries,
|
||||
bubble.is_some(),
|
||||
bubble.as_ref().map(|b| b.message.content.as_str()),
|
||||
);
|
||||
|
||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||
|
||||
// Set canvas resolution
|
||||
canvas_el.set_width(canvas_width as u32);
|
||||
canvas_el.set_height(canvas_height as u32);
|
||||
// Set canvas resolution from layout
|
||||
canvas_el.set_width(layout.canvas_width as u32);
|
||||
canvas_el.set_height(layout.canvas_height as u32);
|
||||
|
||||
let Ok(Some(ctx)) = canvas_el.get_context("2d") else {
|
||||
return;
|
||||
|
|
@ -492,16 +622,7 @@ pub fn AvatarCanvas(
|
|||
let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into().unwrap();
|
||||
|
||||
// Clear canvas
|
||||
ctx.clear_rect(0.0, 0.0, canvas_width, canvas_height);
|
||||
|
||||
// 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_cy = match bubble_position {
|
||||
BubblePosition::Above => fixed_bubble_height + avatar_size / 2.0,
|
||||
BubblePosition::Below => avatar_size / 2.0,
|
||||
};
|
||||
ctx.clear_rect(0.0, 0.0, layout.canvas_width, layout.canvas_height);
|
||||
|
||||
// Helper to load and draw an image
|
||||
// Images are cached; when loaded, triggers a redraw via signal
|
||||
|
|
@ -544,9 +665,9 @@ pub fn AvatarCanvas(
|
|||
};
|
||||
|
||||
// Draw all 9 positions of the avatar grid (3x3 layout)
|
||||
let cell_size = ps;
|
||||
let grid_origin_x = avatar_cx - avatar_size / 2.0;
|
||||
let grid_origin_y = avatar_cy - avatar_size / 2.0;
|
||||
let cell_size = layout.prop_size;
|
||||
let grid_origin_x = layout.avatar_cx - layout.avatar_size / 2.0;
|
||||
let grid_origin_y = layout.avatar_cy - layout.avatar_size / 2.0;
|
||||
|
||||
// Draw skin layer for all 9 positions
|
||||
for pos in 0..9 {
|
||||
|
|
@ -616,9 +737,9 @@ pub fn AvatarCanvas(
|
|||
// Draw emotion badge if non-neutral
|
||||
let current_emotion = m.member.current_emotion;
|
||||
if current_emotion > 0 {
|
||||
let badge_size = 16.0 * text_scale;
|
||||
let badge_x = avatar_cx + avatar_size / 2.0 - badge_size / 2.0;
|
||||
let badge_y = avatar_cy - avatar_size / 2.0 - badge_size / 2.0;
|
||||
let badge_size = 16.0 * layout.text_scale;
|
||||
let badge_x = layout.avatar_cx + layout.avatar_size / 2.0 - badge_size / 2.0;
|
||||
let badge_y = layout.avatar_cy - layout.avatar_size / 2.0 - badge_size / 2.0;
|
||||
|
||||
ctx.begin_path();
|
||||
let _ = ctx.arc(
|
||||
|
|
@ -632,23 +753,21 @@ pub fn AvatarCanvas(
|
|||
ctx.fill();
|
||||
|
||||
ctx.set_fill_style_str("#000");
|
||||
ctx.set_font(&format!("bold {}px sans-serif", 10.0 * text_scale));
|
||||
ctx.set_font(&format!("bold {}px sans-serif", 10.0 * layout.text_scale));
|
||||
ctx.set_text_align("center");
|
||||
ctx.set_text_baseline("middle");
|
||||
let _ = ctx.fill_text(&format!("{}", current_emotion), badge_x, badge_y);
|
||||
}
|
||||
|
||||
// Calculate content bounds for name positioning
|
||||
let name_x = avatar_cx + content_bounds.x_offset(cell_size);
|
||||
let empty_bottom_rows = content_bounds.empty_bottom_rows();
|
||||
|
||||
// Draw display name below avatar (with black outline for readability)
|
||||
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 * text_scale));
|
||||
ctx.set_font(&format!("{}px sans-serif", 12.0 * layout.text_scale));
|
||||
ctx.set_text_align("center");
|
||||
ctx.set_text_baseline("alphabetic");
|
||||
let name_y = avatar_cy + avatar_size / 2.0 - (empty_bottom_rows as f64 * cell_size)
|
||||
+ 15.0 * text_scale;
|
||||
// Black outline
|
||||
ctx.set_stroke_style_str("#000");
|
||||
ctx.set_line_width(3.0);
|
||||
|
|
@ -661,35 +780,7 @@ pub fn AvatarCanvas(
|
|||
if let Some(ref b) = bubble {
|
||||
let current_time = js_sys::Date::now() as i64;
|
||||
if b.expires_at >= current_time {
|
||||
let content_x_offset = content_bounds.x_offset(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(
|
||||
&ctx,
|
||||
b,
|
||||
avatar_cx,
|
||||
avatar_top_y,
|
||||
avatar_bottom_y,
|
||||
content_x_offset,
|
||||
content_top_adjustment,
|
||||
te,
|
||||
bubble_position,
|
||||
Some(&boundaries),
|
||||
);
|
||||
draw_bubble_with_layout(&ctx, b, &layout, te);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -724,33 +815,18 @@ fn normalize_asset_path(path: &str) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
/// Draw a speech bubble relative to the avatar with boundary awareness.
|
||||
/// Draw a speech bubble using the unified CanvasLayout.
|
||||
///
|
||||
/// # 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)
|
||||
/// 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(
|
||||
fn draw_bubble_with_layout(
|
||||
ctx: &web_sys::CanvasRenderingContext2d,
|
||||
bubble: &ActiveBubble,
|
||||
center_x: f64,
|
||||
top_y: f64,
|
||||
bottom_y: f64,
|
||||
content_x_offset: f64,
|
||||
content_top_adjustment: f64,
|
||||
layout: &CanvasLayout,
|
||||
text_em_size: f64,
|
||||
position: BubblePosition,
|
||||
boundaries: Option<&ScreenBoundaries>,
|
||||
) {
|
||||
// Text scale independent of zoom - only affected by user's text_em_size setting
|
||||
let text_scale = text_em_size * BASE_TEXT_SCALE;
|
||||
let max_bubble_width = 200.0 * text_scale;
|
||||
let padding = 8.0 * text_scale;
|
||||
|
|
@ -771,11 +847,7 @@ fn draw_bubble(
|
|||
|
||||
// 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,
|
||||
);
|
||||
let lines = wrap_text(ctx, &bubble.message.content, max_bubble_width - padding * 2.0);
|
||||
|
||||
// Calculate bubble dimensions
|
||||
let bubble_width = lines
|
||||
|
|
@ -786,25 +858,14 @@ fn draw_bubble(
|
|||
let bubble_width = bubble_width.max(60.0 * text_scale);
|
||||
let bubble_height = (lines.len() as f64) * line_height + padding * 2.0;
|
||||
|
||||
// Center bubble horizontally on content (not grid center)
|
||||
let content_center_x = center_x + content_x_offset;
|
||||
// Get content center from layout
|
||||
let content_center_x = layout.content_center_x();
|
||||
|
||||
// Calculate initial bubble X position (centered on content)
|
||||
let mut bubble_x = content_center_x - bubble_width / 2.0;
|
||||
// Calculate initial bubble X (centered on content)
|
||||
let initial_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;
|
||||
}
|
||||
}
|
||||
// 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
|
||||
|
|
@ -812,27 +873,20 @@ fn draw_bubble(
|
|||
.min(bubble_x + bubble_width - tail_size - border_radius);
|
||||
|
||||
// Calculate vertical position based on bubble position
|
||||
let bubble_y = match position {
|
||||
let bubble_y = match layout.bubble_position {
|
||||
BubblePosition::Above => {
|
||||
// Position vertically closer to content when top rows are empty
|
||||
let adjusted_top_y = top_y + content_top_adjustment;
|
||||
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
|
||||
bottom_y + tail_size + gap
|
||||
layout.avatar_bottom_y() + tail_size + gap
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
ctx.set_fill_style_str(bg_color);
|
||||
ctx.fill();
|
||||
ctx.set_stroke_style_str(border_color);
|
||||
|
|
@ -841,7 +895,7 @@ fn draw_bubble(
|
|||
|
||||
// Draw tail pointing to content center
|
||||
ctx.begin_path();
|
||||
match position {
|
||||
match layout.bubble_position {
|
||||
BubblePosition::Above => {
|
||||
// Tail points DOWN toward avatar
|
||||
ctx.move_to(tail_center_x - tail_size, bubble_y + bubble_height);
|
||||
|
|
@ -861,7 +915,7 @@ fn draw_bubble(
|
|||
ctx.set_stroke_style_str(border_color);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw text (re-set font in case canvas state changed)
|
||||
// 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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue