Fix prop renders
* Incorporate prop scaling * Props now render to a canvas
This commit is contained in:
parent
af89394df1
commit
a2841c413d
21 changed files with 942 additions and 353 deletions
|
|
@ -15,13 +15,15 @@ use uuid::Uuid;
|
|||
|
||||
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene};
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
use super::avatar_canvas::hit_test_canvas;
|
||||
use super::avatar_canvas::{AvatarCanvas, 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_PROP_SIZE, REFERENCE_HEIGHT, REFERENCE_WIDTH, ViewerSettings, calculate_min_zoom,
|
||||
BASE_AVATAR_SCALE, BASE_PROP_SCALE, BASE_PROP_SIZE, REFERENCE_HEIGHT, REFERENCE_WIDTH,
|
||||
ViewerSettings, calculate_min_zoom,
|
||||
};
|
||||
use super::ws_client::FadingMember;
|
||||
use crate::utils::parse_bounds_dimensions;
|
||||
|
|
@ -60,6 +62,12 @@ pub fn RealmSceneViewer(
|
|||
/// Callback when whisper is requested on a member.
|
||||
#[prop(optional, into)]
|
||||
on_whisper_request: Option<Callback<String>>,
|
||||
/// Whether the current user is a moderator (can edit prop scales).
|
||||
#[prop(optional, into)]
|
||||
is_moderator: Option<Signal<bool>>,
|
||||
/// Callback when prop scale is updated (moderator only).
|
||||
#[prop(optional, into)]
|
||||
on_prop_scale_update: Option<Callback<(Uuid, f32)>>,
|
||||
) -> impl IntoView {
|
||||
let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false));
|
||||
// Use default settings if none provided
|
||||
|
|
@ -103,10 +111,9 @@ pub fn RealmSceneViewer(
|
|||
let has_background_image = scene.background_image_path.is_some();
|
||||
let image_path = scene.background_image_path.clone().unwrap_or_default();
|
||||
|
||||
// Canvas refs for background and props layers
|
||||
// Avatar layer now uses individual canvas elements per user
|
||||
// Canvas ref for background layer
|
||||
// Avatar and prop layers use individual canvas elements per user/prop
|
||||
let bg_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
||||
let props_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
||||
|
||||
// Outer container ref for middle-mouse drag scrolling
|
||||
let outer_container_ref = NodeRef::<leptos::html::Div>::new();
|
||||
|
|
@ -121,70 +128,153 @@ pub fn RealmSceneViewer(
|
|||
// Signal to track when scale factors have been properly calculated
|
||||
let (scales_ready, set_scales_ready) = signal(false);
|
||||
|
||||
// Context menu state
|
||||
// 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);
|
||||
|
||||
// Prop context menu state (for moderator scale editing)
|
||||
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 (when dragging to resize prop)
|
||||
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, set_scale_mode_preview_scale) = signal(1.0_f32);
|
||||
// Prop center in canvas coordinates (for scale calculation)
|
||||
let (scale_mode_prop_center, set_scale_mode_prop_center) = signal((0.0_f64, 0.0_f64));
|
||||
|
||||
// Handle overlay click for movement or prop pickup
|
||||
// TODO: Add hit-testing for avatar clicks
|
||||
// Uses pixel-perfect hit testing on prop canvases
|
||||
#[cfg(feature = "hydrate")]
|
||||
let on_overlay_click = {
|
||||
let on_move = on_move.clone();
|
||||
let on_prop_click = on_prop_click.clone();
|
||||
move |ev: web_sys::MouseEvent| {
|
||||
// Get click position relative to the target element
|
||||
let target = ev.current_target().unwrap();
|
||||
let element: web_sys::HtmlElement = target.dyn_into().unwrap();
|
||||
let rect = element.get_bounding_client_rect();
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
let click_x = ev.client_x() as f64 - rect.left();
|
||||
let click_y = ev.client_y() as f64 - rect.top();
|
||||
let client_x = ev.client_x() as f64;
|
||||
let client_y = ev.client_y() as f64;
|
||||
|
||||
let sx = scale_x.get();
|
||||
let sy = scale_y.get();
|
||||
let ox = offset_x.get();
|
||||
let oy = offset_y.get();
|
||||
// First check for pixel-perfect prop hits
|
||||
let document = web_sys::window().unwrap().document().unwrap();
|
||||
let mut clicked_prop: Option<Uuid> = None;
|
||||
|
||||
if sx > 0.0 && sy > 0.0 {
|
||||
let scene_x = (click_x - ox) / sx;
|
||||
let scene_y = (click_y - oy) / sy;
|
||||
// Query prop canvases in the props container
|
||||
if let Some(container) = document.query_selector(".props-container").ok().flatten() {
|
||||
let canvases = container.get_elements_by_tag_name("canvas");
|
||||
let canvas_count = canvases.length();
|
||||
|
||||
let scene_x = scene_x.max(0.0).min(scene_width as f64);
|
||||
let scene_y = scene_y.max(0.0).min(scene_height as f64);
|
||||
|
||||
// Check if click is within 40px of any loose prop
|
||||
let current_props = loose_props.get();
|
||||
let prop_click_radius = 40.0;
|
||||
let mut clicked_prop: Option<Uuid> = None;
|
||||
|
||||
for prop in ¤t_props {
|
||||
let dx = scene_x - prop.position_x;
|
||||
let dy = scene_y - prop.position_y;
|
||||
let distance = (dx * dx + dy * dy).sqrt();
|
||||
if distance <= prop_click_radius {
|
||||
clicked_prop = Some(prop.id);
|
||||
break;
|
||||
for i in 0..canvas_count {
|
||||
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") {
|
||||
// Pixel-perfect hit test
|
||||
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 let Some(prop_id) = clicked_prop {
|
||||
on_prop_click.run(prop_id);
|
||||
} else {
|
||||
// No prop hit - handle as movement
|
||||
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;
|
||||
let scene_y = (click_y - oy) / sy;
|
||||
|
||||
let scene_x = scene_x.max(0.0).min(scene_width as f64);
|
||||
let scene_y = scene_y.max(0.0).min(scene_height as f64);
|
||||
|
||||
if let Some(prop_id) = clicked_prop {
|
||||
on_prop_click.run(prop_id);
|
||||
} else {
|
||||
on_move.run((scene_x, scene_y));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle right-click for context menu on avatars
|
||||
// Handle right-click for context menu on avatars or props (moderators only for props)
|
||||
#[cfg(feature = "hydrate")]
|
||||
let on_overlay_contextmenu = {
|
||||
let current_user_id = current_user_id.clone();
|
||||
move |ev: web_sys::MouseEvent| {
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
// Guests cannot message other users - don't show context menu
|
||||
// Get click position
|
||||
let client_x = ev.client_x() as f64;
|
||||
let client_y = ev.client_y() as f64;
|
||||
|
||||
// Check if moderator and if click is on a prop (for scale editing)
|
||||
let is_mod = is_moderator.map(|s| s.get()).unwrap_or(false);
|
||||
if is_mod {
|
||||
let document = web_sys::window().unwrap().document().unwrap();
|
||||
|
||||
// Query prop canvases for pixel-perfect hit testing
|
||||
if let Some(container) = document.query_selector(".props-container").ok().flatten()
|
||||
{
|
||||
let canvases = container.get_elements_by_tag_name("canvas");
|
||||
let canvas_count = canvases.length();
|
||||
|
||||
for i in 0..canvas_count {
|
||||
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") {
|
||||
// Pixel-perfect hit test
|
||||
if hit_test_canvas(&canvas, client_x, client_y) {
|
||||
if let Ok(prop_id) = prop_id_str.parse::<Uuid>() {
|
||||
// Found a prop - show prop context menu
|
||||
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);
|
||||
|
||||
// Find the prop data for scale mode
|
||||
if let Some(prop) = loose_props
|
||||
.get()
|
||||
.iter()
|
||||
.find(|p| p.id == prop_id)
|
||||
{
|
||||
set_scale_mode_initial_scale.set(prop.scale);
|
||||
// Get prop center from canvas bounding rect
|
||||
let rect = canvas.get_bounding_client_rect();
|
||||
let prop_canvas_x =
|
||||
rect.left() + rect.width() / 2.0;
|
||||
let prop_canvas_y =
|
||||
rect.top() + rect.height() / 2.0;
|
||||
set_scale_mode_prop_center
|
||||
.set((prop_canvas_x, prop_canvas_y));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Guests cannot message other users - don't show avatar context menu
|
||||
if is_guest.get() {
|
||||
return;
|
||||
}
|
||||
|
|
@ -192,10 +282,6 @@ pub fn RealmSceneViewer(
|
|||
// Get current user identity for filtering
|
||||
let my_user_id = current_user_id.map(|s| s.get()).flatten();
|
||||
|
||||
// Get click position
|
||||
let client_x = ev.client_x() as f64;
|
||||
let client_y = ev.client_y() as f64;
|
||||
|
||||
// Query all avatar canvases and check for hit
|
||||
let document = web_sys::window().unwrap().document().unwrap();
|
||||
|
||||
|
|
@ -485,117 +571,9 @@ pub fn RealmSceneViewer(
|
|||
draw_bg.forget();
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// Props Effect - runs when loose_props or settings change
|
||||
// =========================================================
|
||||
Effect::new(move |_| {
|
||||
// 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() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read scale factors inside the Effect (reactive context) before the closure
|
||||
let sx = scale_x.get();
|
||||
let sy = scale_y.get();
|
||||
let ox = offset_x.get();
|
||||
let oy = offset_y.get();
|
||||
|
||||
let Some(canvas) = props_canvas_ref.get() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||
let canvas_el = canvas_el.clone();
|
||||
|
||||
let draw_props_closure = Closure::once(Box::new(move || {
|
||||
let canvas_width = canvas_el.width();
|
||||
let canvas_height = canvas_el.height();
|
||||
|
||||
if canvas_width == 0 || canvas_height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
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, canvas_width as f64, canvas_height as f64);
|
||||
|
||||
// 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, prop_size);
|
||||
}
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
let window = web_sys::window().unwrap();
|
||||
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);
|
||||
}
|
||||
}
|
||||
// Note: Avatar canvases are now individual elements that manage their own sizes
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
// Note: Avatar canvases are now individual elements that manage their own sizes
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// Note: Props are now rendered as individual LoosePropCanvas components
|
||||
// that manage their own positioning and sizing via CSS transforms.
|
||||
// No shared props canvas or effect needed.
|
||||
|
||||
// =========================================================
|
||||
// Middle mouse button drag-to-pan (only in pan mode)
|
||||
|
|
@ -865,7 +843,7 @@ pub fn RealmSceneViewer(
|
|||
m
|
||||
});
|
||||
|
||||
// Calculate prop size based on current settings
|
||||
// Calculate prop size based on current settings (for avatars, uses BASE_AVATAR_SCALE)
|
||||
let prop_size = Signal::derive(move || {
|
||||
let current_pan_mode = is_pan_mode.get();
|
||||
let current_zoom = zoom_level.get();
|
||||
|
|
@ -876,16 +854,17 @@ pub fn RealmSceneViewer(
|
|||
// Reference scale factor for "enlarge props" mode
|
||||
let ref_scale = (scene_width_f / REFERENCE_WIDTH).max(scene_height_f / REFERENCE_HEIGHT);
|
||||
|
||||
// Avatar size uses BASE_AVATAR_SCALE (60px cells at native size)
|
||||
if current_pan_mode {
|
||||
if current_enlarge {
|
||||
BASE_PROP_SIZE * ref_scale * current_zoom
|
||||
BASE_PROP_SIZE * BASE_AVATAR_SCALE * ref_scale * current_zoom
|
||||
} else {
|
||||
BASE_PROP_SIZE * current_zoom
|
||||
BASE_PROP_SIZE * BASE_AVATAR_SCALE * current_zoom
|
||||
}
|
||||
} else if current_enlarge {
|
||||
BASE_PROP_SIZE * ref_scale * sx.min(sy)
|
||||
BASE_PROP_SIZE * BASE_AVATAR_SCALE * ref_scale * sx.min(sy)
|
||||
} else {
|
||||
BASE_PROP_SIZE * sx.min(sy)
|
||||
BASE_PROP_SIZE * BASE_AVATAR_SCALE * sx.min(sy)
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -938,13 +917,41 @@ pub fn RealmSceneViewer(
|
|||
style=move || canvas_style(0)
|
||||
aria-hidden="true"
|
||||
/>
|
||||
// Props layer - loose props, redrawn on drop/pickup
|
||||
<canvas
|
||||
node_ref=props_canvas_ref
|
||||
class=canvas_class
|
||||
style=move || canvas_style(1)
|
||||
aria-hidden="true"
|
||||
/>
|
||||
// Props container - individual canvases per prop for pixel-perfect hit detection
|
||||
<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;
|
||||
// Create a derived signal for this specific prop
|
||||
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>
|
||||
// Avatars container - individual canvases per user
|
||||
<div
|
||||
class="avatars-container absolute inset-0"
|
||||
|
|
@ -1095,108 +1102,147 @@ pub fn RealmSceneViewer(
|
|||
set_context_menu_target.set(None);
|
||||
})
|
||||
/>
|
||||
|
||||
// Context menu for prop interactions (moderators only)
|
||||
<ContextMenu
|
||||
open=Signal::derive(move || prop_context_menu_open.get())
|
||||
position=Signal::derive(move || prop_context_menu_position.get())
|
||||
header=Signal::derive(move || Some("Prop".to_string()))
|
||||
items=Signal::derive(move || {
|
||||
vec![
|
||||
ContextMenuItem {
|
||||
label: "Set Scale".to_string(),
|
||||
action: "set_scale".to_string(),
|
||||
},
|
||||
]
|
||||
})
|
||||
on_select=Callback::new({
|
||||
move |action: String| {
|
||||
if action == "set_scale" {
|
||||
if let Some(prop_id) = prop_context_menu_target.get() {
|
||||
// Enter scale mode
|
||||
set_scale_mode_prop_id.set(Some(prop_id));
|
||||
set_scale_mode_preview_scale.set(scale_mode_initial_scale.get());
|
||||
set_scale_mode_active.set(true);
|
||||
}
|
||||
}
|
||||
// Close the menu
|
||||
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);
|
||||
})
|
||||
/>
|
||||
|
||||
// Scale mode overlay (shown when editing prop scale)
|
||||
<Show when=move || scale_mode_active.get()>
|
||||
{move || {
|
||||
let prop_id = scale_mode_prop_id.get();
|
||||
let preview_scale = scale_mode_preview_scale.get();
|
||||
let (center_x, center_y) = scale_mode_prop_center.get();
|
||||
|
||||
// Find the prop to get its dimensions
|
||||
let prop_data = prop_id.and_then(|id| {
|
||||
loose_props.get().iter().find(|p| p.id == id).cloned()
|
||||
});
|
||||
|
||||
view! {
|
||||
<div
|
||||
class="fixed inset-0 z-50 cursor-crosshair"
|
||||
style="background: rgba(0,0,0,0.3);"
|
||||
on:mousemove=move |ev| {
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use wasm_bindgen::JsCast;
|
||||
// Calculate scale based on distance from prop center
|
||||
let ev: web_sys::MouseEvent = ev.dyn_into().unwrap();
|
||||
let mouse_x = ev.client_x() as f64;
|
||||
let mouse_y = ev.client_y() as f64;
|
||||
let (cx, cy) = scale_mode_prop_center.get();
|
||||
let dx = mouse_x - cx;
|
||||
let dy = mouse_y - cy;
|
||||
let distance = (dx * dx + dy * dy).sqrt();
|
||||
// Scale formula: distance / 40 gives 1x at 40px
|
||||
let new_scale = (distance / 40.0).clamp(0.1, 10.0) as f32;
|
||||
set_scale_mode_preview_scale.set(new_scale);
|
||||
}
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
let _ = ev;
|
||||
}
|
||||
on:mouseup=move |ev| {
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
// Apply the scale
|
||||
if let (Some(prop_id), Some(ref callback)) = (scale_mode_prop_id.get(), on_prop_scale_update.as_ref()) {
|
||||
let final_scale = scale_mode_preview_scale.get();
|
||||
callback.run((prop_id, final_scale));
|
||||
}
|
||||
// Exit scale mode
|
||||
set_scale_mode_active.set(false);
|
||||
set_scale_mode_prop_id.set(None);
|
||||
}
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
let _ = ev;
|
||||
}
|
||||
on:keydown=move |ev| {
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use wasm_bindgen::JsCast;
|
||||
let ev: web_sys::KeyboardEvent = ev.dyn_into().unwrap();
|
||||
if ev.key() == "Escape" {
|
||||
// Cancel scale mode
|
||||
ev.prevent_default();
|
||||
set_scale_mode_active.set(false);
|
||||
set_scale_mode_prop_id.set(None);
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
let _ = ev;
|
||||
}
|
||||
tabindex="0"
|
||||
>
|
||||
// Visual feedback: dashed border around prop
|
||||
{move || {
|
||||
if let Some(ref _prop) = prop_data {
|
||||
let prop_size = BASE_PROP_SIZE * BASE_PROP_SCALE * preview_scale as f64;
|
||||
let half_size = prop_size / 2.0;
|
||||
view! {
|
||||
<div
|
||||
class="absolute pointer-events-none"
|
||||
style=format!(
|
||||
"left: {}px; top: {}px; width: {}px; height: {}px; \
|
||||
border: 2px dashed #fbbf24; \
|
||||
transform: translate(-50%, -50%); \
|
||||
box-sizing: border-box;",
|
||||
center_x, center_y, prop_size, prop_size
|
||||
)
|
||||
/>
|
||||
// Scale indicator
|
||||
<div
|
||||
class="absolute bg-gray-900/90 text-yellow-400 px-2 py-1 rounded text-sm font-mono pointer-events-none"
|
||||
style=format!(
|
||||
"left: {}px; top: {}px; transform: translate(-50%, 8px);",
|
||||
center_x, center_y + half_size
|
||||
)
|
||||
>
|
||||
{format!("{:.2}x", preview_scale)}
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
().into_any()
|
||||
}
|
||||
}}
|
||||
// Instructions
|
||||
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 bg-gray-900/90 text-white px-4 py-2 rounded text-sm">
|
||||
"Drag to resize • Release to apply • Escape to cancel"
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
/// Calculate prop/avatar size based on current rendering mode.
|
||||
///
|
||||
/// - Pan mode without enlarge: BASE_PROP_SIZE * zoom_level
|
||||
/// - Pan mode with enlarge: BASE_PROP_SIZE * reference_scale * 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 {
|
||||
// Reference scale factor for "enlarge props" mode
|
||||
let ref_scale = (scene_width / REFERENCE_WIDTH).max(scene_height / REFERENCE_HEIGHT);
|
||||
|
||||
if pan_mode {
|
||||
if enlarge_props {
|
||||
BASE_PROP_SIZE * ref_scale * zoom_level
|
||||
} else {
|
||||
BASE_PROP_SIZE * zoom_level
|
||||
}
|
||||
} else if enlarge_props {
|
||||
// Reference scaling: scale props relative to 1920x1080 reference
|
||||
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 {
|
||||
if path.starts_with('/') {
|
||||
path.to_string()
|
||||
} else {
|
||||
format!("/static/{}", path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw loose props on the props canvas layer.
|
||||
#[cfg(feature = "hydrate")]
|
||||
fn draw_loose_props(
|
||||
ctx: &web_sys::CanvasRenderingContext2d,
|
||||
props: &[LooseProp],
|
||||
scale_x: f64,
|
||||
scale_y: f64,
|
||||
offset_x: f64,
|
||||
offset_y: f64,
|
||||
prop_size: f64,
|
||||
) {
|
||||
for prop in props {
|
||||
let x = prop.position_x * scale_x + offset_x;
|
||||
let y = prop.position_y * scale_y + offset_y;
|
||||
|
||||
// Draw prop sprite if asset path available
|
||||
if !prop.prop_asset_path.is_empty() {
|
||||
let img = web_sys::HtmlImageElement::new().unwrap();
|
||||
let img_clone = img.clone();
|
||||
let ctx_clone = ctx.clone();
|
||||
let draw_x = x - prop_size / 2.0;
|
||||
let draw_y = y - prop_size / 2.0;
|
||||
let size = prop_size;
|
||||
|
||||
let onload = wasm_bindgen::closure::Closure::once(Box::new(move || {
|
||||
let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh(
|
||||
&img_clone, draw_x, draw_y, size, size,
|
||||
);
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
img.set_onload(Some(onload.as_ref().unchecked_ref()));
|
||||
onload.forget();
|
||||
img.set_src(&normalize_asset_path(&prop.prop_asset_path));
|
||||
} else {
|
||||
// Fallback: draw a placeholder circle with prop name
|
||||
ctx.begin_path();
|
||||
let _ = ctx.arc(x, y, prop_size / 2.0, 0.0, std::f64::consts::PI * 2.0);
|
||||
ctx.set_fill_style_str("#f59e0b"); // Amber color
|
||||
ctx.fill();
|
||||
ctx.set_stroke_style_str("#d97706");
|
||||
ctx.set_line_width(2.0);
|
||||
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 * 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