clean up scene_viewer

This commit is contained in:
Evan Carroll 2026-01-23 19:41:33 -06:00
parent 2afe43547d
commit fe40fd32ab
4 changed files with 958 additions and 958 deletions

File diff suppressed because it is too large Load diff

View file

@ -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)
}

View 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();
}

View file

@ -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>
}
}