clean up scene_viewer
This commit is contained in:
parent
2afe43547d
commit
fe40fd32ab
4 changed files with 958 additions and 958 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,96 @@
|
||||||
|
//! Coordinate conversion utilities for scene viewer.
|
||||||
|
//!
|
||||||
|
//! Handles conversions between:
|
||||||
|
//! - Scene coordinates (native scene dimensions)
|
||||||
|
//! - Canvas coordinates (scaled/offset for display)
|
||||||
|
//! - Viewport/client coordinates (browser window)
|
||||||
|
|
||||||
|
/// Coordinate transform state for converting between scene and canvas coordinates.
|
||||||
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
|
pub struct CoordinateTransform {
|
||||||
|
pub scale_x: f64,
|
||||||
|
pub scale_y: f64,
|
||||||
|
pub offset_x: f64,
|
||||||
|
pub offset_y: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CoordinateTransform {
|
||||||
|
/// Create a new transform with the given scale and offset values.
|
||||||
|
pub fn new(scale_x: f64, scale_y: f64, offset_x: f64, offset_y: f64) -> Self {
|
||||||
|
Self {
|
||||||
|
scale_x,
|
||||||
|
scale_y,
|
||||||
|
offset_x,
|
||||||
|
offset_y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert canvas coordinates to scene coordinates.
|
||||||
|
pub fn canvas_to_scene(&self, canvas_x: f64, canvas_y: f64) -> (f64, f64) {
|
||||||
|
if self.scale_x > 0.0 && self.scale_y > 0.0 {
|
||||||
|
let scene_x = (canvas_x - self.offset_x) / self.scale_x;
|
||||||
|
let scene_y = (canvas_y - self.offset_y) / self.scale_y;
|
||||||
|
(scene_x, scene_y)
|
||||||
|
} else {
|
||||||
|
(0.0, 0.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert scene coordinates to canvas coordinates.
|
||||||
|
pub fn scene_to_canvas(&self, scene_x: f64, scene_y: f64) -> (f64, f64) {
|
||||||
|
let canvas_x = scene_x * self.scale_x + self.offset_x;
|
||||||
|
let canvas_y = scene_y * self.scale_y + self.offset_y;
|
||||||
|
(canvas_x, canvas_y)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamp scene coordinates to scene bounds.
|
||||||
|
pub fn clamp_to_scene(&self, x: f64, y: f64, scene_width: f64, scene_height: f64) -> (f64, f64) {
|
||||||
|
(x.max(0.0).min(scene_width), y.max(0.0).min(scene_height))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the transform has valid (non-zero) scales.
|
||||||
|
pub fn is_valid(&self) -> bool {
|
||||||
|
self.scale_x > 0.0 && self.scale_y > 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate the aspect-ratio preserving scale and offset for fit mode.
|
||||||
|
///
|
||||||
|
/// Returns (draw_width, draw_height, offset_x, offset_y, scale_x, scale_y).
|
||||||
|
pub fn calculate_fit_transform(
|
||||||
|
display_width: f64,
|
||||||
|
display_height: f64,
|
||||||
|
scene_width: f64,
|
||||||
|
scene_height: f64,
|
||||||
|
) -> CoordinateTransform {
|
||||||
|
if display_width == 0.0 || display_height == 0.0 {
|
||||||
|
return CoordinateTransform::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
let canvas_aspect = display_width / display_height;
|
||||||
|
let scene_aspect = scene_width / scene_height;
|
||||||
|
|
||||||
|
let (draw_width, draw_height, offset_x, offset_y) = if canvas_aspect > scene_aspect {
|
||||||
|
// Canvas is wider than scene - letterbox on sides
|
||||||
|
let h = display_height;
|
||||||
|
let w = h * scene_aspect;
|
||||||
|
let x = (display_width - w) / 2.0;
|
||||||
|
(w, h, x, 0.0)
|
||||||
|
} else {
|
||||||
|
// Canvas is taller than scene - letterbox on top/bottom
|
||||||
|
let w = display_width;
|
||||||
|
let h = w / scene_aspect;
|
||||||
|
let y = (display_height - h) / 2.0;
|
||||||
|
(w, h, 0.0, y)
|
||||||
|
};
|
||||||
|
|
||||||
|
let scale_x = draw_width / scene_width;
|
||||||
|
let scale_y = draw_height / scene_height;
|
||||||
|
|
||||||
|
CoordinateTransform::new(scale_x, scale_y, offset_x, offset_y)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate transform for pan mode (native resolution * zoom).
|
||||||
|
pub fn calculate_pan_transform(zoom: f64) -> CoordinateTransform {
|
||||||
|
CoordinateTransform::new(zoom, zoom, 0.0, 0.0)
|
||||||
|
}
|
||||||
392
crates/chattyness-user-ui/src/components/scene_viewer/effects.rs
Normal file
392
crates/chattyness-user-ui/src/components/scene_viewer/effects.rs
Normal file
|
|
@ -0,0 +1,392 @@
|
||||||
|
//! Effect functions for scene viewer background drawing and pan handling.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
/// Set up viewport dimension tracking effect.
|
||||||
|
///
|
||||||
|
/// Tracks the outer container size and updates the provided signal.
|
||||||
|
/// Also listens for window resize events.
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
pub fn setup_viewport_tracking(
|
||||||
|
outer_container_ref: NodeRef<leptos::html::Div>,
|
||||||
|
is_pan_mode: Signal<bool>,
|
||||||
|
set_viewport_dimensions: WriteSignal<(f64, f64)>,
|
||||||
|
) {
|
||||||
|
use wasm_bindgen::{JsCast, closure::Closure};
|
||||||
|
|
||||||
|
Effect::new(move |_| {
|
||||||
|
// Track pan mode to re-run when it changes
|
||||||
|
let _ = is_pan_mode.get();
|
||||||
|
|
||||||
|
let Some(container) = outer_container_ref.get() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let container_el: web_sys::HtmlElement = container.into();
|
||||||
|
|
||||||
|
// Measure and update dimensions
|
||||||
|
let measure_container = {
|
||||||
|
let container_el = container_el.clone();
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Measure immediately
|
||||||
|
measure_container();
|
||||||
|
|
||||||
|
// Also measure on window resize
|
||||||
|
let resize_handler = Closure::wrap(Box::new({
|
||||||
|
let container_el = container_el.clone();
|
||||||
|
move |_: web_sys::Event| {
|
||||||
|
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 Fn(web_sys::Event)>);
|
||||||
|
|
||||||
|
let window = web_sys::window().unwrap();
|
||||||
|
let _ = window.add_event_listener_with_callback(
|
||||||
|
"resize",
|
||||||
|
resize_handler.as_ref().unchecked_ref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep the closure alive
|
||||||
|
resize_handler.forget();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set up middle mouse button drag-to-pan effect.
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
pub fn setup_middle_mouse_pan(
|
||||||
|
outer_container_ref: NodeRef<leptos::html::Div>,
|
||||||
|
is_pan_mode: Signal<bool>,
|
||||||
|
) {
|
||||||
|
use std::cell::Cell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use wasm_bindgen::{JsCast, closure::Closure};
|
||||||
|
|
||||||
|
Effect::new(move |_| {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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| {
|
||||||
|
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
|
||||||
|
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(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep closures alive
|
||||||
|
onmousedown.forget();
|
||||||
|
onmousemove.forget();
|
||||||
|
onmouseup.forget();
|
||||||
|
oncontextmenu.forget();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set up wheel zoom effect for pan mode.
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
pub fn setup_wheel_zoom(
|
||||||
|
outer_container_ref: NodeRef<leptos::html::Div>,
|
||||||
|
is_pan_mode: Signal<bool>,
|
||||||
|
on_zoom_change: Option<Callback<f64>>,
|
||||||
|
) {
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use wasm_bindgen::{JsCast, closure::Closure};
|
||||||
|
|
||||||
|
let wheel_closure: Rc<RefCell<Option<Closure<dyn Fn(web_sys::WheelEvent)>>>> =
|
||||||
|
Rc::new(RefCell::new(None));
|
||||||
|
let wheel_closure_clone = wheel_closure.clone();
|
||||||
|
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let pan_mode = is_pan_mode.get();
|
||||||
|
|
||||||
|
if let Some(container) = outer_container_ref.get() {
|
||||||
|
let element: &web_sys::Element = &container;
|
||||||
|
|
||||||
|
// Remove existing listener if any
|
||||||
|
if let Some(closure) = wheel_closure_clone.borrow().as_ref() {
|
||||||
|
let _ = element.remove_event_listener_with_callback(
|
||||||
|
"wheel",
|
||||||
|
closure.as_ref().unchecked_ref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if pan_mode {
|
||||||
|
// Add non-passive wheel listener for zoom
|
||||||
|
let closure = Closure::new(move |ev: web_sys::WheelEvent| {
|
||||||
|
if !ev.ctrl_key() {
|
||||||
|
if let Some(zoom_callback) = on_zoom_change {
|
||||||
|
let delta_y = ev.delta_y();
|
||||||
|
let zoom_delta = if delta_y < 0.0 { 0.1 } else { -0.1 };
|
||||||
|
zoom_callback.run(zoom_delta);
|
||||||
|
ev.prevent_default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let options = web_sys::AddEventListenerOptions::new();
|
||||||
|
options.set_passive(false);
|
||||||
|
|
||||||
|
let _ = element.add_event_listener_with_callback_and_add_event_listener_options(
|
||||||
|
"wheel",
|
||||||
|
closure.as_ref().unchecked_ref(),
|
||||||
|
&options,
|
||||||
|
);
|
||||||
|
|
||||||
|
*wheel_closure_clone.borrow_mut() = Some(closure);
|
||||||
|
} else {
|
||||||
|
*wheel_closure_clone.borrow_mut() = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw background to canvas (handles both pan and fit modes).
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
pub fn draw_background(
|
||||||
|
canvas_el: &web_sys::HtmlCanvasElement,
|
||||||
|
bg_color: &str,
|
||||||
|
image_path: &str,
|
||||||
|
has_background_image: bool,
|
||||||
|
scene_width: f64,
|
||||||
|
scene_height: f64,
|
||||||
|
is_pan_mode: bool,
|
||||||
|
zoom: f64,
|
||||||
|
set_scale_x: WriteSignal<f64>,
|
||||||
|
set_scale_y: WriteSignal<f64>,
|
||||||
|
set_offset_x: WriteSignal<f64>,
|
||||||
|
set_offset_y: WriteSignal<f64>,
|
||||||
|
set_scales_ready: WriteSignal<bool>,
|
||||||
|
) {
|
||||||
|
use wasm_bindgen::{JsCast, closure::Closure};
|
||||||
|
|
||||||
|
let canvas_el = canvas_el.clone();
|
||||||
|
let bg_color = bg_color.to_string();
|
||||||
|
let image_path = image_path.to_string();
|
||||||
|
|
||||||
|
let draw_bg = Closure::once(Box::new(move || {
|
||||||
|
if is_pan_mode {
|
||||||
|
// Pan mode: canvas at native resolution * zoom
|
||||||
|
let canvas_width = (scene_width * zoom) as u32;
|
||||||
|
let canvas_height = (scene_height * zoom) as u32;
|
||||||
|
|
||||||
|
canvas_el.set_width(canvas_width);
|
||||||
|
canvas_el.set_height(canvas_height);
|
||||||
|
|
||||||
|
set_scale_x.set(zoom);
|
||||||
|
set_scale_y.set(zoom);
|
||||||
|
set_offset_x.set(0.0);
|
||||||
|
set_offset_y.set(0.0);
|
||||||
|
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();
|
||||||
|
|
||||||
|
ctx.set_fill_style_str(&bg_color);
|
||||||
|
ctx.fill_rect(0.0, 0.0, canvas_width as f64, canvas_height as f64);
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
if display_width == 0 || display_height == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas_el.set_width(display_width);
|
||||||
|
canvas_el.set_height(display_height);
|
||||||
|
|
||||||
|
let canvas_aspect = display_width as f64 / display_height as f64;
|
||||||
|
let scene_aspect = scene_width / scene_height;
|
||||||
|
|
||||||
|
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)
|
||||||
|
};
|
||||||
|
|
||||||
|
let sx = draw_width / scene_width;
|
||||||
|
let sy = draw_height / scene_height;
|
||||||
|
set_scale_x.set(sx);
|
||||||
|
set_scale_y.set(sy);
|
||||||
|
set_offset_x.set(draw_x);
|
||||||
|
set_offset_y.set(draw_y);
|
||||||
|
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);
|
||||||
|
|
||||||
|
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()>);
|
||||||
|
|
||||||
|
// Use setTimeout with small delay to ensure canvas is in DOM
|
||||||
|
let window = web_sys::window().unwrap();
|
||||||
|
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||||
|
draw_bg.as_ref().unchecked_ref(),
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
draw_bg.forget();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,306 @@
|
||||||
|
//! Overlay components for prop editing in scene viewer.
|
||||||
|
//!
|
||||||
|
//! Contains ScaleOverlay and MoveOverlay components used when
|
||||||
|
//! moderators edit prop scale or position.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use chattyness_db::models::LooseProp;
|
||||||
|
|
||||||
|
use super::super::settings::{BASE_AVATAR_SCALE, BASE_PROP_SCALE};
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
use super::super::canvas_utils::normalize_asset_path;
|
||||||
|
|
||||||
|
/// Overlay shown when editing a prop's scale.
|
||||||
|
///
|
||||||
|
/// Allows dragging from the prop center to adjust scale.
|
||||||
|
#[component]
|
||||||
|
pub fn ScaleOverlay(
|
||||||
|
#[prop(into)] active: Signal<bool>,
|
||||||
|
#[prop(into)] prop_id: Signal<Option<Uuid>>,
|
||||||
|
#[prop(into)] preview_scale: RwSignal<f32>,
|
||||||
|
#[prop(into)] prop_center: Signal<(f64, f64)>,
|
||||||
|
#[prop(into)] loose_props: Signal<Vec<LooseProp>>,
|
||||||
|
#[prop(into)] prop_size: Signal<f64>,
|
||||||
|
#[prop(optional)] on_apply: Option<Callback<(Uuid, f32)>>,
|
||||||
|
#[prop(optional)] on_cancel: Option<Callback<()>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let (_center_x, _center_y) = prop_center.get_untracked();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Show when=move || active.get()>
|
||||||
|
{move || {
|
||||||
|
let current_prop_id = prop_id.get();
|
||||||
|
let current_preview_scale = preview_scale.get();
|
||||||
|
let (center_x, center_y) = prop_center.get();
|
||||||
|
|
||||||
|
// Find the prop to get its dimensions
|
||||||
|
let prop_data = current_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;
|
||||||
|
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) = 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;
|
||||||
|
preview_scale.set(new_scale);
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
let _ = ev;
|
||||||
|
}
|
||||||
|
on:mouseup=move |ev| {
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
if let (Some(pid), Some(ref callback)) = (prop_id.get(), on_apply.as_ref()) {
|
||||||
|
let final_scale = preview_scale.get();
|
||||||
|
callback.run((pid, final_scale));
|
||||||
|
}
|
||||||
|
if let Some(ref callback) = on_cancel {
|
||||||
|
callback.run(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[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" {
|
||||||
|
ev.prevent_default();
|
||||||
|
if let Some(ref callback) = on_cancel {
|
||||||
|
callback.run(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
let _ = ev;
|
||||||
|
}
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
// Visual feedback: dashed border around prop
|
||||||
|
{move || {
|
||||||
|
if let Some(ref _prop) = prop_data {
|
||||||
|
let base_size = prop_size.get();
|
||||||
|
let prop_scale_ratio = BASE_PROP_SCALE / BASE_AVATAR_SCALE;
|
||||||
|
let preview_prop_size = base_size * prop_scale_ratio * current_preview_scale as f64;
|
||||||
|
let half_size = preview_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, preview_prop_size, preview_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", current_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>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overlay shown when moving a prop to a new position.
|
||||||
|
#[component]
|
||||||
|
pub fn MoveOverlay(
|
||||||
|
#[prop(into)] active: Signal<bool>,
|
||||||
|
#[prop(into)] prop_id: Signal<Option<Uuid>>,
|
||||||
|
#[prop(into)] preview_position: RwSignal<(f64, f64)>,
|
||||||
|
#[prop(into)] prop_scale: Signal<f32>,
|
||||||
|
#[prop(into)] loose_props: Signal<Vec<LooseProp>>,
|
||||||
|
#[prop(into)] prop_size: Signal<f64>,
|
||||||
|
#[prop(into)] scale_x: Signal<f64>,
|
||||||
|
#[prop(into)] scale_y: Signal<f64>,
|
||||||
|
#[prop(into)] offset_x: Signal<f64>,
|
||||||
|
#[prop(into)] offset_y: Signal<f64>,
|
||||||
|
#[prop(optional)] on_apply: Option<Callback<(Uuid, f64, f64)>>,
|
||||||
|
#[prop(optional)] on_cancel: Option<Callback<()>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<Show when=move || active.get()>
|
||||||
|
{move || {
|
||||||
|
let current_prop_id = prop_id.get();
|
||||||
|
let (_preview_x, _preview_y) = preview_position.get();
|
||||||
|
let current_prop_scale = prop_scale.get();
|
||||||
|
|
||||||
|
// Find the prop to get its asset path
|
||||||
|
let prop_data = current_prop_id.and_then(|id| {
|
||||||
|
loose_props.get().iter().find(|p| p.id == id).cloned()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate ghost size
|
||||||
|
let base_size = prop_size.get();
|
||||||
|
let prop_scale_ratio = BASE_PROP_SCALE / BASE_AVATAR_SCALE;
|
||||||
|
let ghost_size = base_size * prop_scale_ratio * current_prop_scale as f64;
|
||||||
|
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Get scene viewer's position
|
||||||
|
let document = web_sys::window().unwrap().document().unwrap();
|
||||||
|
if let Some(viewer) = document.query_selector(".scene-viewer-container").ok().flatten() {
|
||||||
|
let rect = viewer.get_bounding_client_rect();
|
||||||
|
let viewer_x = mouse_x - rect.left();
|
||||||
|
let viewer_y = mouse_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 = (viewer_x - ox) / sx;
|
||||||
|
let scene_y = (viewer_y - oy) / sy;
|
||||||
|
preview_position.set((scene_x, scene_y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
let _ = ev;
|
||||||
|
}
|
||||||
|
on:click=move |ev| {
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
if let (Some(pid), Some(ref callback)) = (prop_id.get(), on_apply.as_ref()) {
|
||||||
|
let (final_x, final_y) = preview_position.get();
|
||||||
|
callback.run((pid, final_x, final_y));
|
||||||
|
}
|
||||||
|
if let Some(ref callback) = on_cancel {
|
||||||
|
callback.run(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[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" {
|
||||||
|
ev.prevent_default();
|
||||||
|
if let Some(ref callback) = on_cancel {
|
||||||
|
callback.run(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
let _ = ev;
|
||||||
|
}
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
// Ghost prop at cursor position
|
||||||
|
{move || {
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
if let Some(ref prop) = prop_data {
|
||||||
|
let (preview_x, preview_y) = preview_position.get();
|
||||||
|
let sx = scale_x.get();
|
||||||
|
let sy = scale_y.get();
|
||||||
|
let ox = offset_x.get();
|
||||||
|
let oy = offset_y.get();
|
||||||
|
|
||||||
|
// Get scene viewer position in viewport
|
||||||
|
let document = web_sys::window().unwrap().document().unwrap();
|
||||||
|
let viewer_offset = document
|
||||||
|
.query_selector(".scene-viewer-container")
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|v| {
|
||||||
|
let rect = v.get_bounding_client_rect();
|
||||||
|
(rect.left(), rect.top())
|
||||||
|
})
|
||||||
|
.unwrap_or((0.0, 0.0));
|
||||||
|
|
||||||
|
// Convert scene coords to viewport coords
|
||||||
|
let viewer_x = preview_x * sx + ox;
|
||||||
|
let viewer_y = preview_y * sy + oy;
|
||||||
|
let viewport_x = viewer_x + viewer_offset.0;
|
||||||
|
let viewport_y = viewer_y + viewer_offset.1;
|
||||||
|
|
||||||
|
let normalized_path = normalize_asset_path(&prop.prop_asset_path);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div
|
||||||
|
class="absolute pointer-events-none"
|
||||||
|
style=format!(
|
||||||
|
"left: {}px; top: {}px; width: {}px; height: {}px; \
|
||||||
|
transform: translate(-50%, -50%); \
|
||||||
|
border: 2px dashed #10b981; \
|
||||||
|
background: rgba(16, 185, 129, 0.2); \
|
||||||
|
box-sizing: border-box;",
|
||||||
|
viewport_x, viewport_y, ghost_size, ghost_size
|
||||||
|
)
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src=normalized_path
|
||||||
|
class="w-full h-full object-contain opacity-50"
|
||||||
|
style="pointer-events: none;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
().into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
{
|
||||||
|
().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">
|
||||||
|
"Click to place • Escape to cancel"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue