fix: eliminate redraws on position changes
This commit is contained in:
parent
5fcd49e847
commit
b361460485
4 changed files with 235 additions and 176 deletions
|
|
@ -1772,7 +1772,7 @@ pub struct ChannelMember {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Channel member with user info for display.
|
/// Channel member with user info for display.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
||||||
pub struct ChannelMemberInfo {
|
pub struct ChannelMemberInfo {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
|
@ -1814,7 +1814,7 @@ pub struct UpdateEmotionRequest {
|
||||||
|
|
||||||
/// Data needed to render an avatar's current appearance.
|
/// Data needed to render an avatar's current appearance.
|
||||||
/// Contains the asset paths for all equipped props.
|
/// Contains the asset paths for all equipped props.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct AvatarRenderData {
|
pub struct AvatarRenderData {
|
||||||
pub avatar_id: Uuid,
|
pub avatar_id: Uuid,
|
||||||
pub current_emotion: i16,
|
pub current_emotion: i16,
|
||||||
|
|
@ -1842,7 +1842,7 @@ impl Default for AvatarRenderData {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Channel member with full avatar render data.
|
/// Channel member with full avatar render data.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct ChannelMemberWithAvatar {
|
pub struct ChannelMemberWithAvatar {
|
||||||
pub member: ChannelMemberInfo,
|
pub member: ChannelMemberInfo,
|
||||||
pub avatar: AvatarRenderData,
|
pub avatar: AvatarRenderData,
|
||||||
|
|
|
||||||
|
|
@ -96,103 +96,100 @@ pub fn member_key(m: &ChannelMemberWithAvatar) -> (Option<Uuid>, Option<Uuid>) {
|
||||||
/// - Optional speech bubble above the avatar
|
/// - Optional speech bubble above the avatar
|
||||||
#[component]
|
#[component]
|
||||||
pub fn AvatarCanvas(
|
pub fn AvatarCanvas(
|
||||||
/// The member data for this avatar.
|
/// The member data for this avatar (as a signal for reactive updates).
|
||||||
member: ChannelMemberWithAvatar,
|
member: Signal<ChannelMemberWithAvatar>,
|
||||||
/// X scale factor for coordinate conversion.
|
/// X scale factor for coordinate conversion.
|
||||||
scale_x: f64,
|
scale_x: Signal<f64>,
|
||||||
/// Y scale factor for coordinate conversion.
|
/// Y scale factor for coordinate conversion.
|
||||||
scale_y: f64,
|
scale_y: Signal<f64>,
|
||||||
/// X offset for coordinate conversion.
|
/// X offset for coordinate conversion.
|
||||||
offset_x: f64,
|
offset_x: Signal<f64>,
|
||||||
/// Y offset for coordinate conversion.
|
/// Y offset for coordinate conversion.
|
||||||
offset_y: f64,
|
offset_y: Signal<f64>,
|
||||||
/// Size of the avatar in pixels.
|
/// Size of the avatar in pixels.
|
||||||
prop_size: f64,
|
prop_size: Signal<f64>,
|
||||||
/// Z-index for stacking order (higher = on top).
|
/// Z-index for stacking order (higher = on top).
|
||||||
z_index: i32,
|
z_index: i32,
|
||||||
/// Active speech bubble for this user (if any).
|
/// Active speech bubble for this user (if any).
|
||||||
active_bubble: Option<ActiveBubble>,
|
active_bubble: Signal<Option<ActiveBubble>>,
|
||||||
/// Text size multiplier for display names, chat bubbles, and badges.
|
/// Text size multiplier for display names, chat bubbles, and badges.
|
||||||
#[prop(default = 1.0)]
|
#[prop(default = 1.0.into())]
|
||||||
text_em_size: f64,
|
text_em_size: Signal<f64>,
|
||||||
/// Opacity for fade-out animation (0.0 to 1.0, default 1.0).
|
/// Opacity for fade-out animation (0.0 to 1.0, default 1.0).
|
||||||
#[prop(default = 1.0)]
|
#[prop(default = 1.0)]
|
||||||
opacity: f64,
|
opacity: f64,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
let canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
||||||
|
|
||||||
// Clone data for use in closures
|
|
||||||
let skin_layer = member.avatar.skin_layer.clone();
|
|
||||||
let clothes_layer = member.avatar.clothes_layer.clone();
|
|
||||||
let accessories_layer = member.avatar.accessories_layer.clone();
|
|
||||||
let emotion_layer = member.avatar.emotion_layer.clone();
|
|
||||||
let display_name = member.member.display_name.clone();
|
|
||||||
let current_emotion = member.member.current_emotion;
|
|
||||||
|
|
||||||
// Calculate content bounds for centering on actual content
|
|
||||||
let content_bounds = ContentBounds::from_layers(
|
|
||||||
&skin_layer,
|
|
||||||
&clothes_layer,
|
|
||||||
&accessories_layer,
|
|
||||||
&emotion_layer,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get offsets from grid center to content center
|
|
||||||
let x_content_offset = content_bounds.x_offset(prop_size);
|
|
||||||
let y_content_offset = content_bounds.y_offset(prop_size);
|
|
||||||
let empty_bottom_rows = content_bounds.empty_bottom_rows();
|
|
||||||
|
|
||||||
// Avatar is a 3x3 grid of props, each prop is prop_size
|
|
||||||
let avatar_size = prop_size * 3.0;
|
|
||||||
|
|
||||||
// Calculate canvas position from scene coordinates, adjusted for content bounds
|
|
||||||
// Both X and Y center the avatar content on the click point
|
|
||||||
// Note: x_content_offset and y_content_offset are already in viewport pixels (prop_size includes scale)
|
|
||||||
let canvas_x = member.member.position_x * scale_x + offset_x - avatar_size / 2.0 - x_content_offset;
|
|
||||||
let canvas_y = member.member.position_y * scale_y + offset_y - avatar_size / 2.0 - y_content_offset;
|
|
||||||
|
|
||||||
// Fixed text dimensions (independent of prop_size/zoom)
|
|
||||||
// Text stays readable regardless of zoom level - only affected by text_em_size slider
|
|
||||||
let text_scale = text_em_size * BASE_TEXT_SCALE;
|
|
||||||
let fixed_bubble_height = if active_bubble.is_some() {
|
|
||||||
// 4 lines * 16px line_height + 16px padding + 8px tail + 5px margin
|
|
||||||
(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;
|
|
||||||
|
|
||||||
// Canvas must fit both avatar AND fixed-size text
|
|
||||||
let canvas_width = avatar_size.max(fixed_text_width);
|
|
||||||
let canvas_height = avatar_size + fixed_bubble_height + fixed_name_height;
|
|
||||||
|
|
||||||
// Adjust bubble_extra for positioning (used later in avatar_cy calculation)
|
|
||||||
let bubble_extra = fixed_bubble_height;
|
|
||||||
|
|
||||||
// Adjust position to account for extra space above avatar
|
|
||||||
let adjusted_y = canvas_y - bubble_extra;
|
|
||||||
|
|
||||||
// CSS positioning via transform (GPU-accelerated)
|
|
||||||
// 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" };
|
||||||
let style = format!(
|
|
||||||
"position: absolute; \
|
// Reactive style for CSS positioning (GPU-accelerated transforms)
|
||||||
left: 0; top: 0; \
|
// This closure re-runs when position, scale, offset, or prop_size changes
|
||||||
transform: translate({}px, {}px); \
|
let style = move || {
|
||||||
z-index: {}; \
|
let m = member.get();
|
||||||
pointer-events: {}; \
|
let ps = prop_size.get();
|
||||||
width: {}px; \
|
let sx = scale_x.get();
|
||||||
height: {}px; \
|
let sy = scale_y.get();
|
||||||
opacity: {};",
|
let ox = offset_x.get();
|
||||||
canvas_x - (canvas_width - avatar_size) / 2.0,
|
let oy = offset_y.get();
|
||||||
adjusted_y,
|
let te = text_em_size.get();
|
||||||
z_index,
|
let bubble = active_bubble.get();
|
||||||
pointer_events,
|
|
||||||
canvas_width,
|
// Calculate content bounds for centering on actual content
|
||||||
canvas_height,
|
let content_bounds = ContentBounds::from_layers(
|
||||||
opacity
|
&m.avatar.skin_layer,
|
||||||
);
|
&m.avatar.clothes_layer,
|
||||||
|
&m.avatar.accessories_layer,
|
||||||
|
&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;
|
||||||
|
|
||||||
|
// Calculate canvas position from scene coordinates, adjusted for content bounds
|
||||||
|
let canvas_x = m.member.position_x * sx + ox - avatar_size / 2.0 - x_content_offset;
|
||||||
|
let canvas_y = m.member.position_y * sy + oy - 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;
|
||||||
|
|
||||||
|
// Canvas must fit both avatar AND fixed-size text
|
||||||
|
let canvas_width = avatar_size.max(fixed_text_width);
|
||||||
|
let canvas_height = avatar_size + fixed_bubble_height + fixed_name_height;
|
||||||
|
|
||||||
|
// Adjust position to account for extra space above avatar
|
||||||
|
let adjusted_y = canvas_y - fixed_bubble_height;
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
// Store references for the effect
|
// Store references for the effect
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
|
|
@ -210,22 +207,42 @@ pub fn AvatarCanvas(
|
||||||
// Redraw trigger - incremented when images load to cause Effect to re-run
|
// Redraw trigger - incremented when images load to cause Effect to re-run
|
||||||
let (redraw_trigger, set_redraw_trigger) = signal(0u32);
|
let (redraw_trigger, set_redraw_trigger) = signal(0u32);
|
||||||
|
|
||||||
// Clone values for the effect
|
|
||||||
let skin_layer_clone = skin_layer.clone();
|
|
||||||
let clothes_layer_clone = clothes_layer.clone();
|
|
||||||
let accessories_layer_clone = accessories_layer.clone();
|
|
||||||
let emotion_layer_clone = emotion_layer.clone();
|
|
||||||
let display_name_clone = display_name.clone();
|
|
||||||
let active_bubble_clone = active_bubble.clone();
|
|
||||||
|
|
||||||
// Effect to draw the avatar when canvas is ready or appearance changes
|
// Effect to draw the avatar when canvas is ready or appearance changes
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
// Subscribe to redraw trigger so this effect re-runs when images load
|
// Subscribe to redraw trigger so this effect re-runs when images load
|
||||||
let _ = redraw_trigger.get();
|
let _ = redraw_trigger.get();
|
||||||
|
|
||||||
|
// Get current values from signals
|
||||||
|
let m = member.get();
|
||||||
|
let ps = prop_size.get();
|
||||||
|
let te = text_em_size.get();
|
||||||
|
let bubble = active_bubble.get();
|
||||||
|
|
||||||
let Some(canvas) = canvas_ref.get() else {
|
let Some(canvas) = canvas_ref.get() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Calculate dimensions (same as in style closure)
|
||||||
|
let content_bounds = ContentBounds::from_layers(
|
||||||
|
&m.avatar.skin_layer,
|
||||||
|
&m.avatar.clothes_layer,
|
||||||
|
&m.avatar.accessories_layer,
|
||||||
|
&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;
|
||||||
|
|
||||||
|
let canvas_width = avatar_size.max(fixed_text_width);
|
||||||
|
let canvas_height = avatar_size + fixed_bubble_height + fixed_name_height;
|
||||||
|
|
||||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||||
|
|
||||||
// Set canvas resolution
|
// Set canvas resolution
|
||||||
|
|
@ -242,7 +259,7 @@ pub fn AvatarCanvas(
|
||||||
|
|
||||||
// Avatar center position within the canvas
|
// Avatar center position within the canvas
|
||||||
let avatar_cx = canvas_width / 2.0;
|
let avatar_cx = canvas_width / 2.0;
|
||||||
let avatar_cy = bubble_extra + avatar_size / 2.0;
|
let avatar_cy = fixed_bubble_height + 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
|
||||||
|
|
@ -280,18 +297,13 @@ pub fn AvatarCanvas(
|
||||||
};
|
};
|
||||||
|
|
||||||
// Draw all 9 positions of the avatar grid (3x3 layout)
|
// Draw all 9 positions of the avatar grid (3x3 layout)
|
||||||
// Grid positions:
|
let cell_size = ps;
|
||||||
// 0 1 2
|
|
||||||
// 3 4 5
|
|
||||||
// 6 7 8
|
|
||||||
// Each cell is full prop_size, grid is 3x3
|
|
||||||
let cell_size = prop_size;
|
|
||||||
let grid_origin_x = avatar_cx - avatar_size / 2.0;
|
let grid_origin_x = avatar_cx - avatar_size / 2.0;
|
||||||
let grid_origin_y = avatar_cy - avatar_size / 2.0;
|
let grid_origin_y = avatar_cy - avatar_size / 2.0;
|
||||||
|
|
||||||
// Draw skin layer for all 9 positions
|
// Draw skin layer for all 9 positions
|
||||||
for pos in 0..9 {
|
for pos in 0..9 {
|
||||||
if let Some(ref skin_path) = skin_layer_clone[pos] {
|
if let Some(ref skin_path) = m.avatar.skin_layer[pos] {
|
||||||
let col = pos % 3;
|
let col = pos % 3;
|
||||||
let row = pos / 3;
|
let row = pos / 3;
|
||||||
let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size;
|
let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size;
|
||||||
|
|
@ -302,7 +314,7 @@ pub fn AvatarCanvas(
|
||||||
|
|
||||||
// Draw clothes layer for all 9 positions
|
// Draw clothes layer for all 9 positions
|
||||||
for pos in 0..9 {
|
for pos in 0..9 {
|
||||||
if let Some(ref clothes_path) = clothes_layer_clone[pos] {
|
if let Some(ref clothes_path) = m.avatar.clothes_layer[pos] {
|
||||||
let col = pos % 3;
|
let col = pos % 3;
|
||||||
let row = pos / 3;
|
let row = pos / 3;
|
||||||
let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size;
|
let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size;
|
||||||
|
|
@ -313,7 +325,7 @@ pub fn AvatarCanvas(
|
||||||
|
|
||||||
// Draw accessories layer for all 9 positions
|
// Draw accessories layer for all 9 positions
|
||||||
for pos in 0..9 {
|
for pos in 0..9 {
|
||||||
if let Some(ref accessories_path) = accessories_layer_clone[pos] {
|
if let Some(ref accessories_path) = m.avatar.accessories_layer[pos] {
|
||||||
let col = pos % 3;
|
let col = pos % 3;
|
||||||
let row = pos / 3;
|
let row = pos / 3;
|
||||||
let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size;
|
let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size;
|
||||||
|
|
@ -324,7 +336,7 @@ pub fn AvatarCanvas(
|
||||||
|
|
||||||
// Draw emotion overlay for all 9 positions
|
// Draw emotion overlay for all 9 positions
|
||||||
for pos in 0..9 {
|
for pos in 0..9 {
|
||||||
if let Some(ref emotion_path) = emotion_layer_clone[pos] {
|
if let Some(ref emotion_path) = m.avatar.emotion_layer[pos] {
|
||||||
let col = pos % 3;
|
let col = pos % 3;
|
||||||
let row = pos / 3;
|
let row = pos / 3;
|
||||||
let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size;
|
let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size;
|
||||||
|
|
@ -333,10 +345,8 @@ pub fn AvatarCanvas(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text scale independent of zoom - only affected by user's text_em_size setting
|
|
||||||
let text_scale = text_em_size * BASE_TEXT_SCALE;
|
|
||||||
|
|
||||||
// Draw emotion badge if non-neutral
|
// Draw emotion badge if non-neutral
|
||||||
|
let current_emotion = m.member.current_emotion;
|
||||||
if current_emotion > 0 {
|
if current_emotion > 0 {
|
||||||
let badge_size = 16.0 * text_scale;
|
let badge_size = 16.0 * text_scale;
|
||||||
let badge_x = avatar_cx + avatar_size / 2.0 - badge_size / 2.0;
|
let badge_x = avatar_cx + avatar_size / 2.0 - badge_size / 2.0;
|
||||||
|
|
@ -355,16 +365,11 @@ pub fn AvatarCanvas(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate content bounds for name positioning
|
// Calculate content bounds for name positioning
|
||||||
let content_bounds = ContentBounds::from_layers(
|
|
||||||
&skin_layer_clone,
|
|
||||||
&clothes_layer_clone,
|
|
||||||
&accessories_layer_clone,
|
|
||||||
&emotion_layer_clone,
|
|
||||||
);
|
|
||||||
let name_x = avatar_cx + content_bounds.x_offset(cell_size);
|
let name_x = avatar_cx + content_bounds.x_offset(cell_size);
|
||||||
let empty_bottom_rows = content_bounds.empty_bottom_rows();
|
let empty_bottom_rows = content_bounds.empty_bottom_rows();
|
||||||
|
|
||||||
// Draw display name below avatar (with black outline for readability)
|
// Draw display name below avatar (with black outline for readability)
|
||||||
|
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 * text_scale));
|
||||||
ctx.set_text_align("center");
|
ctx.set_text_align("center");
|
||||||
ctx.set_text_baseline("alphabetic");
|
ctx.set_text_baseline("alphabetic");
|
||||||
|
|
@ -372,26 +377,34 @@ pub fn AvatarCanvas(
|
||||||
// Black outline
|
// Black outline
|
||||||
ctx.set_stroke_style_str("#000");
|
ctx.set_stroke_style_str("#000");
|
||||||
ctx.set_line_width(3.0);
|
ctx.set_line_width(3.0);
|
||||||
let _ = ctx.stroke_text(&display_name_clone, name_x, name_y);
|
let _ = ctx.stroke_text(display_name, name_x, name_y);
|
||||||
// White fill
|
// White fill
|
||||||
ctx.set_fill_style_str("#fff");
|
ctx.set_fill_style_str("#fff");
|
||||||
let _ = ctx.fill_text(&display_name_clone, name_x, name_y);
|
let _ = ctx.fill_text(display_name, name_x, name_y);
|
||||||
|
|
||||||
// Draw speech bubble if active
|
// Draw speech bubble if active
|
||||||
if let Some(ref bubble) = active_bubble_clone {
|
if let Some(ref b) = bubble {
|
||||||
let current_time = js_sys::Date::now() as i64;
|
let current_time = js_sys::Date::now() as i64;
|
||||||
if bubble.expires_at >= current_time {
|
if b.expires_at >= current_time {
|
||||||
draw_bubble(&ctx, bubble, avatar_cx, avatar_cy - avatar_size / 2.0, avatar_size, text_em_size);
|
draw_bubble(&ctx, b, avatar_cx, avatar_cy - avatar_size / 2.0, avatar_size, te);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compute data-member-id reactively
|
||||||
|
let data_member_id = move || {
|
||||||
|
let m = member.get();
|
||||||
|
m.member.user_id.map(|u| u.to_string())
|
||||||
|
.or_else(|| m.member.guest_session_id.map(|g| g.to_string()))
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<canvas
|
<canvas
|
||||||
node_ref=canvas_ref
|
node_ref=canvas_ref
|
||||||
style=style
|
style=style
|
||||||
data-member-id=member.member.user_id.map(|u| u.to_string()).or_else(|| member.member.guest_session_id.map(|g| g.to_string())).unwrap_or_default()
|
data-member-id=data_member_id
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ pub const MAX_MESSAGE_LOG_SIZE: usize = 2000;
|
||||||
pub const DEFAULT_BUBBLE_TIMEOUT_MS: i64 = 60_000;
|
pub const DEFAULT_BUBBLE_TIMEOUT_MS: i64 = 60_000;
|
||||||
|
|
||||||
/// A chat message for display and logging.
|
/// A chat message for display and logging.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct ChatMessage {
|
pub struct ChatMessage {
|
||||||
pub message_id: Uuid,
|
pub message_id: Uuid,
|
||||||
pub user_id: Option<Uuid>,
|
pub user_id: Option<Uuid>,
|
||||||
|
|
@ -73,7 +73,7 @@ impl MessageLog {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Active speech bubble state for a user.
|
/// Active speech bubble state for a user.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct ActiveBubble {
|
pub struct ActiveBubble {
|
||||||
pub message: ChatMessage,
|
pub message: ChatMessage,
|
||||||
/// When the bubble should expire (milliseconds since epoch).
|
/// When the bubble should expire (milliseconds since epoch).
|
||||||
|
|
|
||||||
|
|
@ -737,6 +737,31 @@ pub fn RealmSceneViewer(
|
||||||
// Text size multiplier from settings
|
// Text size multiplier from settings
|
||||||
let text_em_size = Signal::derive(move || settings.get().text_em_size);
|
let text_em_size = Signal::derive(move || settings.get().text_em_size);
|
||||||
|
|
||||||
|
// Create signals for scale/offset values to pass to AvatarCanvas
|
||||||
|
let scale_x_signal = Signal::derive(move || scale_x.get_value());
|
||||||
|
let scale_y_signal = Signal::derive(move || scale_y.get_value());
|
||||||
|
let offset_x_signal = Signal::derive(move || offset_x.get_value());
|
||||||
|
let offset_y_signal = Signal::derive(move || offset_y.get_value());
|
||||||
|
|
||||||
|
// Create a map of members by key for efficient lookup
|
||||||
|
let members_by_key = Signal::derive(move || {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
sorted_members.get()
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, m)| (member_key(&m), (idx, m)))
|
||||||
|
.collect::<HashMap<_, _>>()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the list of member keys - use Memo so it only updates when keys actually change
|
||||||
|
// (not when member data like position changes)
|
||||||
|
let member_keys = Memo::new(move |_| {
|
||||||
|
sorted_members.get()
|
||||||
|
.iter()
|
||||||
|
.map(member_key)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
});
|
||||||
|
|
||||||
let scene_name = scene.name.clone();
|
let scene_name = scene.name.clone();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
|
|
@ -764,76 +789,97 @@ pub fn RealmSceneViewer(
|
||||||
class="avatars-container absolute inset-0"
|
class="avatars-container absolute inset-0"
|
||||||
style="z-index: 2; pointer-events: none;"
|
style="z-index: 2; pointer-events: none;"
|
||||||
>
|
>
|
||||||
{move || {
|
// Wait for scale factors before rendering
|
||||||
// Wait for scale factors to be calculated before rendering avatars
|
<Show
|
||||||
if !scales_ready.get() {
|
when=move || scales_ready.get()
|
||||||
return Vec::new().into_iter().collect_view();
|
fallback=|| ()
|
||||||
}
|
>
|
||||||
|
// Use stable keys - each AvatarCanvas gets its own derived signal
|
||||||
|
{move || {
|
||||||
|
member_keys.get().into_iter().map(|key| {
|
||||||
|
// Create a derived signal for this specific member
|
||||||
|
let member_signal = Signal::derive(move || {
|
||||||
|
members_by_key.get()
|
||||||
|
.get(&key)
|
||||||
|
.map(|(_, m)| m.clone())
|
||||||
|
.expect("member key should exist")
|
||||||
|
});
|
||||||
|
|
||||||
let current_bubbles = active_bubbles.get();
|
// Derive z-index from position in sorted list
|
||||||
let sx = scale_x.get_value();
|
let z_index_signal = Signal::derive(move || {
|
||||||
let sy = scale_y.get_value();
|
members_by_key.get()
|
||||||
let ox = offset_x.get_value();
|
.get(&key)
|
||||||
let oy = offset_y.get_value();
|
.map(|(idx, _)| (*idx as i32) + 10)
|
||||||
let ps = prop_size.get();
|
.unwrap_or(10)
|
||||||
let te = text_em_size.get();
|
});
|
||||||
|
|
||||||
|
// Derive bubble for this member
|
||||||
|
let bubble_signal = Signal::derive(move || {
|
||||||
|
active_bubbles.get().get(&key).cloned()
|
||||||
|
});
|
||||||
|
|
||||||
|
let z = z_index_signal.get_untracked();
|
||||||
|
|
||||||
// Render active members
|
|
||||||
let mut views: Vec<_> = sorted_members.get()
|
|
||||||
.into_iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(idx, member)| {
|
|
||||||
let key = member_key(&member);
|
|
||||||
let bubble = current_bubbles.get(&key).cloned();
|
|
||||||
let z = (idx as i32) + 10;
|
|
||||||
view! {
|
view! {
|
||||||
<AvatarCanvas
|
<AvatarCanvas
|
||||||
member=member
|
member=member_signal
|
||||||
scale_x=sx
|
scale_x=scale_x_signal
|
||||||
scale_y=sy
|
scale_y=scale_y_signal
|
||||||
offset_x=ox
|
offset_x=offset_x_signal
|
||||||
offset_y=oy
|
offset_y=offset_y_signal
|
||||||
prop_size=ps
|
prop_size=prop_size
|
||||||
z_index=z
|
z_index=z
|
||||||
active_bubble=bubble
|
active_bubble=bubble_signal
|
||||||
text_em_size=te
|
text_em_size=text_em_size
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
})
|
}).collect_view()
|
||||||
.collect();
|
}}
|
||||||
|
// Fading members use closure approach (temporary, per-frame updates)
|
||||||
|
{move || {
|
||||||
|
let Some(fading_signal) = fading_members else {
|
||||||
|
return Vec::new().into_iter().collect_view();
|
||||||
|
};
|
||||||
|
|
||||||
// Render fading members with calculated opacity
|
|
||||||
if let Some(fading_signal) = fading_members {
|
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
let now = js_sys::Date::now() as i64;
|
let now = js_sys::Date::now() as i64;
|
||||||
#[cfg(not(feature = "hydrate"))]
|
#[cfg(not(feature = "hydrate"))]
|
||||||
let now = 0i64;
|
let now = 0i64;
|
||||||
|
|
||||||
for fading in fading_signal.get() {
|
fading_signal.get()
|
||||||
let elapsed = now - fading.fade_start;
|
.into_iter()
|
||||||
if elapsed < fading.fade_duration {
|
.filter_map(|fading| {
|
||||||
let opacity = 1.0 - (elapsed as f64 / fading.fade_duration as f64);
|
let elapsed = now - fading.fade_start;
|
||||||
let opacity = opacity.max(0.0).min(1.0);
|
if elapsed < fading.fade_duration {
|
||||||
views.push(view! {
|
let opacity = 1.0 - (elapsed as f64 / fading.fade_duration as f64);
|
||||||
<AvatarCanvas
|
let opacity = opacity.max(0.0).min(1.0);
|
||||||
member=fading.member
|
// Fading members get static signals (they're temporary)
|
||||||
scale_x=sx
|
let member_signal = Signal::derive({
|
||||||
scale_y=sy
|
let m = fading.member.clone();
|
||||||
offset_x=ox
|
move || m.clone()
|
||||||
offset_y=oy
|
});
|
||||||
prop_size=ps
|
let bubble_signal: Signal<Option<ActiveBubble>> = Signal::derive(|| None);
|
||||||
z_index=5
|
Some(view! {
|
||||||
active_bubble=None
|
<AvatarCanvas
|
||||||
text_em_size=te
|
member=member_signal
|
||||||
opacity=opacity
|
scale_x=scale_x_signal
|
||||||
/>
|
scale_y=scale_y_signal
|
||||||
});
|
offset_x=offset_x_signal
|
||||||
}
|
offset_y=offset_y_signal
|
||||||
}
|
prop_size=prop_size
|
||||||
}
|
z_index=5
|
||||||
|
active_bubble=bubble_signal
|
||||||
views.into_iter().collect_view()
|
text_em_size=text_em_size
|
||||||
}}
|
opacity=opacity
|
||||||
|
/>
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect_view()
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
// Click overlay - captures clicks for movement and hit-testing
|
// Click overlay - captures clicks for movement and hit-testing
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue