fix: Move to use HTML for text

Previously we had the bubbles drawn on the avatar canvas. Now it's
actually text, so is the label.
This commit is contained in:
Evan Carroll 2026-01-26 00:05:41 -06:00
parent 23374ee024
commit 66368fe274
6 changed files with 650 additions and 403 deletions

View file

@ -22,12 +22,14 @@ use uuid::Uuid;
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene};
use super::avatar_canvas::{AvatarCanvas, member_key};
use super::avatar_canvas::{AvatarBoundsStore, AvatarCanvas, ScreenBounds, member_key};
#[cfg(feature = "hydrate")]
use super::canvas_utils::hit_test_canvas;
use super::chat_types::ActiveBubble;
use super::context_menu::{ContextMenu, ContextMenuItem};
use super::loose_prop_canvas::LoosePropCanvas;
use super::speech_bubble::SpeechBubble;
use super::username_label::UsernameLabel;
use super::settings::{
BASE_AVATAR_SCALE, BASE_PROP_SIZE, REFERENCE_HEIGHT, REFERENCE_WIDTH, ViewerSettings,
calculate_min_zoom,
@ -460,6 +462,34 @@ pub fn RealmSceneViewer(
sorted_members.get().iter().map(member_key).collect::<Vec<_>>()
});
// Shared store for avatar bounds - AvatarCanvas writes, SpeechBubble reads
let avatar_bounds_store: AvatarBoundsStore = RwSignal::new(HashMap::new());
// Clean up bounds store when members change (prevent memory leak)
Effect::new(move |_| {
let current_member_ids: std::collections::HashSet<_> = members
.get()
.iter()
.map(|m| m.member.user_id)
.collect();
avatar_bounds_store.update(|map| {
map.retain(|id, _| current_member_ids.contains(id));
});
});
// Scene bounds for clamping bubbles - computed once outside render loop
let scene_bounds_signal = Signal::derive(move || {
ScreenBounds::from_transform(
scene_width_signal.get(),
scene_height_signal.get(),
scale_x_signal.get(),
scale_y_signal.get(),
offset_x_signal.get(),
offset_y_signal.get(),
)
});
let scene_name = scene.name.clone();
view! {
@ -504,7 +534,6 @@ pub fn RealmSceneViewer(
let z_index_signal = Signal::derive(move || {
members_by_key.get().get(&key).map(|(idx, _)| (*idx as i32) + 10).unwrap_or(10)
});
let bubble_signal = Signal::derive(move || active_bubbles.get().get(&key).cloned());
let z = z_index_signal.get_untracked();
view! {
@ -516,10 +545,10 @@ pub fn RealmSceneViewer(
offset_y=offset_y_signal
prop_size=prop_size
z_index=z
active_bubble=bubble_signal
text_em_size=text_em_size
scene_width=scene_width_signal
scene_height=scene_height_signal
bounds_store=avatar_bounds_store
/>
}
}).collect_view()
@ -538,7 +567,7 @@ pub fn RealmSceneViewer(
if elapsed < fading.fade_duration {
let opacity = (1.0 - (elapsed as f64 / fading.fade_duration as f64)).max(0.0).min(1.0);
let member_signal = Signal::derive({ let m = fading.member.clone(); move || m.clone() });
let bubble_signal: Signal<Option<ActiveBubble>> = Signal::derive(|| None);
// Note: fading members don't need to update bounds store
Some(view! {
<AvatarCanvas
member=member_signal
@ -548,7 +577,6 @@ pub fn RealmSceneViewer(
offset_y=offset_y_signal
prop_size=prop_size
z_index=5
active_bubble=bubble_signal
text_em_size=text_em_size
opacity=opacity
scene_width=scene_width_signal
@ -560,9 +588,96 @@ pub fn RealmSceneViewer(
}}
</Show>
</div>
<div class="labels-container absolute inset-0" style="z-index: 3; pointer-events: none; overflow: visible;">
<Show when=move || scales_ready.get() fallback=|| ()>
// Debug dots at avatar bounds center
{move || {
members_by_key.get().into_iter().map(|(_, (_, m))| {
let user_id = m.member.user_id;
let dot_style = Memo::new(move |_| {
let bounds = avatar_bounds_store.get();
if let Some(ab) = bounds.get(&user_id) {
format!(
"position: absolute; left: {}px; top: {}px; \
width: 5px; height: 5px; background: red; \
border-radius: 50%; transform: translate(-50%, -50%); \
z-index: 99997;",
ab.content_center_x, ab.content_center_y
)
} else {
"display: none;".to_string()
}
});
view! {
<div style=dot_style data-debug="bounds-center" data-user-id=user_id.to_string() />
}
}).collect_view()
}}
// Active members
{move || {
members_by_key.get().into_iter().map(|(_, (_, m))| {
let user_id = m.member.user_id;
let display_name = m.member.display_name.clone();
view! {
<UsernameLabel
user_id=user_id
display_name=display_name
avatar_bounds_store=avatar_bounds_store
text_em_size=text_em_size
/>
}
}).collect_view()
}}
// Fading members
{move || {
let Some(fading_signal) = fading_members else {
return Vec::new().into_iter().collect_view();
};
#[cfg(feature = "hydrate")]
let now = js_sys::Date::now() as i64;
#[cfg(not(feature = "hydrate"))]
let now = 0i64;
fading_signal.get().into_iter().filter_map(|fading| {
let elapsed = now - fading.fade_start;
if elapsed < fading.fade_duration {
let opacity = (1.0 - (elapsed as f64 / fading.fade_duration as f64)).max(0.0).min(1.0);
let user_id = fading.member.member.user_id;
let display_name = fading.member.member.display_name.clone();
Some(view! {
<UsernameLabel
user_id=user_id
display_name=display_name
avatar_bounds_store=avatar_bounds_store
text_em_size=text_em_size
opacity=opacity
/>
})
} else { None }
}).collect_view()
}}
</Show>
</div>
<div class="bubbles-container absolute inset-0" style="z-index: 4; pointer-events: none; overflow: visible;">
<Show when=move || scales_ready.get() fallback=|| ()>
{move || {
active_bubbles.get().into_iter().map(|(user_id, bubble)| {
view! {
<SpeechBubble
user_id=user_id
bubble=bubble
avatar_bounds_store=avatar_bounds_store
bounds=scene_bounds_signal
text_em_size=text_em_size
/>
}
}).collect_view()
}}
</Show>
</div>
<div
class="click-overlay absolute inset-0"
style="z-index: 3; cursor: pointer;"
style="z-index: 5; cursor: pointer;"
aria-label=format!("Scene: {}", scene_name)
role="img"
on:click=move |ev| {