fix: scaling, and chat
* Chat ergonomics vastly improved. * Scaling now done through client side settings
This commit is contained in:
parent
98f38c9714
commit
b430c80000
8 changed files with 1564 additions and 439 deletions
|
|
@ -3,6 +3,10 @@
|
|||
//! 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
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
|
|
@ -12,6 +16,9 @@ use uuid::Uuid;
|
|||
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene};
|
||||
|
||||
use super::chat_types::{emotion_bubble_colors, ActiveBubble};
|
||||
use super::settings::{
|
||||
calculate_min_zoom, ViewerSettings, BASE_PROP_SIZE, REFERENCE_HEIGHT, REFERENCE_WIDTH,
|
||||
};
|
||||
|
||||
/// Parse bounds WKT to extract width and height.
|
||||
///
|
||||
|
|
@ -72,9 +79,48 @@ pub fn RealmSceneViewer(
|
|||
on_move: Callback<(f64, f64)>,
|
||||
#[prop(into)]
|
||||
on_prop_click: Callback<Uuid>,
|
||||
/// Viewer settings for pan/zoom/enlarge modes.
|
||||
#[prop(optional)]
|
||||
settings: Option<Signal<ViewerSettings>>,
|
||||
/// Callback for zoom changes (from mouse wheel). Receives zoom delta.
|
||||
#[prop(optional)]
|
||||
on_zoom_change: Option<Callback<f64>>,
|
||||
) -> impl IntoView {
|
||||
// Use default settings if none provided
|
||||
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);
|
||||
|
||||
// Signal for viewport dimensions (outer container size)
|
||||
// Used to calculate effective minimum zoom in pan mode
|
||||
let (viewport_dimensions, set_viewport_dimensions) = signal((800.0_f64, 600.0_f64));
|
||||
|
||||
// Calculate effective minimum zoom based on scene and viewport
|
||||
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)
|
||||
});
|
||||
|
||||
// Zoom level clamped to effective minimum
|
||||
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 || {
|
||||
let s = settings.get();
|
||||
!s.panning_enabled && s.enlarge_props
|
||||
});
|
||||
|
||||
let bg_color = scene
|
||||
.background_color
|
||||
|
|
@ -91,6 +137,9 @@ pub fn RealmSceneViewer(
|
|||
let props_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
||||
let avatar_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
||||
|
||||
// Outer container ref for middle-mouse drag scrolling
|
||||
let outer_container_ref = NodeRef::<leptos::html::Div>::new();
|
||||
|
||||
// Store scale factors for coordinate conversion (shared between both canvases)
|
||||
let scale_x = StoredValue::new(1.0_f64);
|
||||
let scale_y = StoredValue::new(1.0_f64);
|
||||
|
|
@ -160,103 +209,193 @@ pub fn RealmSceneViewer(
|
|||
|
||||
let image_path_clone = image_path.clone();
|
||||
let bg_color_clone = bg_color.clone();
|
||||
let scene_width_f = scene_width as f64;
|
||||
let scene_height_f = scene_height as f64;
|
||||
|
||||
// Flag to track if background has been drawn
|
||||
let bg_drawn = Rc::new(RefCell::new(false));
|
||||
|
||||
// =========================================================
|
||||
// Background Effect - runs once on mount, draws static background
|
||||
// Viewport Dimensions Effect - tracks outer container size
|
||||
// Uses setTimeout to ensure DOM is ready after mount
|
||||
// =========================================================
|
||||
Effect::new(move |_| {
|
||||
// Don't track any reactive signals - this should only run once
|
||||
// Track pan mode to re-run when it changes (affects container layout)
|
||||
let _ = is_pan_mode.get();
|
||||
|
||||
let Some(container) = outer_container_ref.get() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let container_el: web_sys::HtmlElement = container.into();
|
||||
|
||||
// Use setTimeout to ensure DOM has settled after any layout changes
|
||||
let update_dimensions = Closure::once(Box::new(move || {
|
||||
let width = container_el.client_width() as f64;
|
||||
let height = container_el.client_height() as f64;
|
||||
|
||||
if width > 0.0 && height > 0.0 {
|
||||
set_viewport_dimensions.set((width, height));
|
||||
}
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
let window = web_sys::window().unwrap();
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
update_dimensions.as_ref().unchecked_ref(),
|
||||
150, // Slightly longer delay to ensure DOM settles
|
||||
);
|
||||
update_dimensions.forget();
|
||||
});
|
||||
|
||||
// Track the last settings to detect changes
|
||||
let last_pan_mode = Rc::new(RefCell::new(None::<bool>));
|
||||
let last_zoom = Rc::new(RefCell::new(None::<f64>));
|
||||
|
||||
// =========================================================
|
||||
// Background Effect - redraws when settings change
|
||||
// =========================================================
|
||||
Effect::new(move |_| {
|
||||
// Track settings signals - this Effect reruns when they change
|
||||
let current_pan_mode = is_pan_mode.get();
|
||||
let current_zoom = zoom_level.get();
|
||||
|
||||
let Some(canvas) = bg_canvas_ref.get() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Skip if already drawn
|
||||
if *bg_drawn.borrow() {
|
||||
// Check if we need to redraw (settings changed or first render)
|
||||
let needs_redraw = {
|
||||
let last_pan = *last_pan_mode.borrow();
|
||||
let last_z = *last_zoom.borrow();
|
||||
last_pan != Some(current_pan_mode)
|
||||
|| (current_pan_mode && last_z != Some(current_zoom))
|
||||
};
|
||||
|
||||
if !needs_redraw {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update last values
|
||||
*last_pan_mode.borrow_mut() = Some(current_pan_mode);
|
||||
*last_zoom.borrow_mut() = Some(current_zoom);
|
||||
|
||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||
let canvas_el = canvas_el.clone();
|
||||
let bg_color = bg_color_clone.clone();
|
||||
let image_path = image_path_clone.clone();
|
||||
let bg_drawn_inner = bg_drawn.clone();
|
||||
|
||||
// Use setTimeout to ensure DOM is ready before drawing
|
||||
let draw_bg = Closure::once(Box::new(move || {
|
||||
let display_width = canvas_el.client_width() as u32;
|
||||
let display_height = canvas_el.client_height() as u32;
|
||||
if current_pan_mode {
|
||||
// Pan mode: canvas at native resolution * zoom
|
||||
let canvas_width = (scene_width_f * current_zoom) as u32;
|
||||
let canvas_height = (scene_height_f * current_zoom) as u32;
|
||||
|
||||
// If still no dimensions, the canvas likely isn't visible - skip drawing
|
||||
if display_width == 0 || display_height == 0 {
|
||||
return;
|
||||
}
|
||||
canvas_el.set_width(canvas_width);
|
||||
canvas_el.set_height(canvas_height);
|
||||
|
||||
canvas_el.set_width(display_width);
|
||||
canvas_el.set_height(display_height);
|
||||
// Store scale factors (zoom level, no offset)
|
||||
scale_x.set_value(current_zoom);
|
||||
scale_y.set_value(current_zoom);
|
||||
offset_x.set_value(0.0);
|
||||
offset_y.set_value(0.0);
|
||||
|
||||
// Calculate scale to fit scene in canvas
|
||||
let canvas_aspect = display_width as f64 / display_height as f64;
|
||||
let scene_aspect = scene_width_f / scene_height_f;
|
||||
// Signal that scale factors are ready
|
||||
set_scales_ready.set(true);
|
||||
|
||||
let (draw_width, draw_height, draw_x, draw_y) = if canvas_aspect > scene_aspect {
|
||||
let h = display_height as f64;
|
||||
let w = h * scene_aspect;
|
||||
let x = (display_width as f64 - w) / 2.0;
|
||||
(w, h, x, 0.0)
|
||||
if let Ok(Some(ctx)) = canvas_el.get_context("2d") {
|
||||
let ctx: web_sys::CanvasRenderingContext2d =
|
||||
ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap();
|
||||
|
||||
// Fill entire canvas with background color (no letterboxing)
|
||||
ctx.set_fill_style_str(&bg_color);
|
||||
ctx.fill_rect(0.0, 0.0, canvas_width as f64, canvas_height as f64);
|
||||
|
||||
// Draw background image if available
|
||||
if has_background_image && !image_path.is_empty() {
|
||||
let img = web_sys::HtmlImageElement::new().unwrap();
|
||||
let img_clone = img.clone();
|
||||
let ctx_clone = ctx.clone();
|
||||
|
||||
let onload = Closure::once(Box::new(move || {
|
||||
let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh(
|
||||
&img_clone,
|
||||
0.0,
|
||||
0.0,
|
||||
canvas_width as f64,
|
||||
canvas_height as f64,
|
||||
);
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
img.set_onload(Some(onload.as_ref().unchecked_ref()));
|
||||
onload.forget();
|
||||
img.set_src(&image_path);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let w = display_width as f64;
|
||||
let h = w / scene_aspect;
|
||||
let y = (display_height as f64 - h) / 2.0;
|
||||
(w, h, 0.0, y)
|
||||
};
|
||||
// Fit mode: scale to viewport with letterboxing
|
||||
let display_width = canvas_el.client_width() as u32;
|
||||
let display_height = canvas_el.client_height() as u32;
|
||||
|
||||
// Store scale factors
|
||||
let sx = draw_width / scene_width_f;
|
||||
let sy = draw_height / scene_height_f;
|
||||
scale_x.set_value(sx);
|
||||
scale_y.set_value(sy);
|
||||
offset_x.set_value(draw_x);
|
||||
offset_y.set_value(draw_y);
|
||||
|
||||
// Signal that scale factors are ready
|
||||
set_scales_ready.set(true);
|
||||
|
||||
if let Ok(Some(ctx)) = canvas_el.get_context("2d") {
|
||||
let ctx: web_sys::CanvasRenderingContext2d =
|
||||
ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap();
|
||||
|
||||
// Fill letterbox area with black
|
||||
ctx.set_fill_style_str("#000");
|
||||
ctx.fill_rect(0.0, 0.0, display_width as f64, display_height as f64);
|
||||
|
||||
// Fill scene area with background color
|
||||
ctx.set_fill_style_str(&bg_color);
|
||||
ctx.fill_rect(draw_x, draw_y, draw_width, draw_height);
|
||||
|
||||
// Draw background image if available
|
||||
if has_background_image && !image_path.is_empty() {
|
||||
let img = web_sys::HtmlImageElement::new().unwrap();
|
||||
let img_clone = img.clone();
|
||||
let ctx_clone = ctx.clone();
|
||||
|
||||
let onload = Closure::once(Box::new(move || {
|
||||
let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh(
|
||||
&img_clone, draw_x, draw_y, draw_width, draw_height,
|
||||
);
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
img.set_onload(Some(onload.as_ref().unchecked_ref()));
|
||||
onload.forget();
|
||||
img.set_src(&image_path);
|
||||
// If still no dimensions, the canvas likely isn't visible - skip drawing
|
||||
if display_width == 0 || display_height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark background as drawn
|
||||
*bg_drawn_inner.borrow_mut() = true;
|
||||
canvas_el.set_width(display_width);
|
||||
canvas_el.set_height(display_height);
|
||||
|
||||
// Calculate scale to fit scene in canvas
|
||||
let canvas_aspect = display_width as f64 / display_height as f64;
|
||||
let scene_aspect = scene_width_f / scene_height_f;
|
||||
|
||||
let (draw_width, draw_height, draw_x, draw_y) = if canvas_aspect > scene_aspect {
|
||||
let h = display_height as f64;
|
||||
let w = h * scene_aspect;
|
||||
let x = (display_width as f64 - w) / 2.0;
|
||||
(w, h, x, 0.0)
|
||||
} else {
|
||||
let w = display_width as f64;
|
||||
let h = w / scene_aspect;
|
||||
let y = (display_height as f64 - h) / 2.0;
|
||||
(w, h, 0.0, y)
|
||||
};
|
||||
|
||||
// Store scale factors
|
||||
let sx = draw_width / scene_width_f;
|
||||
let sy = draw_height / scene_height_f;
|
||||
scale_x.set_value(sx);
|
||||
scale_y.set_value(sy);
|
||||
offset_x.set_value(draw_x);
|
||||
offset_y.set_value(draw_y);
|
||||
|
||||
// Signal that scale factors are ready
|
||||
set_scales_ready.set(true);
|
||||
|
||||
if let Ok(Some(ctx)) = canvas_el.get_context("2d") {
|
||||
let ctx: web_sys::CanvasRenderingContext2d =
|
||||
ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap();
|
||||
|
||||
// Fill letterbox area with black
|
||||
ctx.set_fill_style_str("#000");
|
||||
ctx.fill_rect(0.0, 0.0, display_width as f64, display_height as f64);
|
||||
|
||||
// Fill scene area with background color
|
||||
ctx.set_fill_style_str(&bg_color);
|
||||
ctx.fill_rect(draw_x, draw_y, draw_width, draw_height);
|
||||
|
||||
// Draw background image if available
|
||||
if has_background_image && !image_path.is_empty() {
|
||||
let img = web_sys::HtmlImageElement::new().unwrap();
|
||||
let img_clone = img.clone();
|
||||
let ctx_clone = ctx.clone();
|
||||
|
||||
let onload = Closure::once(Box::new(move || {
|
||||
let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh(
|
||||
&img_clone, draw_x, draw_y, draw_width, draw_height,
|
||||
);
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
img.set_onload(Some(onload.as_ref().unchecked_ref()));
|
||||
onload.forget();
|
||||
img.set_src(&image_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
|
|
@ -270,12 +409,15 @@ pub fn RealmSceneViewer(
|
|||
});
|
||||
|
||||
// =========================================================
|
||||
// Avatar Effect - runs when members or bubbles change
|
||||
// Avatar Effect - runs when members, bubbles, or settings change
|
||||
// =========================================================
|
||||
Effect::new(move |_| {
|
||||
// Track both signals - this Effect reruns when either changes
|
||||
// Track signals - this Effect reruns when any changes
|
||||
let current_members = members.get();
|
||||
let current_bubbles = active_bubbles.get();
|
||||
let current_pan_mode = is_pan_mode.get();
|
||||
let current_zoom = zoom_level.get();
|
||||
let current_enlarge = enlarge_props.get();
|
||||
|
||||
// Skip drawing if scale factors haven't been calculated yet
|
||||
if !scales_ready.get() {
|
||||
|
|
@ -290,25 +432,19 @@ pub fn RealmSceneViewer(
|
|||
let canvas_el = canvas_el.clone();
|
||||
|
||||
let draw_avatars_closure = Closure::once(Box::new(move || {
|
||||
let display_width = canvas_el.client_width() as u32;
|
||||
let display_height = canvas_el.client_height() as u32;
|
||||
let canvas_width = canvas_el.width();
|
||||
let canvas_height = canvas_el.height();
|
||||
|
||||
if display_width == 0 || display_height == 0 {
|
||||
if canvas_width == 0 || canvas_height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resize avatar canvas to match (if needed)
|
||||
if canvas_el.width() != display_width || canvas_el.height() != display_height {
|
||||
canvas_el.set_width(display_width);
|
||||
canvas_el.set_height(display_height);
|
||||
}
|
||||
|
||||
if let Ok(Some(ctx)) = canvas_el.get_context("2d") {
|
||||
let ctx: web_sys::CanvasRenderingContext2d =
|
||||
ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap();
|
||||
|
||||
// Clear with transparency (not fill - keeps canvas transparent)
|
||||
ctx.clear_rect(0.0, 0.0, display_width as f64, display_height as f64);
|
||||
ctx.clear_rect(0.0, 0.0, canvas_width as f64, canvas_height as f64);
|
||||
|
||||
// Get stored scale factors
|
||||
let sx = scale_x.get_value();
|
||||
|
|
@ -316,8 +452,19 @@ pub fn RealmSceneViewer(
|
|||
let ox = offset_x.get_value();
|
||||
let oy = offset_y.get_value();
|
||||
|
||||
// Calculate prop size based on mode
|
||||
let prop_size = calculate_prop_size(
|
||||
current_pan_mode,
|
||||
current_zoom,
|
||||
current_enlarge,
|
||||
sx,
|
||||
sy,
|
||||
scene_width_f,
|
||||
scene_height_f,
|
||||
);
|
||||
|
||||
// Draw avatars first
|
||||
draw_avatars(&ctx, ¤t_members, sx, sy, ox, oy);
|
||||
draw_avatars(&ctx, ¤t_members, sx, sy, ox, oy, prop_size);
|
||||
|
||||
// Draw speech bubbles on top
|
||||
let current_time = js_sys::Date::now() as i64;
|
||||
|
|
@ -330,6 +477,7 @@ pub fn RealmSceneViewer(
|
|||
ox,
|
||||
oy,
|
||||
current_time,
|
||||
prop_size,
|
||||
);
|
||||
}
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
|
@ -340,11 +488,14 @@ pub fn RealmSceneViewer(
|
|||
});
|
||||
|
||||
// =========================================================
|
||||
// Props Effect - runs when loose_props changes
|
||||
// Props Effect - runs when loose_props or settings change
|
||||
// =========================================================
|
||||
Effect::new(move |_| {
|
||||
// Track loose_props signal
|
||||
// Track signals
|
||||
let current_props = loose_props.get();
|
||||
let current_pan_mode = is_pan_mode.get();
|
||||
let current_zoom = zoom_level.get();
|
||||
let current_enlarge = enlarge_props.get();
|
||||
|
||||
// Skip drawing if scale factors haven't been calculated yet
|
||||
if !scales_ready.get() {
|
||||
|
|
@ -359,25 +510,19 @@ pub fn RealmSceneViewer(
|
|||
let canvas_el = canvas_el.clone();
|
||||
|
||||
let draw_props_closure = Closure::once(Box::new(move || {
|
||||
let display_width = canvas_el.client_width() as u32;
|
||||
let display_height = canvas_el.client_height() as u32;
|
||||
let canvas_width = canvas_el.width();
|
||||
let canvas_height = canvas_el.height();
|
||||
|
||||
if display_width == 0 || display_height == 0 {
|
||||
if canvas_width == 0 || canvas_height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resize props canvas to match (if needed)
|
||||
if canvas_el.width() != display_width || canvas_el.height() != display_height {
|
||||
canvas_el.set_width(display_width);
|
||||
canvas_el.set_height(display_height);
|
||||
}
|
||||
|
||||
if let Ok(Some(ctx)) = canvas_el.get_context("2d") {
|
||||
let ctx: web_sys::CanvasRenderingContext2d =
|
||||
ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap();
|
||||
|
||||
// Clear with transparency
|
||||
ctx.clear_rect(0.0, 0.0, display_width as f64, display_height as f64);
|
||||
ctx.clear_rect(0.0, 0.0, canvas_width as f64, canvas_height as f64);
|
||||
|
||||
// Get stored scale factors
|
||||
let sx = scale_x.get_value();
|
||||
|
|
@ -385,8 +530,19 @@ pub fn RealmSceneViewer(
|
|||
let ox = offset_x.get_value();
|
||||
let oy = offset_y.get_value();
|
||||
|
||||
// Calculate prop size based on mode
|
||||
let prop_size = calculate_prop_size(
|
||||
current_pan_mode,
|
||||
current_zoom,
|
||||
current_enlarge,
|
||||
sx,
|
||||
sy,
|
||||
scene_width_f,
|
||||
scene_height_f,
|
||||
);
|
||||
|
||||
// Draw loose props
|
||||
draw_loose_props(&ctx, ¤t_props, sx, sy, ox, oy);
|
||||
draw_loose_props(&ctx, ¤t_props, sx, sy, ox, oy, prop_size);
|
||||
}
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
|
|
@ -394,38 +550,312 @@ pub fn RealmSceneViewer(
|
|||
let _ = window.request_animation_frame(draw_props_closure.as_ref().unchecked_ref());
|
||||
draw_props_closure.forget();
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// Sync canvas sizes when mode or zoom changes
|
||||
// =========================================================
|
||||
Effect::new(move |_| {
|
||||
let current_pan_mode = is_pan_mode.get();
|
||||
let current_zoom = zoom_level.get();
|
||||
|
||||
// Wait for scales to be ready (background drawn)
|
||||
if !scales_ready.get() {
|
||||
return;
|
||||
}
|
||||
|
||||
if current_pan_mode {
|
||||
// Pan mode: resize props and avatar canvases to match background
|
||||
let canvas_width = (scene_width_f * current_zoom) as u32;
|
||||
let canvas_height = (scene_height_f * current_zoom) as u32;
|
||||
|
||||
if let Some(canvas) = props_canvas_ref.get() {
|
||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||
if canvas_el.width() != canvas_width || canvas_el.height() != canvas_height {
|
||||
canvas_el.set_width(canvas_width);
|
||||
canvas_el.set_height(canvas_height);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(canvas) = avatar_canvas_ref.get() {
|
||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||
if canvas_el.width() != canvas_width || canvas_el.height() != canvas_height {
|
||||
canvas_el.set_width(canvas_width);
|
||||
canvas_el.set_height(canvas_height);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fit mode: sync props and avatar canvases to background canvas size
|
||||
if let Some(bg_canvas) = bg_canvas_ref.get() {
|
||||
let bg_el: &web_sys::HtmlCanvasElement = &bg_canvas;
|
||||
let canvas_width = bg_el.width();
|
||||
let canvas_height = bg_el.height();
|
||||
|
||||
if canvas_width > 0 && canvas_height > 0 {
|
||||
if let Some(canvas) = props_canvas_ref.get() {
|
||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||
if canvas_el.width() != canvas_width || canvas_el.height() != canvas_height {
|
||||
canvas_el.set_width(canvas_width);
|
||||
canvas_el.set_height(canvas_height);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(canvas) = avatar_canvas_ref.get() {
|
||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||
if canvas_el.width() != canvas_width || canvas_el.height() != canvas_height {
|
||||
canvas_el.set_width(canvas_width);
|
||||
canvas_el.set_height(canvas_height);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// Middle mouse button drag-to-pan (only in pan mode)
|
||||
// =========================================================
|
||||
Effect::new(move |_| {
|
||||
// Track pan mode - re-run when it changes
|
||||
let pan_mode_enabled = is_pan_mode.get();
|
||||
|
||||
let Some(container) = outer_container_ref.get() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let container_el: web_sys::HtmlElement = container.into();
|
||||
|
||||
if !pan_mode_enabled {
|
||||
// Reset cursor when not in pan mode
|
||||
let _ = container_el.style().set_property("cursor", "");
|
||||
return;
|
||||
}
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
|
||||
let is_dragging = Rc::new(Cell::new(false));
|
||||
let last_x = Rc::new(Cell::new(0i32));
|
||||
let last_y = Rc::new(Cell::new(0i32));
|
||||
|
||||
let container_for_move = container_el.clone();
|
||||
let is_dragging_move = is_dragging.clone();
|
||||
let last_x_move = last_x.clone();
|
||||
let last_y_move = last_y.clone();
|
||||
|
||||
let container_for_down = container_el.clone();
|
||||
let is_dragging_down = is_dragging.clone();
|
||||
let last_x_down = last_x.clone();
|
||||
let last_y_down = last_y.clone();
|
||||
|
||||
// Middle mouse down - start drag
|
||||
let onmousedown = Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
|
||||
// Button 1 is middle mouse button
|
||||
if ev.button() == 1 {
|
||||
is_dragging_down.set(true);
|
||||
last_x_down.set(ev.client_x());
|
||||
last_y_down.set(ev.client_y());
|
||||
let _ = container_for_down.style().set_property("cursor", "grabbing");
|
||||
ev.prevent_default();
|
||||
}
|
||||
});
|
||||
|
||||
// Mouse move - drag scroll
|
||||
let onmousemove = Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
|
||||
if is_dragging_move.get() {
|
||||
let dx = last_x_move.get() - ev.client_x();
|
||||
let dy = last_y_move.get() - ev.client_y();
|
||||
last_x_move.set(ev.client_x());
|
||||
last_y_move.set(ev.client_y());
|
||||
container_for_move.set_scroll_left(container_for_move.scroll_left() + dx);
|
||||
container_for_move.set_scroll_top(container_for_move.scroll_top() + dy);
|
||||
}
|
||||
});
|
||||
|
||||
let container_for_up = container_el.clone();
|
||||
let is_dragging_up = is_dragging.clone();
|
||||
|
||||
// Mouse up - stop drag
|
||||
let onmouseup = Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |_ev: web_sys::MouseEvent| {
|
||||
if is_dragging_up.get() {
|
||||
is_dragging_up.set(false);
|
||||
let _ = container_for_up.style().set_property("cursor", "");
|
||||
}
|
||||
});
|
||||
|
||||
// Add event listeners
|
||||
let _ = container_el.add_event_listener_with_callback(
|
||||
"mousedown",
|
||||
onmousedown.as_ref().unchecked_ref(),
|
||||
);
|
||||
let _ = container_el.add_event_listener_with_callback(
|
||||
"mousemove",
|
||||
onmousemove.as_ref().unchecked_ref(),
|
||||
);
|
||||
let _ = container_el.add_event_listener_with_callback(
|
||||
"mouseup",
|
||||
onmouseup.as_ref().unchecked_ref(),
|
||||
);
|
||||
|
||||
// Also listen for mouseup on window (in case mouse released outside container)
|
||||
if let Some(window) = web_sys::window() {
|
||||
let is_dragging_window = is_dragging.clone();
|
||||
let container_for_window = container_el.clone();
|
||||
let onmouseup_window = Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |_ev: web_sys::MouseEvent| {
|
||||
if is_dragging_window.get() {
|
||||
is_dragging_window.set(false);
|
||||
let _ = container_for_window.style().set_property("cursor", "");
|
||||
}
|
||||
});
|
||||
let _ = window.add_event_listener_with_callback(
|
||||
"mouseup",
|
||||
onmouseup_window.as_ref().unchecked_ref(),
|
||||
);
|
||||
onmouseup_window.forget();
|
||||
}
|
||||
|
||||
// Prevent context menu on middle click
|
||||
let oncontextmenu = Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
|
||||
if ev.button() == 1 {
|
||||
ev.prevent_default();
|
||||
}
|
||||
});
|
||||
let _ = container_el.add_event_listener_with_callback(
|
||||
"auxclick",
|
||||
oncontextmenu.as_ref().unchecked_ref(),
|
||||
);
|
||||
|
||||
// Forget closures to keep them alive
|
||||
onmousedown.forget();
|
||||
onmousemove.forget();
|
||||
onmouseup.forget();
|
||||
oncontextmenu.forget();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
// Create wheel handler closure for use in view
|
||||
let handle_wheel = move |ev: leptos::web_sys::WheelEvent| {
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
// Only zoom in pan mode and without Ctrl key
|
||||
if is_pan_mode.get() && !ev.ctrl_key() {
|
||||
if let Some(zoom_callback) = on_zoom_change {
|
||||
let delta_y = ev.delta_y();
|
||||
// Normalize: scroll up (negative deltaY) = zoom in (positive delta)
|
||||
// Scroll down (positive deltaY) = zoom out (negative delta)
|
||||
let zoom_delta = if delta_y < 0.0 { 0.1 } else { -0.1 };
|
||||
zoom_callback.run(zoom_delta);
|
||||
ev.prevent_default();
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
let _ = ev;
|
||||
};
|
||||
|
||||
let aspect_ratio = scene_width as f64 / scene_height as f64;
|
||||
|
||||
// Computed styles based on mode
|
||||
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;
|
||||
|
||||
// Center canvas if smaller than viewport in both dimensions
|
||||
if canvas_w <= vp_w && canvas_h <= vp_h {
|
||||
"scene-container w-full overflow-auto flex justify-center items-center"
|
||||
} else {
|
||||
"scene-container w-full overflow-auto"
|
||||
}
|
||||
} else {
|
||||
"scene-container w-full h-full flex justify-center items-center"
|
||||
}
|
||||
};
|
||||
|
||||
// Outer container needs max-height in pan mode to enable vertical scrolling
|
||||
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)
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="scene-container w-full h-full flex justify-center items-center">
|
||||
<div node_ref=outer_container_ref class=outer_container_class style=outer_container_style on:wheel=handle_wheel>
|
||||
<div
|
||||
class="scene-canvas relative overflow-hidden cursor-pointer"
|
||||
style:background-color=bg_color.clone()
|
||||
style:aspect-ratio=format!("{} / {}", scene_width, scene_height)
|
||||
style:width=format!("min(100%, calc((100vh - 64px) * {}))", aspect_ratio)
|
||||
style:max-height="calc(100vh - 64px)"
|
||||
class=container_class
|
||||
style=container_style
|
||||
>
|
||||
// Background layer - static, drawn once
|
||||
<canvas
|
||||
node_ref=bg_canvas_ref
|
||||
class="absolute inset-0 w-full h-full"
|
||||
style="z-index: 0"
|
||||
class=canvas_class
|
||||
style=move || canvas_style(0)
|
||||
aria-hidden="true"
|
||||
/>
|
||||
// Props layer - loose props, redrawn on drop/pickup
|
||||
<canvas
|
||||
node_ref=props_canvas_ref
|
||||
class="absolute inset-0 w-full h-full"
|
||||
style="z-index: 1"
|
||||
class=canvas_class
|
||||
style=move || canvas_style(1)
|
||||
aria-hidden="true"
|
||||
/>
|
||||
// Avatar layer - dynamic, transparent background
|
||||
<canvas
|
||||
node_ref=avatar_canvas_ref
|
||||
class="absolute inset-0 w-full h-full"
|
||||
style="z-index: 2"
|
||||
class=canvas_class
|
||||
style=move || canvas_style(2)
|
||||
aria-label=format!("Scene: {}", scene.name)
|
||||
role="img"
|
||||
on:click=move |ev| {
|
||||
|
|
@ -445,6 +875,34 @@ pub fn RealmSceneViewer(
|
|||
#[cfg(feature = "hydrate")]
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
/// Calculate prop/avatar size based on current rendering mode.
|
||||
///
|
||||
/// - Pan mode: BASE_PROP_SIZE * zoom_level
|
||||
/// - Fit mode with enlarge: Reference scaling based on 1920x1080
|
||||
/// - Fit mode without enlarge: BASE_PROP_SIZE * min(scale_x, scale_y)
|
||||
#[cfg(feature = "hydrate")]
|
||||
fn calculate_prop_size(
|
||||
pan_mode: bool,
|
||||
zoom_level: f64,
|
||||
enlarge_props: bool,
|
||||
scale_x: f64,
|
||||
scale_y: f64,
|
||||
scene_width: f64,
|
||||
scene_height: f64,
|
||||
) -> f64 {
|
||||
if pan_mode {
|
||||
// Pan mode: base size * zoom
|
||||
BASE_PROP_SIZE * zoom_level
|
||||
} else if enlarge_props {
|
||||
// Reference scaling: scale props relative to 1920x1080 reference
|
||||
let ref_scale = (scene_width / REFERENCE_WIDTH).max(scene_height / REFERENCE_HEIGHT);
|
||||
BASE_PROP_SIZE * ref_scale * scale_x.min(scale_y)
|
||||
} else {
|
||||
// Default: base size scaled to viewport
|
||||
BASE_PROP_SIZE * scale_x.min(scale_y)
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize an asset path to be absolute, prefixing with /static/ if needed.
|
||||
#[cfg(feature = "hydrate")]
|
||||
fn normalize_asset_path(path: &str) -> String {
|
||||
|
|
@ -463,13 +921,14 @@ fn draw_avatars(
|
|||
scale_y: f64,
|
||||
offset_x: f64,
|
||||
offset_y: f64,
|
||||
prop_size: f64,
|
||||
) {
|
||||
let avatar_size = prop_size;
|
||||
|
||||
for member in members {
|
||||
let x = member.member.position_x * scale_x + offset_x;
|
||||
let y = member.member.position_y * scale_y + offset_y;
|
||||
|
||||
let avatar_size = 60.0 * scale_x.min(scale_y);
|
||||
|
||||
// Draw avatar placeholder circle
|
||||
ctx.begin_path();
|
||||
let _ = ctx.arc(x, y - avatar_size / 2.0, avatar_size / 2.0, 0.0, std::f64::consts::PI * 2.0);
|
||||
|
|
@ -516,11 +975,14 @@ fn draw_avatars(
|
|||
img.set_src(&normalize_asset_path(emotion_path));
|
||||
}
|
||||
|
||||
// Scale factor for text/badges relative to avatar size
|
||||
let text_scale = avatar_size / BASE_PROP_SIZE;
|
||||
|
||||
// Draw emotion indicator on avatar
|
||||
let emotion = member.member.current_emotion;
|
||||
if emotion > 0 {
|
||||
// Draw emotion number in a small badge
|
||||
let badge_size = 16.0 * scale_x.min(scale_y);
|
||||
let badge_size = 16.0 * text_scale;
|
||||
let badge_x = x + avatar_size / 2.0 - badge_size / 2.0;
|
||||
let badge_y = y - avatar_size - badge_size / 2.0;
|
||||
|
||||
|
|
@ -532,7 +994,7 @@ fn draw_avatars(
|
|||
|
||||
// Emotion number
|
||||
ctx.set_fill_style_str("#000");
|
||||
ctx.set_font(&format!("bold {}px sans-serif", 10.0 * scale_x.min(scale_y)));
|
||||
ctx.set_font(&format!("bold {}px sans-serif", 10.0 * text_scale));
|
||||
ctx.set_text_align("center");
|
||||
ctx.set_text_baseline("middle");
|
||||
let _ = ctx.fill_text(&format!("{}", emotion), badge_x, badge_y);
|
||||
|
|
@ -540,10 +1002,10 @@ fn draw_avatars(
|
|||
|
||||
// Draw display name
|
||||
ctx.set_fill_style_str("#fff");
|
||||
ctx.set_font(&format!("{}px sans-serif", 12.0 * scale_x.min(scale_y)));
|
||||
ctx.set_font(&format!("{}px sans-serif", 12.0 * text_scale));
|
||||
ctx.set_text_align("center");
|
||||
ctx.set_text_baseline("alphabetic");
|
||||
let _ = ctx.fill_text(&member.member.display_name, x, y + 10.0 * scale_y);
|
||||
let _ = ctx.fill_text(&member.member.display_name, x, y + 10.0 * text_scale);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -558,15 +1020,16 @@ fn draw_speech_bubbles(
|
|||
offset_x: f64,
|
||||
offset_y: f64,
|
||||
current_time_ms: i64,
|
||||
prop_size: f64,
|
||||
) {
|
||||
let scale = scale_x.min(scale_y);
|
||||
let avatar_size = 60.0 * scale;
|
||||
let max_bubble_width = 200.0 * scale;
|
||||
let padding = 8.0 * scale;
|
||||
let font_size = 12.0 * scale;
|
||||
let line_height = 16.0 * scale;
|
||||
let tail_size = 8.0 * scale;
|
||||
let border_radius = 8.0 * scale;
|
||||
let avatar_size = prop_size;
|
||||
let text_scale = avatar_size / BASE_PROP_SIZE;
|
||||
let max_bubble_width = 200.0 * text_scale;
|
||||
let padding = 8.0 * text_scale;
|
||||
let font_size = 12.0 * text_scale;
|
||||
let line_height = 16.0 * text_scale;
|
||||
let tail_size = 8.0 * text_scale;
|
||||
let border_radius = 8.0 * text_scale;
|
||||
|
||||
for member in members {
|
||||
let key = (member.member.user_id, member.member.guest_session_id);
|
||||
|
|
@ -599,12 +1062,12 @@ fn draw_speech_bubbles(
|
|||
})
|
||||
.fold(0.0_f64, |a: f64, b: f64| a.max(b))
|
||||
+ padding * 2.0;
|
||||
let bubble_width = bubble_width.max(60.0 * scale); // Minimum width
|
||||
let bubble_width = bubble_width.max(60.0 * text_scale); // Minimum width
|
||||
let bubble_height = (lines.len() as f64) * line_height + padding * 2.0;
|
||||
|
||||
// Position bubble above avatar
|
||||
let bubble_x = x - bubble_width / 2.0;
|
||||
let bubble_y = y - avatar_size - bubble_height - tail_size - 5.0 * scale;
|
||||
let bubble_y = y - avatar_size - bubble_height - tail_size - 5.0 * text_scale;
|
||||
|
||||
// Draw bubble background with rounded corners
|
||||
draw_rounded_rect(ctx, bubble_x, bubble_y, bubble_width, bubble_height, border_radius);
|
||||
|
|
@ -719,8 +1182,8 @@ fn draw_loose_props(
|
|||
scale_y: f64,
|
||||
offset_x: f64,
|
||||
offset_y: f64,
|
||||
prop_size: f64,
|
||||
) {
|
||||
let prop_size = 60.0 * scale_x.min(scale_y);
|
||||
|
||||
for prop in props {
|
||||
let x = prop.position_x * scale_x + offset_x;
|
||||
|
|
@ -755,8 +1218,9 @@ fn draw_loose_props(
|
|||
ctx.stroke();
|
||||
|
||||
// Draw prop name below
|
||||
let text_scale = prop_size / BASE_PROP_SIZE;
|
||||
ctx.set_fill_style_str("#fff");
|
||||
ctx.set_font(&format!("{}px sans-serif", 10.0 * scale_x.min(scale_y)));
|
||||
ctx.set_font(&format!("{}px sans-serif", 10.0 * text_scale));
|
||||
ctx.set_text_align("center");
|
||||
ctx.set_text_baseline("top");
|
||||
let _ = ctx.fill_text(&prop.prop_name, x, y + prop_size / 2.0 + 2.0);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue