752 lines
35 KiB
Rust
752 lines
35 KiB
Rust
//! Scene viewer component for displaying realm scenes with avatars.
|
|
//!
|
|
//! Uses layered canvases for efficient rendering:
|
|
//! - Background canvas: Static, drawn once when scene loads
|
|
//! - Avatar canvas: Dynamic, redrawn when members change
|
|
//!
|
|
//! Supports two rendering modes:
|
|
//! - **Fit mode** (default): Background scales to fit viewport with letterboxing
|
|
//! - **Pan mode**: Canvas at native resolution with optional zoom, user can scroll
|
|
|
|
mod coordinates;
|
|
mod effects;
|
|
mod overlays;
|
|
|
|
pub use coordinates::*;
|
|
pub use overlays::*;
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use leptos::prelude::*;
|
|
use uuid::Uuid;
|
|
|
|
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene};
|
|
|
|
use super::avatar::{Avatar, 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::settings::{
|
|
BASE_AVATAR_SCALE, BASE_PROP_SIZE, REFERENCE_HEIGHT, REFERENCE_WIDTH, ViewerSettings,
|
|
calculate_min_zoom,
|
|
};
|
|
use super::ws_client::FadingMember;
|
|
use crate::utils::parse_bounds_dimensions;
|
|
|
|
/// Scene viewer component for displaying a realm scene with avatars.
|
|
///
|
|
/// Uses three layered canvases:
|
|
/// - Background canvas (z-index 0): Static background, drawn once
|
|
/// - Props canvas (z-index 1): Loose props, redrawn on drop/pickup
|
|
/// - Avatar canvas (z-index 2): Transparent, redrawn on member updates
|
|
#[component]
|
|
pub fn RealmSceneViewer(
|
|
scene: Scene,
|
|
#[allow(unused)]
|
|
realm_slug: String,
|
|
#[prop(into)] members: Signal<Vec<ChannelMemberWithAvatar>>,
|
|
#[prop(into)] active_bubbles: Signal<HashMap<Uuid, ActiveBubble>>,
|
|
#[prop(into)] loose_props: Signal<Vec<LooseProp>>,
|
|
#[prop(into)] on_move: Callback<(f64, f64)>,
|
|
#[prop(into)] on_prop_click: Callback<Uuid>,
|
|
#[prop(optional)] settings: Option<Signal<ViewerSettings>>,
|
|
#[prop(optional)] on_zoom_change: Option<Callback<f64>>,
|
|
#[prop(optional, into)] fading_members: Option<Signal<Vec<FadingMember>>>,
|
|
#[prop(optional, into)] current_user_id: Option<Signal<Option<Uuid>>>,
|
|
#[prop(optional, into)] is_guest: Option<Signal<bool>>,
|
|
#[prop(optional, into)] on_whisper_request: Option<Callback<String>>,
|
|
#[prop(optional, into)] is_moderator: Option<Signal<bool>>,
|
|
#[prop(optional, into)] on_prop_scale_update: Option<Callback<(Uuid, f32)>>,
|
|
#[prop(optional, into)] on_prop_move: Option<Callback<(Uuid, f64, f64)>>,
|
|
#[prop(optional, into)] on_prop_lock_toggle: Option<Callback<(Uuid, bool)>>,
|
|
#[prop(optional, into)] on_prop_delete: Option<Callback<Uuid>>,
|
|
#[prop(optional, into)] on_view_prop_state: Option<Callback<Uuid>>,
|
|
) -> impl IntoView {
|
|
let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false));
|
|
let settings = settings.unwrap_or_else(|| Signal::derive(ViewerSettings::default));
|
|
let dimensions = parse_bounds_dimensions(&scene.bounds_wkt);
|
|
let (scene_width, scene_height) = dimensions.unwrap_or((800, 600));
|
|
let scene_width_f = scene_width as f64;
|
|
let scene_height_f = scene_height as f64;
|
|
|
|
// Derived signals for rendering mode
|
|
let is_pan_mode = Signal::derive(move || settings.get().panning_enabled);
|
|
let (viewport_dimensions, set_viewport_dimensions) = signal((800.0_f64, 600.0_f64));
|
|
|
|
let effective_min_zoom = Signal::derive(move || {
|
|
let (vp_w, vp_h) = viewport_dimensions.get();
|
|
calculate_min_zoom(scene_width_f, scene_height_f, vp_w, vp_h)
|
|
});
|
|
|
|
let zoom_level = Signal::derive(move || {
|
|
let s = settings.get();
|
|
if s.panning_enabled {
|
|
let min_zoom = effective_min_zoom.get();
|
|
s.zoom_level.max(min_zoom)
|
|
} else {
|
|
1.0
|
|
}
|
|
});
|
|
|
|
let enlarge_props = Signal::derive(move || settings.get().enlarge_props);
|
|
|
|
let bg_color = scene
|
|
.background_color
|
|
.clone()
|
|
.unwrap_or_else(|| "#1a1a2e".to_string());
|
|
|
|
let has_background_image = scene.background_image_path.is_some();
|
|
let image_path = scene.background_image_path.clone().unwrap_or_default();
|
|
|
|
let bg_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
|
let outer_container_ref = NodeRef::<leptos::html::Div>::new();
|
|
|
|
// Scale/offset state
|
|
let (scale_x, set_scale_x) = signal(1.0_f64);
|
|
let (scale_y, set_scale_y) = signal(1.0_f64);
|
|
let (offset_x, set_offset_x) = signal(0.0_f64);
|
|
let (offset_y, set_offset_y) = signal(0.0_f64);
|
|
let (scales_ready, set_scales_ready) = signal(false);
|
|
|
|
// Context menu state (for avatar whisper)
|
|
let (context_menu_open, set_context_menu_open) = signal(false);
|
|
let (context_menu_position, set_context_menu_position) = signal((0.0_f64, 0.0_f64));
|
|
let (context_menu_target, set_context_menu_target) = signal(Option::<String>::None);
|
|
let (context_menu_username, set_context_menu_username) = signal(Option::<String>::None);
|
|
|
|
// Prop context menu state
|
|
let (prop_context_menu_open, set_prop_context_menu_open) = signal(false);
|
|
let (prop_context_menu_position, set_prop_context_menu_position) = signal((0.0_f64, 0.0_f64));
|
|
let (prop_context_menu_target, set_prop_context_menu_target) = signal(Option::<Uuid>::None);
|
|
|
|
// Scale mode state
|
|
let (scale_mode_active, set_scale_mode_active) = signal(false);
|
|
let (scale_mode_prop_id, set_scale_mode_prop_id) = signal(Option::<Uuid>::None);
|
|
let (scale_mode_initial_scale, set_scale_mode_initial_scale) = signal(1.0_f32);
|
|
let scale_mode_preview_scale = RwSignal::new(1.0_f32);
|
|
let (scale_mode_prop_center, set_scale_mode_prop_center) = signal((0.0_f64, 0.0_f64));
|
|
|
|
// Move mode state
|
|
let (move_mode_active, set_move_mode_active) = signal(false);
|
|
let (move_mode_prop_id, set_move_mode_prop_id) = signal(Option::<Uuid>::None);
|
|
let move_mode_preview_position = RwSignal::new((0.0_f64, 0.0_f64));
|
|
let (move_mode_prop_scale, set_move_mode_prop_scale) = signal(1.0_f32);
|
|
let (prop_context_is_locked, set_prop_context_is_locked) = signal(false);
|
|
let (prop_context_name, set_prop_context_name) = signal(Option::<String>::None);
|
|
|
|
// Click handler for movement (props are now handled via context menu)
|
|
#[cfg(feature = "hydrate")]
|
|
let on_overlay_click = {
|
|
let on_move = on_move.clone();
|
|
move |ev: web_sys::MouseEvent| {
|
|
use wasm_bindgen::JsCast;
|
|
|
|
let client_x = ev.client_x() as f64;
|
|
let client_y = ev.client_y() as f64;
|
|
|
|
let document = web_sys::window().unwrap().document().unwrap();
|
|
let mut clicked_prop: Option<Uuid> = None;
|
|
|
|
if let Some(container) = document.query_selector(".props-container").ok().flatten() {
|
|
let canvases = container.get_elements_by_tag_name("canvas");
|
|
for i in 0..canvases.length() {
|
|
if let Some(element) = canvases.item(i) {
|
|
if let Ok(canvas) = element.dyn_into::<web_sys::HtmlCanvasElement>() {
|
|
if let Some(prop_id_str) = canvas.get_attribute("data-prop-id") {
|
|
if hit_test_canvas(&canvas, client_x, client_y) {
|
|
if let Ok(prop_id) = prop_id_str.parse::<Uuid>() {
|
|
clicked_prop = Some(prop_id);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if clicked_prop.is_none() {
|
|
let target = ev.current_target().unwrap();
|
|
let element: web_sys::HtmlElement = target.dyn_into().unwrap();
|
|
let rect = element.get_bounding_client_rect();
|
|
|
|
let click_x = client_x - rect.left();
|
|
let click_y = client_y - rect.top();
|
|
|
|
let sx = scale_x.get();
|
|
let sy = scale_y.get();
|
|
let ox = offset_x.get();
|
|
let oy = offset_y.get();
|
|
|
|
if sx > 0.0 && sy > 0.0 {
|
|
let scene_x = ((click_x - ox) / sx).max(0.0).min(scene_width as f64);
|
|
let scene_y = ((click_y - oy) / sy).max(0.0).min(scene_height as f64);
|
|
on_move.run((scene_x, scene_y));
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Context menu handler
|
|
#[cfg(feature = "hydrate")]
|
|
let on_overlay_contextmenu = {
|
|
let current_user_id = current_user_id.clone();
|
|
move |ev: web_sys::MouseEvent| {
|
|
use wasm_bindgen::JsCast;
|
|
|
|
let client_x = ev.client_x() as f64;
|
|
let client_y = ev.client_y() as f64;
|
|
let document = web_sys::window().unwrap().document().unwrap();
|
|
|
|
// Check props first
|
|
if let Some(container) = document.query_selector(".props-container").ok().flatten() {
|
|
let canvases = container.get_elements_by_tag_name("canvas");
|
|
for i in 0..canvases.length() {
|
|
if let Some(element) = canvases.item(i) {
|
|
if let Ok(canvas) = element.dyn_into::<web_sys::HtmlCanvasElement>() {
|
|
if let Some(prop_id_str) = canvas.get_attribute("data-prop-id") {
|
|
if hit_test_canvas(&canvas, client_x, client_y) {
|
|
if let Ok(prop_id) = prop_id_str.parse::<Uuid>() {
|
|
if let Some(prop) = loose_props.get().iter().find(|p| p.id == prop_id) {
|
|
let is_mod = is_moderator.map(|s| s.get()).unwrap_or(false);
|
|
|
|
// Don't show menu if prop is locked and user is not a moderator
|
|
if prop.is_locked && !is_mod {
|
|
ev.prevent_default();
|
|
return;
|
|
}
|
|
|
|
ev.prevent_default();
|
|
set_prop_context_menu_position.set((client_x, client_y));
|
|
set_prop_context_menu_target.set(Some(prop_id));
|
|
set_prop_context_menu_open.set(true);
|
|
|
|
set_scale_mode_initial_scale.set(prop.scale);
|
|
set_prop_context_is_locked.set(prop.is_locked);
|
|
set_prop_context_name.set(Some(prop.prop_name.clone()));
|
|
set_move_mode_prop_scale.set(prop.scale);
|
|
let rect = canvas.get_bounding_client_rect();
|
|
set_scale_mode_prop_center.set((
|
|
rect.left() + rect.width() / 2.0,
|
|
rect.top() + rect.height() / 2.0,
|
|
));
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Guests cannot access avatar context menu
|
|
if is_guest.get() {
|
|
return;
|
|
}
|
|
|
|
let my_user_id = current_user_id.map(|s| s.get()).flatten();
|
|
|
|
// Check avatars
|
|
if let Some(container) = document.query_selector(".avatars-container").ok().flatten() {
|
|
let canvases = container.get_elements_by_tag_name("canvas");
|
|
for i in 0..canvases.length() {
|
|
if let Some(element) = canvases.item(i) {
|
|
if let Ok(canvas) = element.dyn_into::<web_sys::HtmlCanvasElement>() {
|
|
if let Some(member_id_str) = canvas.get_attribute("data-member-id") {
|
|
if hit_test_canvas(&canvas, client_x, client_y) {
|
|
if let Ok(member_id) = member_id_str.parse::<Uuid>() {
|
|
if my_user_id != Some(member_id) {
|
|
if let Some(member) = members.get().iter()
|
|
.find(|m| m.member.user_id == member_id)
|
|
{
|
|
ev.prevent_default();
|
|
set_context_menu_position.set((client_x, client_y));
|
|
set_context_menu_target.set(Some(member.member.display_name.clone()));
|
|
set_context_menu_username.set(Some(member.member.username.clone()));
|
|
set_context_menu_open.set(true);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Set up effects
|
|
#[cfg(feature = "hydrate")]
|
|
{
|
|
use std::cell::RefCell;
|
|
use std::rc::Rc;
|
|
|
|
let image_path_clone = image_path.clone();
|
|
let bg_color_clone = bg_color.clone();
|
|
|
|
// Viewport tracking
|
|
effects::setup_viewport_tracking(outer_container_ref, is_pan_mode, set_viewport_dimensions);
|
|
|
|
// Background drawing effect
|
|
let last_pan_mode = Rc::new(RefCell::new(None::<bool>));
|
|
let last_zoom = Rc::new(RefCell::new(None::<f64>));
|
|
let last_viewport = Rc::new(RefCell::new(None::<(f64, f64)>));
|
|
|
|
Effect::new(move |_| {
|
|
let current_pan_mode = is_pan_mode.get();
|
|
let current_zoom = zoom_level.get();
|
|
let current_viewport = viewport_dimensions.get();
|
|
|
|
let Some(canvas) = bg_canvas_ref.get() else { return };
|
|
|
|
let needs_redraw = {
|
|
let last_pan = *last_pan_mode.borrow();
|
|
let last_z = *last_zoom.borrow();
|
|
let last_vp = *last_viewport.borrow();
|
|
last_pan != Some(current_pan_mode)
|
|
|| (current_pan_mode && last_z != Some(current_zoom))
|
|
|| (!current_pan_mode && last_vp != Some(current_viewport))
|
|
};
|
|
|
|
if !needs_redraw { return }
|
|
|
|
*last_pan_mode.borrow_mut() = Some(current_pan_mode);
|
|
*last_zoom.borrow_mut() = Some(current_zoom);
|
|
*last_viewport.borrow_mut() = Some(current_viewport);
|
|
|
|
effects::draw_background(
|
|
&canvas,
|
|
&bg_color_clone,
|
|
&image_path_clone,
|
|
has_background_image,
|
|
scene_width_f,
|
|
scene_height_f,
|
|
current_pan_mode,
|
|
current_zoom,
|
|
set_scale_x,
|
|
set_scale_y,
|
|
set_offset_x,
|
|
set_offset_y,
|
|
set_scales_ready,
|
|
);
|
|
});
|
|
|
|
// Middle mouse pan
|
|
effects::setup_middle_mouse_pan(outer_container_ref, is_pan_mode);
|
|
|
|
// Wheel zoom
|
|
effects::setup_wheel_zoom(outer_container_ref, is_pan_mode, on_zoom_change);
|
|
}
|
|
|
|
let aspect_ratio = scene_width as f64 / scene_height as f64;
|
|
|
|
// Style computations
|
|
let container_class = move || {
|
|
if is_pan_mode.get() {
|
|
"scene-canvas relative cursor-pointer"
|
|
} else {
|
|
"scene-canvas relative overflow-hidden cursor-pointer"
|
|
}
|
|
};
|
|
|
|
let outer_container_class = move || {
|
|
if is_pan_mode.get() {
|
|
let zoom = zoom_level.get();
|
|
let (vp_w, vp_h) = viewport_dimensions.get();
|
|
let canvas_w = scene_width_f * zoom;
|
|
let canvas_h = scene_height_f * zoom;
|
|
|
|
if canvas_w <= vp_w && canvas_h <= vp_h {
|
|
"scene-container scene-viewer-container w-full overflow-auto flex justify-center items-center"
|
|
} else {
|
|
"scene-container scene-viewer-container w-full overflow-auto"
|
|
}
|
|
} else {
|
|
"scene-container scene-viewer-container w-full h-full flex justify-center items-center"
|
|
}
|
|
};
|
|
|
|
let outer_container_style = move || {
|
|
if is_pan_mode.get() {
|
|
"max-height: calc(100vh - 64px)".to_string()
|
|
} else {
|
|
String::new()
|
|
}
|
|
};
|
|
|
|
let container_style = move || {
|
|
if is_pan_mode.get() {
|
|
let zoom = zoom_level.get();
|
|
format!(
|
|
"width: {}px; height: {}px; background-color: {}",
|
|
(scene_width as f64 * zoom) as u32,
|
|
(scene_height as f64 * zoom) as u32,
|
|
bg_color
|
|
)
|
|
} else {
|
|
format!(
|
|
"aspect-ratio: {} / {}; width: min(100%, calc((100vh - 64px) * {})); max-height: calc(100vh - 64px); background-color: {}",
|
|
scene_width, scene_height, aspect_ratio, bg_color
|
|
)
|
|
}
|
|
};
|
|
|
|
let canvas_class = move || {
|
|
if is_pan_mode.get() { "absolute inset-0" } else { "absolute inset-0 w-full h-full" }
|
|
};
|
|
|
|
let canvas_style = move |z_index: i32| {
|
|
if is_pan_mode.get() {
|
|
let zoom = zoom_level.get();
|
|
format!(
|
|
"z-index: {}; width: {}px; height: {}px",
|
|
z_index,
|
|
(scene_width as f64 * zoom) as u32,
|
|
(scene_height as f64 * zoom) as u32
|
|
)
|
|
} else {
|
|
format!("z-index: {}; width: 100%; height: 100%", z_index)
|
|
}
|
|
};
|
|
|
|
// Member sorting and derived signals
|
|
let sorted_members = Signal::derive(move || {
|
|
let mut m = members.get();
|
|
m.sort_by(|a, b| b.member.joined_at.cmp(&a.member.joined_at));
|
|
m
|
|
});
|
|
|
|
let prop_size = Signal::derive(move || {
|
|
let current_pan_mode = is_pan_mode.get();
|
|
let current_zoom = zoom_level.get();
|
|
let current_enlarge = enlarge_props.get();
|
|
let sx = scale_x.get();
|
|
let sy = scale_y.get();
|
|
let ref_scale = (scene_width_f / REFERENCE_WIDTH).max(scene_height_f / REFERENCE_HEIGHT);
|
|
|
|
if current_pan_mode {
|
|
if current_enlarge {
|
|
BASE_PROP_SIZE * BASE_AVATAR_SCALE * ref_scale * current_zoom
|
|
} else {
|
|
BASE_PROP_SIZE * BASE_AVATAR_SCALE * current_zoom
|
|
}
|
|
} else if current_enlarge {
|
|
BASE_PROP_SIZE * BASE_AVATAR_SCALE * ref_scale * sx.min(sy)
|
|
} else {
|
|
BASE_PROP_SIZE * BASE_AVATAR_SCALE * sx.min(sy)
|
|
}
|
|
});
|
|
|
|
let text_em_size = Signal::derive(move || settings.get().text_em_size);
|
|
let scale_x_signal = Signal::derive(move || scale_x.get());
|
|
let scale_y_signal = Signal::derive(move || scale_y.get());
|
|
let offset_x_signal = Signal::derive(move || offset_x.get());
|
|
let offset_y_signal = Signal::derive(move || offset_y.get());
|
|
let scene_width_signal = Signal::derive(move || scene_width_f);
|
|
let scene_height_signal = Signal::derive(move || scene_height_f);
|
|
|
|
// Compute ScreenBounds once for all avatars (instead of each avatar computing it)
|
|
let screen_bounds = 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 members_by_key = Signal::derive(move || {
|
|
sorted_members.get().into_iter().enumerate()
|
|
.map(|(idx, m)| (member_key(&m), (idx, m)))
|
|
.collect::<HashMap<_, _>>()
|
|
});
|
|
|
|
let member_keys = Memo::new(move |_| {
|
|
sorted_members.get().iter().map(member_key).collect::<Vec<_>>()
|
|
});
|
|
|
|
let scene_name = scene.name.clone();
|
|
|
|
view! {
|
|
<div node_ref=outer_container_ref class=outer_container_class style=outer_container_style>
|
|
<div class=container_class style=container_style>
|
|
<canvas
|
|
node_ref=bg_canvas_ref
|
|
class=canvas_class
|
|
style=move || canvas_style(0)
|
|
aria-hidden="true"
|
|
/>
|
|
<div class="props-container absolute inset-0" style="z-index: 1; pointer-events: none;">
|
|
<Show when=move || scales_ready.get() fallback=|| ()>
|
|
{move || {
|
|
loose_props.get().into_iter().map(|prop| {
|
|
let prop_id = prop.id;
|
|
let prop_signal = Signal::derive(move || {
|
|
loose_props.get().into_iter().find(|p| p.id == prop_id).unwrap_or_else(|| prop.clone())
|
|
});
|
|
view! {
|
|
<LoosePropCanvas
|
|
prop=prop_signal
|
|
scale_x=scale_x_signal
|
|
scale_y=scale_y_signal
|
|
offset_x=offset_x_signal
|
|
offset_y=offset_y_signal
|
|
base_prop_size=prop_size
|
|
z_index=5
|
|
/>
|
|
}
|
|
}).collect_view()
|
|
}}
|
|
</Show>
|
|
</div>
|
|
<div class="avatars-container absolute inset-0" style="z-index: 2; pointer-events: none; overflow: visible;">
|
|
<Show when=move || scales_ready.get() fallback=|| ()>
|
|
{move || {
|
|
member_keys.get().into_iter().map(|key| {
|
|
let member_signal = Signal::derive(move || {
|
|
members_by_key.get().get(&key).map(|(_, m)| m.clone()).expect("member key should exist")
|
|
});
|
|
let z_index_signal = Signal::derive(move || {
|
|
members_by_key.get().get(&key).map(|(idx, _)| (*idx as i32) + 10).unwrap_or(10)
|
|
});
|
|
let z = z_index_signal.get_untracked();
|
|
// Derive bubble signal for this member
|
|
let bubble_signal = Signal::derive(move || {
|
|
active_bubbles.get().get(&key).cloned()
|
|
});
|
|
|
|
view! {
|
|
<Avatar
|
|
member=member_signal
|
|
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=z
|
|
text_em_size=text_em_size
|
|
screen_bounds=screen_bounds
|
|
active_bubble=bubble_signal
|
|
/>
|
|
}
|
|
}).collect_view()
|
|
}}
|
|
{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 member_signal = Signal::derive({ let m = fading.member.clone(); move || m.clone() });
|
|
// Fading members don't show bubbles
|
|
Some(view! {
|
|
<Avatar
|
|
member=member_signal
|
|
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
|
|
text_em_size=text_em_size
|
|
opacity=opacity
|
|
screen_bounds=screen_bounds
|
|
/>
|
|
})
|
|
} else { None }
|
|
}).collect_view()
|
|
}}
|
|
</Show>
|
|
</div>
|
|
<div
|
|
class="click-overlay absolute inset-0"
|
|
style="z-index: 5; cursor: pointer;"
|
|
aria-label=format!("Scene: {}", scene_name)
|
|
role="img"
|
|
on:click=move |ev| {
|
|
#[cfg(feature = "hydrate")]
|
|
on_overlay_click(ev);
|
|
#[cfg(not(feature = "hydrate"))]
|
|
let _ = ev;
|
|
}
|
|
on:contextmenu=move |ev| {
|
|
#[cfg(feature = "hydrate")]
|
|
on_overlay_contextmenu(ev);
|
|
#[cfg(not(feature = "hydrate"))]
|
|
let _ = ev;
|
|
}
|
|
/>
|
|
<ContextMenu
|
|
open=Signal::derive(move || context_menu_open.get())
|
|
position=Signal::derive(move || context_menu_position.get())
|
|
header=Signal::derive(move || context_menu_target.get())
|
|
items=Signal::derive(move || vec![
|
|
ContextMenuItem { label: "View Profile".to_string(), action: "view_profile".to_string() },
|
|
ContextMenuItem { label: "Whisper".to_string(), action: "whisper".to_string() },
|
|
])
|
|
on_select=Callback::new({
|
|
let on_whisper_request = on_whisper_request.clone();
|
|
move |action: String| {
|
|
match action.as_str() {
|
|
"view_profile" => {
|
|
if let Some(username) = context_menu_username.get() {
|
|
#[cfg(feature = "hydrate")]
|
|
{
|
|
let url = format!("/users/{}", username);
|
|
let _ = web_sys::window()
|
|
.unwrap()
|
|
.open_with_url_and_target(&url, "_blank");
|
|
}
|
|
}
|
|
}
|
|
"whisper" => {
|
|
if let Some(target) = context_menu_target.get() {
|
|
if let Some(ref callback) = on_whisper_request {
|
|
callback.run(target);
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
})
|
|
on_close=Callback::new(move |_: ()| {
|
|
set_context_menu_open.set(false);
|
|
set_context_menu_target.set(None);
|
|
set_context_menu_username.set(None);
|
|
})
|
|
/>
|
|
<ContextMenu
|
|
open=Signal::derive(move || prop_context_menu_open.get())
|
|
position=Signal::derive(move || prop_context_menu_position.get())
|
|
header=Signal::derive(move || {
|
|
prop_context_name.get().or_else(|| Some("Prop".to_string()))
|
|
})
|
|
items=Signal::derive(move || {
|
|
let is_mod = is_moderator.map(|s| s.get()).unwrap_or(false);
|
|
let is_locked = prop_context_is_locked.get();
|
|
let mut items = Vec::new();
|
|
// Always show View Info for props (to view state/business cards)
|
|
items.push(ContextMenuItem { label: "View Info".to_string(), action: "view_info".to_string() });
|
|
if !is_locked || is_mod {
|
|
items.push(ContextMenuItem { label: "Pick Up".to_string(), action: "pick_up".to_string() });
|
|
items.push(ContextMenuItem { label: "Move".to_string(), action: "move".to_string() });
|
|
}
|
|
if is_mod {
|
|
items.push(ContextMenuItem { label: "Set Scale".to_string(), action: "set_scale".to_string() });
|
|
items.push(ContextMenuItem {
|
|
label: if is_locked { "Unlock" } else { "Lock" }.to_string(),
|
|
action: if is_locked { "unlock" } else { "lock" }.to_string(),
|
|
});
|
|
items.push(ContextMenuItem { label: "Delete".to_string(), action: "delete".to_string() });
|
|
}
|
|
items
|
|
})
|
|
on_select=Callback::new({
|
|
let on_prop_lock_toggle = on_prop_lock_toggle.clone();
|
|
let on_prop_click = on_prop_click.clone();
|
|
let on_prop_delete = on_prop_delete.clone();
|
|
let on_view_prop_state = on_view_prop_state.clone();
|
|
move |action: String| {
|
|
match action.as_str() {
|
|
"view_info" => {
|
|
if let Some(prop_id) = prop_context_menu_target.get() {
|
|
if let Some(ref callback) = on_view_prop_state {
|
|
callback.run(prop_id);
|
|
}
|
|
}
|
|
}
|
|
"pick_up" => {
|
|
if let Some(prop_id) = prop_context_menu_target.get() {
|
|
on_prop_click.run(prop_id);
|
|
}
|
|
}
|
|
"set_scale" => {
|
|
if let Some(prop_id) = prop_context_menu_target.get() {
|
|
set_scale_mode_prop_id.set(Some(prop_id));
|
|
scale_mode_preview_scale.set(scale_mode_initial_scale.get());
|
|
set_scale_mode_active.set(true);
|
|
}
|
|
}
|
|
"move" => {
|
|
if let Some(prop_id) = prop_context_menu_target.get() {
|
|
set_move_mode_prop_id.set(Some(prop_id));
|
|
if let Some(prop) = loose_props.get().iter().find(|p| p.id == prop_id) {
|
|
move_mode_preview_position.set((prop.position_x, prop.position_y));
|
|
}
|
|
set_move_mode_active.set(true);
|
|
}
|
|
}
|
|
"lock" | "unlock" => {
|
|
if let Some(prop_id) = prop_context_menu_target.get() {
|
|
if let Some(ref callback) = on_prop_lock_toggle {
|
|
callback.run((prop_id, action == "lock"));
|
|
}
|
|
}
|
|
}
|
|
"delete" => {
|
|
if let Some(prop_id) = prop_context_menu_target.get() {
|
|
if let Some(ref callback) = on_prop_delete {
|
|
callback.run(prop_id);
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
set_prop_context_menu_open.set(false);
|
|
}
|
|
})
|
|
on_close=Callback::new(move |_: ()| {
|
|
set_prop_context_menu_open.set(false);
|
|
set_prop_context_menu_target.set(None);
|
|
set_prop_context_name.set(None);
|
|
})
|
|
/>
|
|
<ScaleOverlay
|
|
active=Signal::derive(move || scale_mode_active.get())
|
|
prop_id=Signal::derive(move || scale_mode_prop_id.get())
|
|
preview_scale=scale_mode_preview_scale
|
|
prop_center=Signal::derive(move || scale_mode_prop_center.get())
|
|
loose_props=loose_props
|
|
prop_size=prop_size
|
|
on_apply=on_prop_scale_update.unwrap_or_else(|| Callback::new(|_| {}))
|
|
on_cancel=Callback::new(move |_: ()| {
|
|
set_scale_mode_active.set(false);
|
|
set_scale_mode_prop_id.set(None);
|
|
})
|
|
/>
|
|
<MoveOverlay
|
|
active=Signal::derive(move || move_mode_active.get())
|
|
prop_id=Signal::derive(move || move_mode_prop_id.get())
|
|
preview_position=move_mode_preview_position
|
|
prop_scale=Signal::derive(move || move_mode_prop_scale.get())
|
|
loose_props=loose_props
|
|
prop_size=prop_size
|
|
scale_x=scale_x_signal
|
|
scale_y=scale_y_signal
|
|
offset_x=offset_x_signal
|
|
offset_y=offset_y_signal
|
|
on_apply=on_prop_move.unwrap_or_else(|| Callback::new(|_| {}))
|
|
on_cancel=Callback::new(move |_: ()| {
|
|
set_move_mode_active.set(false);
|
|
set_move_mode_prop_id.set(None);
|
|
})
|
|
/>
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|