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
|
|
@ -134,7 +134,7 @@ fn SceneDetailView(
|
||||||
let (show_delete_confirm, set_show_delete_confirm) = signal(false);
|
let (show_delete_confirm, set_show_delete_confirm) = signal(false);
|
||||||
let (show_image_modal, set_show_image_modal) = signal(false);
|
let (show_image_modal, set_show_image_modal) = signal(false);
|
||||||
|
|
||||||
// Parse dimensions from bounds_wkt
|
// Parse dimensions from bounds_wkt for display
|
||||||
let (initial_width, initial_height) = parse_bounds_wkt(&scene.bounds_wkt);
|
let (initial_width, initial_height) = parse_bounds_wkt(&scene.bounds_wkt);
|
||||||
|
|
||||||
// Clone scene data for view (to avoid move issues)
|
// Clone scene data for view (to avoid move issues)
|
||||||
|
|
@ -145,7 +145,6 @@ fn SceneDetailView(
|
||||||
let scene_background_image_path = scene.background_image_path.clone();
|
let scene_background_image_path = scene.background_image_path.clone();
|
||||||
let scene_background_image_path_for_modal = scene.background_image_path.clone();
|
let scene_background_image_path_for_modal = scene.background_image_path.clone();
|
||||||
let scene_background_image_path_for_check = scene.background_image_path.clone();
|
let scene_background_image_path_for_check = scene.background_image_path.clone();
|
||||||
let scene_background_image_path_for_dimensions = scene.background_image_path.clone();
|
|
||||||
let scene_background_color_display = scene.background_color.clone();
|
let scene_background_color_display = scene.background_color.clone();
|
||||||
let scene_created_at = scene.created_at.clone();
|
let scene_created_at = scene.created_at.clone();
|
||||||
let scene_updated_at = scene.updated_at.clone();
|
let scene_updated_at = scene.updated_at.clone();
|
||||||
|
|
@ -158,42 +157,32 @@ fn SceneDetailView(
|
||||||
);
|
);
|
||||||
let (background_image_url, set_background_image_url) = signal(String::new());
|
let (background_image_url, set_background_image_url) = signal(String::new());
|
||||||
let (clear_background_image, set_clear_background_image) = signal(false);
|
let (clear_background_image, set_clear_background_image) = signal(false);
|
||||||
let (infer_dimensions, set_infer_dimensions) = signal(false);
|
|
||||||
let (width, set_width) = signal(initial_width);
|
|
||||||
let (height, set_height) = signal(initial_height);
|
|
||||||
let (dimension_mode, set_dimension_mode) = signal(scene.dimension_mode.clone());
|
|
||||||
let (sort_order, set_sort_order) = signal(scene.sort_order);
|
let (sort_order, set_sort_order) = signal(scene.sort_order);
|
||||||
let (is_entry_point, set_is_entry_point) = signal(scene.is_entry_point);
|
let (is_entry_point, set_is_entry_point) = signal(scene.is_entry_point);
|
||||||
let (is_hidden, set_is_hidden) = signal(scene.is_hidden);
|
let (is_hidden, set_is_hidden) = signal(scene.is_hidden);
|
||||||
|
|
||||||
// UI state for dimension fetching
|
// UI state for dimension detection (read-only display)
|
||||||
let (fetching_dimensions, set_fetching_dimensions) = signal(false);
|
let (fetching_dimensions, set_fetching_dimensions) = signal(false);
|
||||||
let (dimension_message, set_dimension_message) = signal(Option::<(String, bool)>::None);
|
let (detected_dimensions, set_detected_dimensions) = signal(Option::<(u32, u32)>::None);
|
||||||
|
|
||||||
let fetch_dimensions = move |_: leptos::ev::MouseEvent| {
|
let fetch_dimensions = move |_: leptos::ev::MouseEvent| {
|
||||||
let url = background_image_url.get();
|
let url = background_image_url.get();
|
||||||
if url.is_empty() {
|
if url.is_empty() {
|
||||||
set_dimension_message.set(Some(("Please enter an image URL first".to_string(), false)));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
set_fetching_dimensions.set(true);
|
set_fetching_dimensions.set(true);
|
||||||
set_dimension_message.set(None);
|
set_detected_dimensions.set(None);
|
||||||
|
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
{
|
{
|
||||||
fetch_image_dimensions_client(
|
fetch_image_dimensions_client(
|
||||||
url,
|
url,
|
||||||
move |w, h| {
|
move |w, h| {
|
||||||
set_width.set(w as i32);
|
set_detected_dimensions.set(Some((w, h)));
|
||||||
set_height.set(h as i32);
|
|
||||||
set_dimension_message.set(Some((
|
|
||||||
format!("Dimensions: {}x{}", w, h),
|
|
||||||
true,
|
|
||||||
)));
|
|
||||||
},
|
},
|
||||||
move |err| {
|
move |_err| {
|
||||||
set_dimension_message.set(Some((err, false)));
|
set_detected_dimensions.set(None);
|
||||||
},
|
},
|
||||||
set_fetching_dimensions,
|
set_fetching_dimensions,
|
||||||
);
|
);
|
||||||
|
|
@ -210,17 +199,11 @@ fn SceneDetailView(
|
||||||
use gloo_net::http::Request;
|
use gloo_net::http::Request;
|
||||||
|
|
||||||
let id = scene_id.clone();
|
let id = scene_id.clone();
|
||||||
// Build bounds WKT from width/height
|
|
||||||
let w = width.get();
|
|
||||||
let h = height.get();
|
|
||||||
let bounds_wkt = format!("POLYGON((0 0, {} 0, {} {}, 0 {}, 0 0))", w, w, h, h);
|
|
||||||
|
|
||||||
let mut data = serde_json::json!({
|
let mut data = serde_json::json!({
|
||||||
"name": name.get(),
|
"name": name.get(),
|
||||||
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
|
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
|
||||||
"background_color": if background_color.get().is_empty() { None::<String> } else { Some(background_color.get()) },
|
"background_color": if background_color.get().is_empty() { None::<String> } else { Some(background_color.get()) },
|
||||||
"bounds_wkt": bounds_wkt,
|
|
||||||
"dimension_mode": dimension_mode.get(),
|
|
||||||
"sort_order": sort_order.get(),
|
"sort_order": sort_order.get(),
|
||||||
"is_entry_point": is_entry_point.get(),
|
"is_entry_point": is_entry_point.get(),
|
||||||
"is_hidden": is_hidden.get()
|
"is_hidden": is_hidden.get()
|
||||||
|
|
@ -230,10 +213,8 @@ fn SceneDetailView(
|
||||||
let bg_url = background_image_url.get();
|
let bg_url = background_image_url.get();
|
||||||
if !bg_url.is_empty() {
|
if !bg_url.is_empty() {
|
||||||
data["background_image_url"] = serde_json::json!(bg_url);
|
data["background_image_url"] = serde_json::json!(bg_url);
|
||||||
// Include infer dimensions flag when uploading new image
|
// Always infer dimensions from new image
|
||||||
if infer_dimensions.get() {
|
data["infer_dimensions_from_image"] = serde_json::json!(true);
|
||||||
data["infer_dimensions_from_image"] = serde_json::json!(true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include clear flag if set
|
// Include clear flag if set
|
||||||
|
|
@ -447,35 +428,19 @@ fn SceneDetailView(
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
// Dimension fetch message
|
// Show detected dimensions (read-only feedback for new images)
|
||||||
<Show when=move || dimension_message.get().is_some()>
|
<Show when=move || detected_dimensions.get().is_some()>
|
||||||
{move || {
|
{move || {
|
||||||
let (msg, is_success) = dimension_message.get().unwrap_or_default();
|
let (w, h) = detected_dimensions.get().unwrap_or((0, 0));
|
||||||
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
|
|
||||||
view! {
|
view! {
|
||||||
<div class=class role="alert" style="margin-bottom: 1rem">
|
<div class="alert alert-info" role="status" style="margin-bottom: 1rem">
|
||||||
<p>{msg}</p>
|
<p>"Detected Size: " <strong>{w}</strong> " × " <strong>{h}</strong> " px"</p>
|
||||||
|
<small>"Dimensions will be extracted from the new image when saved"</small>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
// Infer dimensions checkbox (only shown when new URL is provided)
|
|
||||||
<Show when=move || !background_image_url.get().is_empty()>
|
|
||||||
<div class="checkbox-group">
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="form-checkbox"
|
|
||||||
prop:checked=move || infer_dimensions.get()
|
|
||||||
on:change=move |ev| set_infer_dimensions.set(event_target_checked(&ev))
|
|
||||||
/>
|
|
||||||
"Infer dimensions from image"
|
|
||||||
</label>
|
|
||||||
<small class="form-help">"If enabled, server will extract dimensions from the image when saving"</small>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{if scene_background_image_path_for_check.is_some() {
|
{if scene_background_image_path_for_check.is_some() {
|
||||||
view! {
|
view! {
|
||||||
<div class="checkbox-group">
|
<div class="checkbox-group">
|
||||||
|
|
@ -494,97 +459,6 @@ fn SceneDetailView(
|
||||||
view! {}.into_any()
|
view! {}.into_any()
|
||||||
}}
|
}}
|
||||||
|
|
||||||
<h3 class="section-title">"Dimensions"</h3>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="width" class="form-label">"Width"</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="width"
|
|
||||||
min=100
|
|
||||||
max=10000
|
|
||||||
class="form-input"
|
|
||||||
prop:value=move || width.get()
|
|
||||||
on:input=move |ev| {
|
|
||||||
if let Ok(v) = event_target_value(&ev).parse() {
|
|
||||||
set_width.set(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="height" class="form-label">"Height"</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="height"
|
|
||||||
min=100
|
|
||||||
max=10000
|
|
||||||
class="form-input"
|
|
||||||
prop:value=move || height.get()
|
|
||||||
on:input=move |ev| {
|
|
||||||
if let Ok(v) = event_target_value(&ev).parse() {
|
|
||||||
set_height.set(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="dimension_mode" class="form-label">"Dimension Mode"</label>
|
|
||||||
<select
|
|
||||||
id="dimension_mode"
|
|
||||||
class="form-select"
|
|
||||||
on:change=move |ev| set_dimension_mode.set(event_target_value(&ev))
|
|
||||||
>
|
|
||||||
<option value="fixed" selected=move || dimension_mode.get() == "fixed">"Fixed"</option>
|
|
||||||
<option value="viewport" selected=move || dimension_mode.get() == "viewport">"Viewport"</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Button to set dimensions from existing background image
|
|
||||||
{if let Some(ref path) = scene_background_image_path_for_dimensions {
|
|
||||||
let path_for_closure = path.clone();
|
|
||||||
view! {
|
|
||||||
<div class="form-group">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-secondary"
|
|
||||||
disabled=move || fetching_dimensions.get()
|
|
||||||
on:click=move |_| {
|
|
||||||
set_fetching_dimensions.set(true);
|
|
||||||
set_dimension_message.set(None);
|
|
||||||
|
|
||||||
#[cfg(feature = "hydrate")]
|
|
||||||
{
|
|
||||||
let path = path_for_closure.clone();
|
|
||||||
fetch_image_dimensions_client(
|
|
||||||
path,
|
|
||||||
move |w, h| {
|
|
||||||
set_width.set(w as i32);
|
|
||||||
set_height.set(h as i32);
|
|
||||||
set_dimension_message.set(Some((
|
|
||||||
format!("Set from image: {}x{}", w, h),
|
|
||||||
true,
|
|
||||||
)));
|
|
||||||
},
|
|
||||||
move |err| {
|
|
||||||
set_dimension_message.set(Some((err, false)));
|
|
||||||
},
|
|
||||||
set_fetching_dimensions,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{move || if fetching_dimensions.get() { "Fetching..." } else { "Set from background image" }}
|
|
||||||
</button>
|
|
||||||
<small class="form-help">"Set dimensions to match the current background image"</small>
|
|
||||||
</div>
|
|
||||||
}.into_any()
|
|
||||||
} else {
|
|
||||||
view! {}.into_any()
|
|
||||||
}}
|
|
||||||
|
|
||||||
<h3 class="section-title">"Options"</h3>
|
<h3 class="section-title">"Options"</h3>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,6 @@ pub fn SceneNewPage() -> impl IntoView {
|
||||||
let (description, set_description) = signal(String::new());
|
let (description, set_description) = signal(String::new());
|
||||||
let (background_color, set_background_color) = signal("#1a1a2e".to_string());
|
let (background_color, set_background_color) = signal("#1a1a2e".to_string());
|
||||||
let (background_image_url, set_background_image_url) = signal(String::new());
|
let (background_image_url, set_background_image_url) = signal(String::new());
|
||||||
let (infer_dimensions, set_infer_dimensions) = signal(false);
|
|
||||||
let (width, set_width) = signal(800i32);
|
|
||||||
let (height, set_height) = signal(600i32);
|
|
||||||
let (dimension_mode, set_dimension_mode) = signal("fixed".to_string());
|
|
||||||
let (sort_order, set_sort_order) = signal(0i32);
|
let (sort_order, set_sort_order) = signal(0i32);
|
||||||
let (is_entry_point, set_is_entry_point) = signal(false);
|
let (is_entry_point, set_is_entry_point) = signal(false);
|
||||||
let (is_hidden, set_is_hidden) = signal(false);
|
let (is_hidden, set_is_hidden) = signal(false);
|
||||||
|
|
@ -37,7 +33,8 @@ pub fn SceneNewPage() -> impl IntoView {
|
||||||
let set_created_id = _set_created_id;
|
let set_created_id = _set_created_id;
|
||||||
let (slug_auto, set_slug_auto) = signal(true);
|
let (slug_auto, set_slug_auto) = signal(true);
|
||||||
let (fetching_dimensions, set_fetching_dimensions) = signal(false);
|
let (fetching_dimensions, set_fetching_dimensions) = signal(false);
|
||||||
let (dimension_message, set_dimension_message) = signal(Option::<(String, bool)>::None);
|
// Stores detected dimensions for display (read-only)
|
||||||
|
let (detected_dimensions, set_detected_dimensions) = signal(Option::<(u32, u32)>::None);
|
||||||
|
|
||||||
let update_name = move |ev: leptos::ev::Event| {
|
let update_name = move |ev: leptos::ev::Event| {
|
||||||
let new_name = event_target_value(&ev);
|
let new_name = event_target_value(&ev);
|
||||||
|
|
@ -57,27 +54,21 @@ pub fn SceneNewPage() -> impl IntoView {
|
||||||
let fetch_dimensions = move |_: leptos::ev::MouseEvent| {
|
let fetch_dimensions = move |_: leptos::ev::MouseEvent| {
|
||||||
let url = background_image_url.get();
|
let url = background_image_url.get();
|
||||||
if url.is_empty() {
|
if url.is_empty() {
|
||||||
set_dimension_message.set(Some(("Please enter an image URL first".to_string(), false)));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
set_fetching_dimensions.set(true);
|
set_fetching_dimensions.set(true);
|
||||||
set_dimension_message.set(None);
|
set_detected_dimensions.set(None);
|
||||||
|
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
{
|
{
|
||||||
fetch_image_dimensions_client(
|
fetch_image_dimensions_client(
|
||||||
url,
|
url,
|
||||||
move |w, h| {
|
move |w, h| {
|
||||||
set_width.set(w as i32);
|
set_detected_dimensions.set(Some((w, h)));
|
||||||
set_height.set(h as i32);
|
|
||||||
set_dimension_message.set(Some((
|
|
||||||
format!("Dimensions: {}x{}", w, h),
|
|
||||||
true,
|
|
||||||
)));
|
|
||||||
},
|
},
|
||||||
move |err| {
|
move |_err| {
|
||||||
set_dimension_message.set(Some((err, false)));
|
set_detected_dimensions.set(None);
|
||||||
},
|
},
|
||||||
set_fetching_dimensions,
|
set_fetching_dimensions,
|
||||||
);
|
);
|
||||||
|
|
@ -96,10 +87,8 @@ pub fn SceneNewPage() -> impl IntoView {
|
||||||
{
|
{
|
||||||
use gloo_net::http::Request;
|
use gloo_net::http::Request;
|
||||||
|
|
||||||
// Build bounds WKT from width/height
|
// Default bounds - server will override from image if background_image_url is provided
|
||||||
let w = width.get();
|
let bounds_wkt = "POLYGON((0 0, 800 0, 800 600, 0 600, 0 0))";
|
||||||
let h = height.get();
|
|
||||||
let bounds_wkt = format!("POLYGON((0 0, {} 0, {} {}, 0 {}, 0 0))", w, w, h, h);
|
|
||||||
|
|
||||||
let data = serde_json::json!({
|
let data = serde_json::json!({
|
||||||
"name": name.get(),
|
"name": name.get(),
|
||||||
|
|
@ -107,9 +96,9 @@ pub fn SceneNewPage() -> impl IntoView {
|
||||||
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
|
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
|
||||||
"background_color": if background_color.get().is_empty() { None::<String> } else { Some(background_color.get()) },
|
"background_color": if background_color.get().is_empty() { None::<String> } else { Some(background_color.get()) },
|
||||||
"background_image_url": if background_image_url.get().is_empty() { None::<String> } else { Some(background_image_url.get()) },
|
"background_image_url": if background_image_url.get().is_empty() { None::<String> } else { Some(background_image_url.get()) },
|
||||||
"infer_dimensions_from_image": infer_dimensions.get(),
|
"infer_dimensions_from_image": true, // Always infer from image
|
||||||
"bounds_wkt": bounds_wkt,
|
"bounds_wkt": bounds_wkt,
|
||||||
"dimension_mode": dimension_mode.get(),
|
"dimension_mode": "fixed", // Default to fixed
|
||||||
"sort_order": sort_order.get(),
|
"sort_order": sort_order.get(),
|
||||||
"is_entry_point": is_entry_point.get(),
|
"is_entry_point": is_entry_point.get(),
|
||||||
"is_hidden": is_hidden.get()
|
"is_hidden": is_hidden.get()
|
||||||
|
|
@ -267,80 +256,19 @@ pub fn SceneNewPage() -> impl IntoView {
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
// Dimension fetch message
|
// Show detected dimensions (read-only feedback)
|
||||||
<Show when=move || dimension_message.get().is_some()>
|
<Show when=move || detected_dimensions.get().is_some()>
|
||||||
{move || {
|
{move || {
|
||||||
let (msg, is_success) = dimension_message.get().unwrap_or_default();
|
let (w, h) = detected_dimensions.get().unwrap_or((0, 0));
|
||||||
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
|
|
||||||
view! {
|
view! {
|
||||||
<div class=class role="alert" style="margin-bottom: 1rem">
|
<div class="alert alert-info" role="status" style="margin-bottom: 1rem">
|
||||||
<p>{msg}</p>
|
<p>"Detected Size: " <strong>{w}</strong> " × " <strong>{h}</strong> " px"</p>
|
||||||
|
<small>"Dimensions will be extracted from the image when the scene is created"</small>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="checkbox-group">
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="form-checkbox"
|
|
||||||
prop:checked=move || infer_dimensions.get()
|
|
||||||
on:change=move |ev| set_infer_dimensions.set(event_target_checked(&ev))
|
|
||||||
/>
|
|
||||||
"Infer dimensions from image"
|
|
||||||
</label>
|
|
||||||
<small class="form-help">"If enabled, server will extract dimensions from the image when creating the scene"</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 class="section-title">"Dimensions"</h3>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="width" class="form-label">"Width"</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="width"
|
|
||||||
min=100
|
|
||||||
max=10000
|
|
||||||
class="form-input"
|
|
||||||
prop:value=move || width.get()
|
|
||||||
on:input=move |ev| {
|
|
||||||
if let Ok(v) = event_target_value(&ev).parse() {
|
|
||||||
set_width.set(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="height" class="form-label">"Height"</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="height"
|
|
||||||
min=100
|
|
||||||
max=10000
|
|
||||||
class="form-input"
|
|
||||||
prop:value=move || height.get()
|
|
||||||
on:input=move |ev| {
|
|
||||||
if let Ok(v) = event_target_value(&ev).parse() {
|
|
||||||
set_height.set(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="dimension_mode" class="form-label">"Dimension Mode"</label>
|
|
||||||
<select
|
|
||||||
id="dimension_mode"
|
|
||||||
class="form-select"
|
|
||||||
on:change=move |ev| set_dimension_mode.set(event_target_value(&ev))
|
|
||||||
>
|
|
||||||
<option value="fixed" selected=move || dimension_mode.get() == "fixed">"Fixed"</option>
|
|
||||||
<option value="viewport" selected=move || dimension_mode.get() == "viewport">"Viewport"</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 class="section-title">"Options"</h3>
|
<h3 class="section-title">"Options"</h3>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ pub mod inventory;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod modals;
|
pub mod modals;
|
||||||
pub mod scene_viewer;
|
pub mod scene_viewer;
|
||||||
|
pub mod settings;
|
||||||
|
pub mod settings_popup;
|
||||||
pub mod ws_client;
|
pub mod ws_client;
|
||||||
|
|
||||||
pub use chat::*;
|
pub use chat::*;
|
||||||
|
|
@ -18,4 +20,6 @@ pub use inventory::*;
|
||||||
pub use layout::*;
|
pub use layout::*;
|
||||||
pub use modals::*;
|
pub use modals::*;
|
||||||
pub use scene_viewer::*;
|
pub use scene_viewer::*;
|
||||||
|
pub use settings::*;
|
||||||
|
pub use settings_popup::*;
|
||||||
pub use ws_client::*;
|
pub use ws_client::*;
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,10 @@ const EMOTIONS: &[&str] = &[
|
||||||
enum CommandMode {
|
enum CommandMode {
|
||||||
/// Normal chat mode, no command active.
|
/// Normal chat mode, no command active.
|
||||||
None,
|
None,
|
||||||
/// Showing command hint (`:e[mote], :l[ist]`).
|
/// Showing command hint for colon commands (`:e[mote], :l[ist]`).
|
||||||
ShowingHint,
|
ShowingColonHint,
|
||||||
|
/// Showing command hint for slash commands (`/setting`).
|
||||||
|
ShowingSlashHint,
|
||||||
/// Showing emotion list popup.
|
/// Showing emotion list popup.
|
||||||
ShowingList,
|
ShowingList,
|
||||||
}
|
}
|
||||||
|
|
@ -63,32 +65,72 @@ fn parse_emote_command(cmd: &str) -> Option<String> {
|
||||||
/// - `ws_sender`: WebSocket sender for emotion updates
|
/// - `ws_sender`: WebSocket sender for emotion updates
|
||||||
/// - `emotion_availability`: Which emotions are available for the user's avatar
|
/// - `emotion_availability`: Which emotions are available for the user's avatar
|
||||||
/// - `skin_preview_path`: Path to the user's skin layer center asset (for previews)
|
/// - `skin_preview_path`: Path to the user's skin layer center asset (for previews)
|
||||||
/// - `focus_trigger`: Signal that triggers focus when set to true
|
/// - `focus_trigger`: Signal that triggers focus when set to true (prefix char in value)
|
||||||
|
/// - `focus_prefix`: The prefix character that triggered focus (':' or '/')
|
||||||
/// - `on_focus_change`: Callback when focus state changes
|
/// - `on_focus_change`: Callback when focus state changes
|
||||||
|
/// - `on_open_settings`: Callback to open settings popup
|
||||||
|
/// - `on_open_inventory`: Callback to open inventory popup
|
||||||
#[component]
|
#[component]
|
||||||
pub fn ChatInput(
|
pub fn ChatInput(
|
||||||
ws_sender: WsSenderStorage,
|
ws_sender: WsSenderStorage,
|
||||||
emotion_availability: Signal<Option<EmotionAvailability>>,
|
emotion_availability: Signal<Option<EmotionAvailability>>,
|
||||||
skin_preview_path: Signal<Option<String>>,
|
skin_preview_path: Signal<Option<String>>,
|
||||||
focus_trigger: Signal<bool>,
|
focus_trigger: Signal<bool>,
|
||||||
|
#[prop(default = Signal::derive(|| ':'))]
|
||||||
|
focus_prefix: Signal<char>,
|
||||||
on_focus_change: Callback<bool>,
|
on_focus_change: Callback<bool>,
|
||||||
|
#[prop(optional)]
|
||||||
|
on_open_settings: Option<Callback<()>>,
|
||||||
|
#[prop(optional)]
|
||||||
|
on_open_inventory: Option<Callback<()>>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let (message, set_message) = signal(String::new());
|
let (message, set_message) = signal(String::new());
|
||||||
let (command_mode, set_command_mode) = signal(CommandMode::None);
|
let (command_mode, set_command_mode) = signal(CommandMode::None);
|
||||||
|
let (list_filter, set_list_filter) = signal(String::new());
|
||||||
|
let (selected_index, set_selected_index) = signal(0usize);
|
||||||
let input_ref = NodeRef::<leptos::html::Input>::new();
|
let input_ref = NodeRef::<leptos::html::Input>::new();
|
||||||
|
|
||||||
// Handle focus trigger from parent (when ':' is pressed globally)
|
// Compute filtered emotions for keyboard navigation
|
||||||
|
let filtered_emotions = move || {
|
||||||
|
let filter_text = list_filter.get().to_lowercase();
|
||||||
|
emotion_availability
|
||||||
|
.get()
|
||||||
|
.map(|avail| {
|
||||||
|
EMOTIONS
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(idx, _)| avail.available.get(*idx).copied().unwrap_or(false))
|
||||||
|
.filter(|(_, name)| filter_text.is_empty() || name.starts_with(&filter_text))
|
||||||
|
.map(|(_, name)| (*name).to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle focus trigger from parent (when space, ':' or '/' is pressed globally)
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
{
|
{
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
if focus_trigger.get() {
|
if focus_trigger.get() {
|
||||||
if let Some(input) = input_ref.get() {
|
if let Some(input) = input_ref.get() {
|
||||||
let _ = input.focus();
|
let _ = input.focus();
|
||||||
// Also set the message to ':' and show the hint
|
let prefix = focus_prefix.get();
|
||||||
set_message.set(":".to_string());
|
|
||||||
set_command_mode.set(CommandMode::ShowingHint);
|
// Space means just focus, no prefix
|
||||||
|
if prefix == ' ' {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefix_str = prefix.to_string();
|
||||||
|
set_message.set(prefix_str.clone());
|
||||||
|
// Show appropriate hint based on prefix
|
||||||
|
set_command_mode.set(if prefix == '/' {
|
||||||
|
CommandMode::ShowingSlashHint
|
||||||
|
} else {
|
||||||
|
CommandMode::ShowingColonHint
|
||||||
|
});
|
||||||
// Update the input value directly
|
// Update the input value directly
|
||||||
input.set_value(":");
|
input.set_value(&prefix_str);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -109,59 +151,207 @@ pub fn ChatInput(
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle input changes to detect commands
|
// Handle input changes to detect commands
|
||||||
let on_input = move |ev| {
|
let on_input = {
|
||||||
let value = event_target_value(&ev);
|
move |ev| {
|
||||||
set_message.set(value.clone());
|
let value = event_target_value(&ev);
|
||||||
|
set_message.set(value.clone());
|
||||||
|
|
||||||
if value.starts_with(':') {
|
// If list is showing, update filter (input is the filter text)
|
||||||
let cmd = value[1..].to_lowercase();
|
if command_mode.get_untracked() == CommandMode::ShowingList {
|
||||||
|
set_list_filter.set(value.clone());
|
||||||
|
set_selected_index.set(0); // Reset selection when filter changes
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check for list command
|
if value.starts_with(':') {
|
||||||
if cmd == "l" || cmd == "list" {
|
let cmd = value[1..].to_lowercase();
|
||||||
set_command_mode.set(CommandMode::ShowingList);
|
|
||||||
} else if cmd.is_empty()
|
// Show hint for colon commands
|
||||||
|| cmd.starts_with('e')
|
if cmd.is_empty()
|
||||||
|| cmd.starts_with('l')
|
|| "list".starts_with(&cmd)
|
||||||
|| cmd.starts_with("em")
|
|| "emote".starts_with(&cmd)
|
||||||
|| cmd.starts_with("li")
|
|| cmd.starts_with("e ")
|
||||||
{
|
|| cmd.starts_with("emote ")
|
||||||
// Show hint for incomplete commands
|
{
|
||||||
set_command_mode.set(CommandMode::ShowingHint);
|
set_command_mode.set(CommandMode::ShowingColonHint);
|
||||||
} else if cmd.starts_with("e ") || cmd.starts_with("emote ") {
|
} else {
|
||||||
// Typing an emote command - keep hint visible
|
set_command_mode.set(CommandMode::None);
|
||||||
set_command_mode.set(CommandMode::ShowingHint);
|
}
|
||||||
|
} else if value.starts_with('/') {
|
||||||
|
let cmd = value[1..].to_lowercase();
|
||||||
|
|
||||||
|
// Show hint for slash commands (don't execute until Enter)
|
||||||
|
if cmd.is_empty()
|
||||||
|
|| "setting".starts_with(&cmd)
|
||||||
|
|| "inventory".starts_with(&cmd)
|
||||||
|
|| cmd == "setting"
|
||||||
|
|| cmd == "settings"
|
||||||
|
|| cmd == "inventory"
|
||||||
|
{
|
||||||
|
set_command_mode.set(CommandMode::ShowingSlashHint);
|
||||||
|
} else {
|
||||||
|
set_command_mode.set(CommandMode::None);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
set_command_mode.set(CommandMode::None);
|
set_command_mode.set(CommandMode::None);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
set_command_mode.set(CommandMode::None);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle key presses (Enter to execute, Escape to close)
|
// Handle key presses (Tab for autocomplete, Enter to execute, Escape to close and blur)
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
let on_keydown = {
|
let on_keydown = {
|
||||||
let apply_emotion = apply_emotion.clone();
|
let apply_emotion = apply_emotion.clone();
|
||||||
|
let on_open_settings = on_open_settings.clone();
|
||||||
|
let on_open_inventory = on_open_inventory.clone();
|
||||||
move |ev: web_sys::KeyboardEvent| {
|
move |ev: web_sys::KeyboardEvent| {
|
||||||
let key = ev.key();
|
let key = ev.key();
|
||||||
|
let current_mode = command_mode.get_untracked();
|
||||||
|
|
||||||
if key == "Escape" {
|
if key == "Escape" {
|
||||||
set_command_mode.set(CommandMode::None);
|
set_command_mode.set(CommandMode::None);
|
||||||
|
set_list_filter.set(String::new());
|
||||||
|
set_selected_index.set(0);
|
||||||
set_message.set(String::new());
|
set_message.set(String::new());
|
||||||
|
// Blur the input to unfocus chat
|
||||||
|
if let Some(input) = input_ref.get() {
|
||||||
|
let _ = input.blur();
|
||||||
|
}
|
||||||
|
ev.prevent_default();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrow key navigation when list is showing
|
||||||
|
if current_mode == CommandMode::ShowingList {
|
||||||
|
let emotions = filtered_emotions();
|
||||||
|
let count = emotions.len();
|
||||||
|
|
||||||
|
if key == "ArrowDown" && count > 0 {
|
||||||
|
set_selected_index.update(|idx| {
|
||||||
|
*idx = (*idx + 1) % count;
|
||||||
|
});
|
||||||
|
ev.prevent_default();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if key == "ArrowUp" && count > 0 {
|
||||||
|
set_selected_index.update(|idx| {
|
||||||
|
*idx = if *idx == 0 { count - 1 } else { *idx - 1 };
|
||||||
|
});
|
||||||
|
ev.prevent_default();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if key == "Enter" && count > 0 {
|
||||||
|
// Select the currently highlighted emotion
|
||||||
|
let idx = selected_index.get_untracked();
|
||||||
|
if let Some(emotion) = emotions.get(idx) {
|
||||||
|
set_list_filter.set(String::new());
|
||||||
|
set_selected_index.set(0);
|
||||||
|
apply_emotion(emotion.clone());
|
||||||
|
}
|
||||||
|
ev.prevent_default();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any other key in list mode is handled by on_input
|
||||||
|
if key == "Enter" {
|
||||||
|
ev.prevent_default();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab for autocomplete
|
||||||
|
if key == "Tab" {
|
||||||
|
let msg = message.get();
|
||||||
|
if msg.starts_with('/') {
|
||||||
|
let cmd = msg[1..].to_lowercase();
|
||||||
|
// Autocomplete to /setting if /s, /se, /set, etc.
|
||||||
|
if !cmd.is_empty() && "setting".starts_with(&cmd) && cmd != "setting" {
|
||||||
|
set_message.set("/setting".to_string());
|
||||||
|
if let Some(input) = input_ref.get() {
|
||||||
|
input.set_value("/setting");
|
||||||
|
}
|
||||||
|
ev.prevent_default();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Autocomplete to /inventory if /i, /in, /inv, etc.
|
||||||
|
if !cmd.is_empty() && "inventory".starts_with(&cmd) && cmd != "inventory" {
|
||||||
|
set_message.set("/inventory".to_string());
|
||||||
|
if let Some(input) = input_ref.get() {
|
||||||
|
input.set_value("/inventory");
|
||||||
|
}
|
||||||
|
ev.prevent_default();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Always prevent Tab from moving focus when in input
|
||||||
ev.prevent_default();
|
ev.prevent_default();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if key == "Enter" {
|
if key == "Enter" {
|
||||||
let msg = message.get();
|
let msg = message.get();
|
||||||
if msg.starts_with(':') {
|
|
||||||
// Try to parse as emote command
|
// Handle slash commands - NEVER send as message
|
||||||
if let Some(emotion_idx) = parse_emote_command(&msg) {
|
if msg.starts_with('/') {
|
||||||
apply_emotion(emotion_idx);
|
let cmd = msg[1..].to_lowercase();
|
||||||
ev.prevent_default();
|
// /s, /se, /set, /sett, /setti, /settin, /setting, /settings
|
||||||
|
if !cmd.is_empty() && ("setting".starts_with(&cmd) || cmd == "settings") {
|
||||||
|
if let Some(ref callback) = on_open_settings {
|
||||||
|
callback.run(());
|
||||||
|
}
|
||||||
|
set_message.set(String::new());
|
||||||
|
set_command_mode.set(CommandMode::None);
|
||||||
|
if let Some(input) = input_ref.get() {
|
||||||
|
input.set_value("");
|
||||||
|
let _ = input.blur();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if !msg.trim().is_empty() {
|
// /i, /in, /inv, /inve, /inven, /invent, /invento, /inventor, /inventory
|
||||||
// Send regular chat message
|
else if !cmd.is_empty() && "inventory".starts_with(&cmd) {
|
||||||
|
if let Some(ref callback) = on_open_inventory {
|
||||||
|
callback.run(());
|
||||||
|
}
|
||||||
|
set_message.set(String::new());
|
||||||
|
set_command_mode.set(CommandMode::None);
|
||||||
|
if let Some(input) = input_ref.get() {
|
||||||
|
input.set_value("");
|
||||||
|
let _ = input.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Invalid slash command - just ignore, don't send
|
||||||
|
ev.prevent_default();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle colon commands - NEVER send as message
|
||||||
|
if msg.starts_with(':') {
|
||||||
|
let cmd = msg[1..].to_lowercase();
|
||||||
|
|
||||||
|
// :l, :li, :lis, :list - open the emotion list
|
||||||
|
if !cmd.is_empty() && "list".starts_with(&cmd) {
|
||||||
|
set_command_mode.set(CommandMode::ShowingList);
|
||||||
|
set_list_filter.set(String::new());
|
||||||
|
set_message.set(String::new());
|
||||||
|
if let Some(input) = input_ref.get() {
|
||||||
|
input.set_value("");
|
||||||
|
}
|
||||||
|
ev.prevent_default();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// :e <name> or :emote <name> - apply emotion if valid
|
||||||
|
if let Some(emotion) = parse_emote_command(&msg) {
|
||||||
|
apply_emotion(emotion);
|
||||||
|
}
|
||||||
|
// Invalid colon command - just ignore, don't send
|
||||||
|
ev.prevent_default();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send regular chat message (only if not a command)
|
||||||
|
if !msg.trim().is_empty() {
|
||||||
ws_sender.with_value(|sender| {
|
ws_sender.with_value(|sender| {
|
||||||
if let Some(send_fn) = sender {
|
if let Some(send_fn) = sender {
|
||||||
send_fn(ClientMessage::SendChatMessage {
|
send_fn(ClientMessage::SendChatMessage {
|
||||||
|
|
@ -170,6 +360,9 @@ pub fn ChatInput(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
set_message.set(String::new());
|
set_message.set(String::new());
|
||||||
|
if let Some(input) = input_ref.get() {
|
||||||
|
input.set_value("");
|
||||||
|
}
|
||||||
ev.prevent_default();
|
ev.prevent_default();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -197,17 +390,21 @@ pub fn ChatInput(
|
||||||
|
|
||||||
// Popup select handler
|
// Popup select handler
|
||||||
let on_popup_select = Callback::new(move |emotion: String| {
|
let on_popup_select = Callback::new(move |emotion: String| {
|
||||||
|
set_list_filter.set(String::new());
|
||||||
apply_emotion(emotion);
|
apply_emotion(emotion);
|
||||||
});
|
});
|
||||||
|
|
||||||
let on_popup_close = Callback::new(move |_: ()| {
|
let on_popup_close = Callback::new(move |_: ()| {
|
||||||
|
set_list_filter.set(String::new());
|
||||||
set_command_mode.set(CommandMode::None);
|
set_command_mode.set(CommandMode::None);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let filter_signal = Signal::derive(move || list_filter.get());
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="chat-input-container w-full max-w-4xl mx-auto pointer-events-auto relative">
|
<div class="chat-input-container w-full max-w-4xl mx-auto pointer-events-auto relative">
|
||||||
// Command hint bar
|
// Colon command hint bar (:e[mote], :l[ist])
|
||||||
<Show when=move || command_mode.get() == CommandMode::ShowingHint>
|
<Show when=move || command_mode.get() == CommandMode::ShowingColonHint>
|
||||||
<div class="absolute bottom-full left-0 mb-1 px-3 py-1 bg-gray-700/90 backdrop-blur-sm rounded text-sm">
|
<div class="absolute bottom-full left-0 mb-1 px-3 py-1 bg-gray-700/90 backdrop-blur-sm rounded text-sm">
|
||||||
<span class="text-gray-400">":"</span>
|
<span class="text-gray-400">":"</span>
|
||||||
<span class="text-blue-400">"e"</span>
|
<span class="text-blue-400">"e"</span>
|
||||||
|
|
@ -219,6 +416,19 @@ pub fn ChatInput(
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
// Slash command hint bar (/s[etting], /i[nventory])
|
||||||
|
<Show when=move || command_mode.get() == CommandMode::ShowingSlashHint>
|
||||||
|
<div class="absolute bottom-full left-0 mb-1 px-3 py-1 bg-gray-700/90 backdrop-blur-sm rounded text-sm">
|
||||||
|
<span class="text-gray-400">"/"</span>
|
||||||
|
<span class="text-blue-400">"s"</span>
|
||||||
|
<span class="text-gray-500">"[etting]"</span>
|
||||||
|
<span class="text-gray-600 mx-2">"|"</span>
|
||||||
|
<span class="text-gray-400">"/"</span>
|
||||||
|
<span class="text-blue-400">"i"</span>
|
||||||
|
<span class="text-gray-500">"[nventory]"</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
// Emotion list popup
|
// Emotion list popup
|
||||||
<Show when=move || command_mode.get() == CommandMode::ShowingList>
|
<Show when=move || command_mode.get() == CommandMode::ShowingList>
|
||||||
<EmoteListPopup
|
<EmoteListPopup
|
||||||
|
|
@ -226,13 +436,15 @@ pub fn ChatInput(
|
||||||
skin_preview_path=skin_preview_path
|
skin_preview_path=skin_preview_path
|
||||||
on_select=on_popup_select
|
on_select=on_popup_select
|
||||||
on_close=on_popup_close
|
on_close=on_popup_close
|
||||||
|
emotion_filter=filter_signal
|
||||||
|
selected_idx=Signal::derive(move || selected_index.get())
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="flex gap-2 items-center bg-gray-900/80 backdrop-blur-sm rounded-lg p-2 border border-gray-600">
|
<div class="flex items-center bg-gray-900/80 backdrop-blur-sm rounded-lg p-2 border border-gray-600">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Type a message... (: for commands)"
|
placeholder="Type a message... (: or / for commands)"
|
||||||
class="flex-1 bg-transparent text-white placeholder-gray-500 px-3 py-2 outline-none"
|
class="flex-1 bg-transparent text-white placeholder-gray-500 px-3 py-2 outline-none"
|
||||||
prop:value=move || message.get()
|
prop:value=move || message.get()
|
||||||
on:input=on_input
|
on:input=on_input
|
||||||
|
|
@ -243,13 +455,6 @@ pub fn ChatInput(
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
aria-label="Chat message input"
|
aria-label="Chat message input"
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
disabled=move || message.get().trim().is_empty()
|
|
||||||
>
|
|
||||||
"Send"
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
@ -257,17 +462,21 @@ pub fn ChatInput(
|
||||||
|
|
||||||
/// Emote list popup component.
|
/// Emote list popup component.
|
||||||
///
|
///
|
||||||
/// Shows available emotions in a 2-column grid with avatar previews.
|
/// Shows available emotions in a grid with avatar previews.
|
||||||
|
/// Supports search-as-you-type filtering and keyboard navigation.
|
||||||
#[component]
|
#[component]
|
||||||
fn EmoteListPopup(
|
fn EmoteListPopup(
|
||||||
emotion_availability: Signal<Option<EmotionAvailability>>,
|
emotion_availability: Signal<Option<EmotionAvailability>>,
|
||||||
skin_preview_path: Signal<Option<String>>,
|
skin_preview_path: Signal<Option<String>>,
|
||||||
on_select: Callback<String>,
|
on_select: Callback<String>,
|
||||||
#[prop(into)] on_close: Callback<()>,
|
#[prop(into)] on_close: Callback<()>,
|
||||||
|
#[prop(into)] emotion_filter: Signal<String>,
|
||||||
|
#[prop(into)] selected_idx: Signal<usize>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let _ = on_close; // Suppress unused warning
|
let _ = on_close; // Suppress unused warning
|
||||||
// Get list of available emotions (name, preview_path)
|
// Get list of available emotions (name, preview_path, index), filtered by search text
|
||||||
let available_emotions = move || {
|
let available_emotions = move || {
|
||||||
|
let filter_text = emotion_filter.get().to_lowercase();
|
||||||
emotion_availability
|
emotion_availability
|
||||||
.get()
|
.get()
|
||||||
.map(|avail| {
|
.map(|avail| {
|
||||||
|
|
@ -275,6 +484,10 @@ fn EmoteListPopup(
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter(|(idx, _)| avail.available.get(*idx).copied().unwrap_or(false))
|
.filter(|(idx, _)| avail.available.get(*idx).copied().unwrap_or(false))
|
||||||
|
.filter(|(_, name)| {
|
||||||
|
// If no filter, show all; otherwise filter by prefix
|
||||||
|
filter_text.is_empty() || name.starts_with(&filter_text)
|
||||||
|
})
|
||||||
.map(|(idx, name)| {
|
.map(|(idx, name)| {
|
||||||
let preview = avail.preview_paths.get(idx).cloned().flatten();
|
let preview = avail.preview_paths.get(idx).cloned().flatten();
|
||||||
((*name).to_string(), preview)
|
((*name).to_string(), preview)
|
||||||
|
|
@ -284,41 +497,71 @@ fn EmoteListPopup(
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let filter_display = move || {
|
||||||
|
let f = emotion_filter.get();
|
||||||
|
if f.is_empty() {
|
||||||
|
"Type to filter...".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Filter: {}", f)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Indexed emotions for selection tracking
|
||||||
|
let indexed_emotions = move || {
|
||||||
|
available_emotions()
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div
|
<div
|
||||||
class="absolute bottom-full left-0 mb-2 w-full max-w-lg bg-gray-800/95 backdrop-blur-sm rounded-lg shadow-2xl border border-gray-700 p-3 z-50"
|
class="absolute bottom-full left-0 mb-2 w-full max-w-lg bg-gray-800/95 backdrop-blur-sm rounded-lg shadow-2xl border border-gray-700 p-3 z-50"
|
||||||
role="listbox"
|
role="listbox"
|
||||||
aria-label="Available emotions"
|
aria-label="Available emotions"
|
||||||
>
|
>
|
||||||
<div class="text-gray-400 text-xs mb-2 px-1">"Select an emotion:"</div>
|
<div class="flex justify-between items-center text-xs mb-2 px-1">
|
||||||
|
<span class="text-gray-400">"Select an emotion:"</span>
|
||||||
|
<span class="text-blue-400 italic">{filter_display}</span>
|
||||||
|
</div>
|
||||||
<div class="grid grid-cols-3 gap-1 max-h-64 overflow-y-auto">
|
<div class="grid grid-cols-3 gap-1 max-h-64 overflow-y-auto">
|
||||||
<For
|
{move || {
|
||||||
each=move || available_emotions()
|
indexed_emotions()
|
||||||
key=|(name, _): &(String, Option<String>)| name.clone()
|
.into_iter()
|
||||||
children=move |(emotion_name, preview_path): (String, Option<String>)| {
|
.map(|(idx, (emotion_name, preview_path))| {
|
||||||
let on_select = on_select.clone();
|
let on_select = on_select.clone();
|
||||||
let emotion_name_for_click = emotion_name.clone();
|
let emotion_name_for_click = emotion_name.clone();
|
||||||
let _skin_path = skin_preview_path.get();
|
let emotion_name_display = emotion_name.clone();
|
||||||
let _emotion_path = preview_path.clone();
|
let _skin_path = skin_preview_path.get();
|
||||||
view! {
|
let _emotion_path = preview_path.clone();
|
||||||
<button
|
let is_selected = move || selected_idx.get() == idx;
|
||||||
type="button"
|
view! {
|
||||||
class="flex items-center gap-2 p-2 rounded hover:bg-gray-700 transition-colors text-left w-full"
|
<button
|
||||||
on:click=move |_| on_select.run(emotion_name_for_click.clone())
|
type="button"
|
||||||
role="option"
|
class=move || {
|
||||||
>
|
if is_selected() {
|
||||||
<EmotionPreview
|
"flex items-center gap-2 p-2 rounded bg-blue-600 text-left w-full"
|
||||||
skin_path=_skin_path.clone()
|
} else {
|
||||||
emotion_path=_emotion_path.clone()
|
"flex items-center gap-2 p-2 rounded hover:bg-gray-700 transition-colors text-left w-full"
|
||||||
/>
|
}
|
||||||
<span class="text-white text-sm">
|
}
|
||||||
":e "
|
on:click=move |_| on_select.run(emotion_name_for_click.clone())
|
||||||
{emotion_name.clone()}
|
role="option"
|
||||||
</span>
|
aria-selected=is_selected
|
||||||
</button>
|
>
|
||||||
}
|
<EmotionPreview
|
||||||
}
|
skin_path=_skin_path.clone()
|
||||||
/>
|
emotion_path=_emotion_path.clone()
|
||||||
|
/>
|
||||||
|
<span class="text-white text-sm">
|
||||||
|
":e "
|
||||||
|
{emotion_name_display}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect_view()
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
<Show when=move || available_emotions().is_empty()>
|
<Show when=move || available_emotions().is_empty()>
|
||||||
<div class="text-gray-500 text-sm text-center py-4">
|
<div class="text-gray-500 text-sm text-center py-4">
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@
|
||||||
//! Uses layered canvases for efficient rendering:
|
//! Uses layered canvases for efficient rendering:
|
||||||
//! - Background canvas: Static, drawn once when scene loads
|
//! - Background canvas: Static, drawn once when scene loads
|
||||||
//! - Avatar canvas: Dynamic, redrawn when members change
|
//! - 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;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
|
@ -12,6 +16,9 @@ use uuid::Uuid;
|
||||||
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene};
|
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene};
|
||||||
|
|
||||||
use super::chat_types::{emotion_bubble_colors, ActiveBubble};
|
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.
|
/// Parse bounds WKT to extract width and height.
|
||||||
///
|
///
|
||||||
|
|
@ -72,9 +79,48 @@ pub fn RealmSceneViewer(
|
||||||
on_move: Callback<(f64, f64)>,
|
on_move: Callback<(f64, f64)>,
|
||||||
#[prop(into)]
|
#[prop(into)]
|
||||||
on_prop_click: Callback<Uuid>,
|
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 {
|
) -> 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 dimensions = parse_bounds_dimensions(&scene.bounds_wkt);
|
||||||
let (scene_width, scene_height) = dimensions.unwrap_or((800, 600));
|
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
|
let bg_color = scene
|
||||||
.background_color
|
.background_color
|
||||||
|
|
@ -91,6 +137,9 @@ pub fn RealmSceneViewer(
|
||||||
let props_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
let props_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
||||||
let avatar_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)
|
// Store scale factors for coordinate conversion (shared between both canvases)
|
||||||
let scale_x = StoredValue::new(1.0_f64);
|
let scale_x = StoredValue::new(1.0_f64);
|
||||||
let scale_y = 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 image_path_clone = image_path.clone();
|
||||||
let bg_color_clone = bg_color.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 |_| {
|
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 {
|
let Some(canvas) = bg_canvas_ref.get() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Skip if already drawn
|
// Check if we need to redraw (settings changed or first render)
|
||||||
if *bg_drawn.borrow() {
|
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;
|
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: &web_sys::HtmlCanvasElement = &canvas;
|
||||||
let canvas_el = canvas_el.clone();
|
let canvas_el = canvas_el.clone();
|
||||||
let bg_color = bg_color_clone.clone();
|
let bg_color = bg_color_clone.clone();
|
||||||
let image_path = image_path_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
|
// Use setTimeout to ensure DOM is ready before drawing
|
||||||
let draw_bg = Closure::once(Box::new(move || {
|
let draw_bg = Closure::once(Box::new(move || {
|
||||||
let display_width = canvas_el.client_width() as u32;
|
if current_pan_mode {
|
||||||
let display_height = canvas_el.client_height() as u32;
|
// 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
|
canvas_el.set_width(canvas_width);
|
||||||
if display_width == 0 || display_height == 0 {
|
canvas_el.set_height(canvas_height);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas_el.set_width(display_width);
|
// Store scale factors (zoom level, no offset)
|
||||||
canvas_el.set_height(display_height);
|
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
|
// Signal that scale factors are ready
|
||||||
let canvas_aspect = display_width as f64 / display_height as f64;
|
set_scales_ready.set(true);
|
||||||
let scene_aspect = scene_width_f / scene_height_f;
|
|
||||||
|
|
||||||
let (draw_width, draw_height, draw_x, draw_y) = if canvas_aspect > scene_aspect {
|
if let Ok(Some(ctx)) = canvas_el.get_context("2d") {
|
||||||
let h = display_height as f64;
|
let ctx: web_sys::CanvasRenderingContext2d =
|
||||||
let w = h * scene_aspect;
|
ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap();
|
||||||
let x = (display_width as f64 - w) / 2.0;
|
|
||||||
(w, h, x, 0.0)
|
// 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 {
|
} else {
|
||||||
let w = display_width as f64;
|
// Fit mode: scale to viewport with letterboxing
|
||||||
let h = w / scene_aspect;
|
let display_width = canvas_el.client_width() as u32;
|
||||||
let y = (display_height as f64 - h) / 2.0;
|
let display_height = canvas_el.client_height() as u32;
|
||||||
(w, h, 0.0, y)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Store scale factors
|
// If still no dimensions, the canvas likely isn't visible - skip drawing
|
||||||
let sx = draw_width / scene_width_f;
|
if display_width == 0 || display_height == 0 {
|
||||||
let sy = draw_height / scene_height_f;
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark background as drawn
|
canvas_el.set_width(display_width);
|
||||||
*bg_drawn_inner.borrow_mut() = true;
|
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()>);
|
}) 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 |_| {
|
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_members = members.get();
|
||||||
let current_bubbles = active_bubbles.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
|
// Skip drawing if scale factors haven't been calculated yet
|
||||||
if !scales_ready.get() {
|
if !scales_ready.get() {
|
||||||
|
|
@ -290,25 +432,19 @@ pub fn RealmSceneViewer(
|
||||||
let canvas_el = canvas_el.clone();
|
let canvas_el = canvas_el.clone();
|
||||||
|
|
||||||
let draw_avatars_closure = Closure::once(Box::new(move || {
|
let draw_avatars_closure = Closure::once(Box::new(move || {
|
||||||
let display_width = canvas_el.client_width() as u32;
|
let canvas_width = canvas_el.width();
|
||||||
let display_height = canvas_el.client_height() as u32;
|
let canvas_height = canvas_el.height();
|
||||||
|
|
||||||
if display_width == 0 || display_height == 0 {
|
if canvas_width == 0 || canvas_height == 0 {
|
||||||
return;
|
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") {
|
if let Ok(Some(ctx)) = canvas_el.get_context("2d") {
|
||||||
let ctx: web_sys::CanvasRenderingContext2d =
|
let ctx: web_sys::CanvasRenderingContext2d =
|
||||||
ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap();
|
ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap();
|
||||||
|
|
||||||
// Clear with transparency (not fill - keeps canvas transparent)
|
// 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
|
// Get stored scale factors
|
||||||
let sx = scale_x.get_value();
|
let sx = scale_x.get_value();
|
||||||
|
|
@ -316,8 +452,19 @@ pub fn RealmSceneViewer(
|
||||||
let ox = offset_x.get_value();
|
let ox = offset_x.get_value();
|
||||||
let oy = offset_y.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 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
|
// Draw speech bubbles on top
|
||||||
let current_time = js_sys::Date::now() as i64;
|
let current_time = js_sys::Date::now() as i64;
|
||||||
|
|
@ -330,6 +477,7 @@ pub fn RealmSceneViewer(
|
||||||
ox,
|
ox,
|
||||||
oy,
|
oy,
|
||||||
current_time,
|
current_time,
|
||||||
|
prop_size,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}) as Box<dyn FnOnce()>);
|
}) 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 |_| {
|
Effect::new(move |_| {
|
||||||
// Track loose_props signal
|
// Track signals
|
||||||
let current_props = loose_props.get();
|
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
|
// Skip drawing if scale factors haven't been calculated yet
|
||||||
if !scales_ready.get() {
|
if !scales_ready.get() {
|
||||||
|
|
@ -359,25 +510,19 @@ pub fn RealmSceneViewer(
|
||||||
let canvas_el = canvas_el.clone();
|
let canvas_el = canvas_el.clone();
|
||||||
|
|
||||||
let draw_props_closure = Closure::once(Box::new(move || {
|
let draw_props_closure = Closure::once(Box::new(move || {
|
||||||
let display_width = canvas_el.client_width() as u32;
|
let canvas_width = canvas_el.width();
|
||||||
let display_height = canvas_el.client_height() as u32;
|
let canvas_height = canvas_el.height();
|
||||||
|
|
||||||
if display_width == 0 || display_height == 0 {
|
if canvas_width == 0 || canvas_height == 0 {
|
||||||
return;
|
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") {
|
if let Ok(Some(ctx)) = canvas_el.get_context("2d") {
|
||||||
let ctx: web_sys::CanvasRenderingContext2d =
|
let ctx: web_sys::CanvasRenderingContext2d =
|
||||||
ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap();
|
ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap();
|
||||||
|
|
||||||
// Clear with transparency
|
// 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
|
// Get stored scale factors
|
||||||
let sx = scale_x.get_value();
|
let sx = scale_x.get_value();
|
||||||
|
|
@ -385,8 +530,19 @@ pub fn RealmSceneViewer(
|
||||||
let ox = offset_x.get_value();
|
let ox = offset_x.get_value();
|
||||||
let oy = offset_y.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
|
||||||
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()>);
|
}) as Box<dyn FnOnce()>);
|
||||||
|
|
||||||
|
|
@ -394,38 +550,312 @@ pub fn RealmSceneViewer(
|
||||||
let _ = window.request_animation_frame(draw_props_closure.as_ref().unchecked_ref());
|
let _ = window.request_animation_frame(draw_props_closure.as_ref().unchecked_ref());
|
||||||
draw_props_closure.forget();
|
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;
|
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! {
|
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
|
<div
|
||||||
class="scene-canvas relative overflow-hidden cursor-pointer"
|
class=container_class
|
||||||
style:background-color=bg_color.clone()
|
style=container_style
|
||||||
style:aspect-ratio=format!("{} / {}", scene_width, scene_height)
|
|
||||||
style:width=format!("min(100%, calc((100vh - 64px) * {}))", aspect_ratio)
|
|
||||||
style:max-height="calc(100vh - 64px)"
|
|
||||||
>
|
>
|
||||||
// Background layer - static, drawn once
|
// Background layer - static, drawn once
|
||||||
<canvas
|
<canvas
|
||||||
node_ref=bg_canvas_ref
|
node_ref=bg_canvas_ref
|
||||||
class="absolute inset-0 w-full h-full"
|
class=canvas_class
|
||||||
style="z-index: 0"
|
style=move || canvas_style(0)
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
// Props layer - loose props, redrawn on drop/pickup
|
// Props layer - loose props, redrawn on drop/pickup
|
||||||
<canvas
|
<canvas
|
||||||
node_ref=props_canvas_ref
|
node_ref=props_canvas_ref
|
||||||
class="absolute inset-0 w-full h-full"
|
class=canvas_class
|
||||||
style="z-index: 1"
|
style=move || canvas_style(1)
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
// Avatar layer - dynamic, transparent background
|
// Avatar layer - dynamic, transparent background
|
||||||
<canvas
|
<canvas
|
||||||
node_ref=avatar_canvas_ref
|
node_ref=avatar_canvas_ref
|
||||||
class="absolute inset-0 w-full h-full"
|
class=canvas_class
|
||||||
style="z-index: 2"
|
style=move || canvas_style(2)
|
||||||
aria-label=format!("Scene: {}", scene.name)
|
aria-label=format!("Scene: {}", scene.name)
|
||||||
role="img"
|
role="img"
|
||||||
on:click=move |ev| {
|
on:click=move |ev| {
|
||||||
|
|
@ -445,6 +875,34 @@ pub fn RealmSceneViewer(
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use wasm_bindgen::JsCast;
|
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.
|
/// Normalize an asset path to be absolute, prefixing with /static/ if needed.
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
fn normalize_asset_path(path: &str) -> String {
|
fn normalize_asset_path(path: &str) -> String {
|
||||||
|
|
@ -463,13 +921,14 @@ fn draw_avatars(
|
||||||
scale_y: f64,
|
scale_y: f64,
|
||||||
offset_x: f64,
|
offset_x: f64,
|
||||||
offset_y: f64,
|
offset_y: f64,
|
||||||
|
prop_size: f64,
|
||||||
) {
|
) {
|
||||||
|
let avatar_size = prop_size;
|
||||||
|
|
||||||
for member in members {
|
for member in members {
|
||||||
let x = member.member.position_x * scale_x + offset_x;
|
let x = member.member.position_x * scale_x + offset_x;
|
||||||
let y = member.member.position_y * scale_y + offset_y;
|
let y = member.member.position_y * scale_y + offset_y;
|
||||||
|
|
||||||
let avatar_size = 60.0 * scale_x.min(scale_y);
|
|
||||||
|
|
||||||
// Draw avatar placeholder circle
|
// Draw avatar placeholder circle
|
||||||
ctx.begin_path();
|
ctx.begin_path();
|
||||||
let _ = ctx.arc(x, y - avatar_size / 2.0, avatar_size / 2.0, 0.0, std::f64::consts::PI * 2.0);
|
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));
|
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
|
// Draw emotion indicator on avatar
|
||||||
let emotion = member.member.current_emotion;
|
let emotion = member.member.current_emotion;
|
||||||
if emotion > 0 {
|
if emotion > 0 {
|
||||||
// Draw emotion number in a small badge
|
// 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_x = x + avatar_size / 2.0 - badge_size / 2.0;
|
||||||
let badge_y = y - avatar_size - badge_size / 2.0;
|
let badge_y = y - avatar_size - badge_size / 2.0;
|
||||||
|
|
||||||
|
|
@ -532,7 +994,7 @@ fn draw_avatars(
|
||||||
|
|
||||||
// Emotion number
|
// Emotion number
|
||||||
ctx.set_fill_style_str("#000");
|
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_align("center");
|
||||||
ctx.set_text_baseline("middle");
|
ctx.set_text_baseline("middle");
|
||||||
let _ = ctx.fill_text(&format!("{}", emotion), badge_x, badge_y);
|
let _ = ctx.fill_text(&format!("{}", emotion), badge_x, badge_y);
|
||||||
|
|
@ -540,10 +1002,10 @@ fn draw_avatars(
|
||||||
|
|
||||||
// Draw display name
|
// Draw display name
|
||||||
ctx.set_fill_style_str("#fff");
|
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_align("center");
|
||||||
ctx.set_text_baseline("alphabetic");
|
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_x: f64,
|
||||||
offset_y: f64,
|
offset_y: f64,
|
||||||
current_time_ms: i64,
|
current_time_ms: i64,
|
||||||
|
prop_size: f64,
|
||||||
) {
|
) {
|
||||||
let scale = scale_x.min(scale_y);
|
let avatar_size = prop_size;
|
||||||
let avatar_size = 60.0 * scale;
|
let text_scale = avatar_size / BASE_PROP_SIZE;
|
||||||
let max_bubble_width = 200.0 * scale;
|
let max_bubble_width = 200.0 * text_scale;
|
||||||
let padding = 8.0 * scale;
|
let padding = 8.0 * text_scale;
|
||||||
let font_size = 12.0 * scale;
|
let font_size = 12.0 * text_scale;
|
||||||
let line_height = 16.0 * scale;
|
let line_height = 16.0 * text_scale;
|
||||||
let tail_size = 8.0 * scale;
|
let tail_size = 8.0 * text_scale;
|
||||||
let border_radius = 8.0 * scale;
|
let border_radius = 8.0 * text_scale;
|
||||||
|
|
||||||
for member in members {
|
for member in members {
|
||||||
let key = (member.member.user_id, member.member.guest_session_id);
|
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))
|
.fold(0.0_f64, |a: f64, b: f64| a.max(b))
|
||||||
+ padding * 2.0;
|
+ 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;
|
let bubble_height = (lines.len() as f64) * line_height + padding * 2.0;
|
||||||
|
|
||||||
// Position bubble above avatar
|
// Position bubble above avatar
|
||||||
let bubble_x = x - bubble_width / 2.0;
|
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 bubble background with rounded corners
|
||||||
draw_rounded_rect(ctx, bubble_x, bubble_y, bubble_width, bubble_height, border_radius);
|
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,
|
scale_y: f64,
|
||||||
offset_x: f64,
|
offset_x: f64,
|
||||||
offset_y: f64,
|
offset_y: f64,
|
||||||
|
prop_size: f64,
|
||||||
) {
|
) {
|
||||||
let prop_size = 60.0 * scale_x.min(scale_y);
|
|
||||||
|
|
||||||
for prop in props {
|
for prop in props {
|
||||||
let x = prop.position_x * scale_x + offset_x;
|
let x = prop.position_x * scale_x + offset_x;
|
||||||
|
|
@ -755,8 +1218,9 @@ fn draw_loose_props(
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Draw prop name below
|
// Draw prop name below
|
||||||
|
let text_scale = prop_size / BASE_PROP_SIZE;
|
||||||
ctx.set_fill_style_str("#fff");
|
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_align("center");
|
||||||
ctx.set_text_baseline("top");
|
ctx.set_text_baseline("top");
|
||||||
let _ = ctx.fill_text(&prop.prop_name, x, y + prop_size / 2.0 + 2.0);
|
let _ = ctx.fill_text(&prop.prop_name, x, y + prop_size / 2.0 + 2.0);
|
||||||
|
|
|
||||||
179
crates/chattyness-user-ui/src/components/settings.rs
Normal file
179
crates/chattyness-user-ui/src/components/settings.rs
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
//! Scene viewer display settings with localStorage persistence.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// LocalStorage key for viewer settings.
|
||||||
|
const SETTINGS_KEY: &str = "chattyness_viewer_settings";
|
||||||
|
|
||||||
|
/// Reference resolution for enlarged props calculation.
|
||||||
|
pub const REFERENCE_WIDTH: f64 = 1920.0;
|
||||||
|
pub const REFERENCE_HEIGHT: f64 = 1080.0;
|
||||||
|
|
||||||
|
/// Base size for props and avatars in scene space.
|
||||||
|
pub const BASE_PROP_SIZE: f64 = 60.0;
|
||||||
|
|
||||||
|
/// Minimum zoom level (25%).
|
||||||
|
pub const ZOOM_MIN: f64 = 0.25;
|
||||||
|
|
||||||
|
/// Maximum zoom level (400%).
|
||||||
|
pub const ZOOM_MAX: f64 = 4.0;
|
||||||
|
|
||||||
|
/// Zoom step increment.
|
||||||
|
pub const ZOOM_STEP: f64 = 0.25;
|
||||||
|
|
||||||
|
/// Pan step in pixels for keyboard navigation.
|
||||||
|
pub const PAN_STEP: f64 = 50.0;
|
||||||
|
|
||||||
|
/// Calculate the minimum zoom level for pan mode.
|
||||||
|
///
|
||||||
|
/// - Large scenes: min zoom fills the viewport
|
||||||
|
/// - Small scenes: min zoom is 1.0 (native resolution, centered)
|
||||||
|
pub fn calculate_min_zoom(
|
||||||
|
scene_width: f64,
|
||||||
|
scene_height: f64,
|
||||||
|
viewport_width: f64,
|
||||||
|
viewport_height: f64,
|
||||||
|
) -> f64 {
|
||||||
|
if scene_width <= 0.0
|
||||||
|
|| scene_height <= 0.0
|
||||||
|
|| viewport_width <= 0.0
|
||||||
|
|| viewport_height <= 0.0
|
||||||
|
{
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let min_x = viewport_width / scene_width;
|
||||||
|
let min_y = viewport_height / scene_height;
|
||||||
|
// For large scenes: min zoom fills viewport
|
||||||
|
// For small scenes: clamp to 1.0 (native resolution)
|
||||||
|
min_x.max(min_y).min(1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scene viewer display settings.
|
||||||
|
///
|
||||||
|
/// These settings control how the scene is rendered and are persisted
|
||||||
|
/// to localStorage for user preference retention.
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct ViewerSettings {
|
||||||
|
/// When true, canvas shows at native resolution with scrolling.
|
||||||
|
/// When false, canvas scales to fit viewport (default).
|
||||||
|
pub panning_enabled: bool,
|
||||||
|
|
||||||
|
/// Zoom level (0.25 to 4.0). Only applicable when `panning_enabled` is true.
|
||||||
|
pub zoom_level: f64,
|
||||||
|
|
||||||
|
/// When true, props use reference scaling based on 1920x1080.
|
||||||
|
/// Only applicable when `panning_enabled` is false.
|
||||||
|
pub enlarge_props: bool,
|
||||||
|
|
||||||
|
/// Saved horizontal scroll position for pan mode.
|
||||||
|
pub scroll_x: f64,
|
||||||
|
|
||||||
|
/// Saved vertical scroll position for pan mode.
|
||||||
|
pub scroll_y: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ViewerSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
panning_enabled: false,
|
||||||
|
zoom_level: 1.0,
|
||||||
|
enlarge_props: false,
|
||||||
|
scroll_x: 0.0,
|
||||||
|
scroll_y: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ViewerSettings {
|
||||||
|
/// Load settings from localStorage, returning defaults if not found or invalid.
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
pub fn load() -> Self {
|
||||||
|
let Some(window) = web_sys::window() else {
|
||||||
|
return Self::default();
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(Some(storage)) = window.local_storage() else {
|
||||||
|
return Self::default();
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(Some(json)) = storage.get_item(SETTINGS_KEY) else {
|
||||||
|
return Self::default();
|
||||||
|
};
|
||||||
|
|
||||||
|
serde_json::from_str(&json).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stub for SSR - returns default settings.
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
pub fn load() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save settings to localStorage.
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
pub fn save(&self) {
|
||||||
|
let Some(window) = web_sys::window() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(Some(storage)) = window.local_storage() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(json) = serde_json::to_string(self) {
|
||||||
|
let _ = storage.set_item(SETTINGS_KEY, &json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stub for SSR - no-op.
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
pub fn save(&self) {}
|
||||||
|
|
||||||
|
/// Calculate the effective prop size based on current settings.
|
||||||
|
///
|
||||||
|
/// In pan mode, returns base size * zoom level.
|
||||||
|
/// In fit mode with enlarge_props, returns size adjusted for reference resolution.
|
||||||
|
/// Otherwise returns base size (caller should multiply by canvas scale).
|
||||||
|
pub fn calculate_prop_size(&self, scene_width: f64, scene_height: f64) -> f64 {
|
||||||
|
if self.panning_enabled {
|
||||||
|
// Pan mode: base size * zoom
|
||||||
|
BASE_PROP_SIZE * self.zoom_level
|
||||||
|
} else if self.enlarge_props {
|
||||||
|
// Reference scaling: ensure minimum size based on 1920x1080
|
||||||
|
let scale_w = scene_width / REFERENCE_WIDTH;
|
||||||
|
let scale_h = scene_height / REFERENCE_HEIGHT;
|
||||||
|
BASE_PROP_SIZE * scale_w.max(scale_h)
|
||||||
|
} else {
|
||||||
|
// Default: base size (will be scaled by canvas scale factor)
|
||||||
|
BASE_PROP_SIZE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adjust zoom level by a delta, clamping to valid range.
|
||||||
|
///
|
||||||
|
/// If `min_zoom` is provided, uses that as the floor instead of `ZOOM_MIN`.
|
||||||
|
pub fn adjust_zoom(&mut self, delta: f64) {
|
||||||
|
self.zoom_level = (self.zoom_level + delta).clamp(ZOOM_MIN, ZOOM_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adjust zoom level with a custom minimum.
|
||||||
|
pub fn adjust_zoom_with_min(&mut self, delta: f64, min_zoom: f64) {
|
||||||
|
let effective_min = min_zoom.max(ZOOM_MIN);
|
||||||
|
self.zoom_level = (self.zoom_level + delta).clamp(effective_min, ZOOM_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamp zoom level to an effective minimum.
|
||||||
|
pub fn clamp_zoom_min(&mut self, min_zoom: f64) {
|
||||||
|
let effective_min = min_zoom.max(ZOOM_MIN);
|
||||||
|
if self.zoom_level < effective_min {
|
||||||
|
self.zoom_level = effective_min;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset scroll position to origin.
|
||||||
|
pub fn reset_scroll(&mut self) {
|
||||||
|
self.scroll_x = 0.0;
|
||||||
|
self.scroll_y = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
280
crates/chattyness-user-ui/src/components/settings_popup.rs
Normal file
280
crates/chattyness-user-ui/src/components/settings_popup.rs
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
//! Settings popup component for scene viewer configuration.
|
||||||
|
|
||||||
|
use leptos::ev::MouseEvent;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
use super::settings::{calculate_min_zoom, ViewerSettings, ZOOM_MAX, ZOOM_STEP};
|
||||||
|
|
||||||
|
/// Settings popup component for scene viewer configuration.
|
||||||
|
///
|
||||||
|
/// Provides controls for:
|
||||||
|
/// - Panning mode (native resolution with scroll)
|
||||||
|
/// - Zoom level (when panning enabled)
|
||||||
|
/// - Enlarge props (when panning disabled)
|
||||||
|
///
|
||||||
|
/// Props:
|
||||||
|
/// - `open`: Signal controlling visibility
|
||||||
|
/// - `settings`: RwSignal for viewer settings (read/write)
|
||||||
|
/// - `on_close`: Callback when popup should close
|
||||||
|
#[component]
|
||||||
|
pub fn SettingsPopup(
|
||||||
|
#[prop(into)] open: Signal<bool>,
|
||||||
|
settings: RwSignal<ViewerSettings>,
|
||||||
|
on_close: Callback<()>,
|
||||||
|
/// Scene dimensions (width, height) for calculating min zoom.
|
||||||
|
#[prop(default = (800.0, 600.0))]
|
||||||
|
scene_dimensions: (f64, f64),
|
||||||
|
/// Viewport dimensions signal for calculating min zoom.
|
||||||
|
#[prop(into, optional)]
|
||||||
|
viewport_dimensions: Option<Signal<(f64, f64)>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
// Derived signals for each setting
|
||||||
|
let panning = Signal::derive(move || settings.get().panning_enabled);
|
||||||
|
let zoom = Signal::derive(move || settings.get().zoom_level);
|
||||||
|
let enlarge = Signal::derive(move || settings.get().enlarge_props);
|
||||||
|
|
||||||
|
// Calculate effective minimum zoom based on scene/viewport dimensions
|
||||||
|
let effective_min_zoom = Signal::derive(move || {
|
||||||
|
let (scene_w, scene_h) = scene_dimensions;
|
||||||
|
let (vp_w, vp_h) = viewport_dimensions
|
||||||
|
.map(|s| s.get())
|
||||||
|
.unwrap_or((800.0, 600.0));
|
||||||
|
calculate_min_zoom(scene_w, scene_h, vp_w, vp_h)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle handlers
|
||||||
|
let on_panning_toggle = move |_| {
|
||||||
|
settings.update(|s| {
|
||||||
|
s.panning_enabled = !s.panning_enabled;
|
||||||
|
// Reset scroll when disabling pan mode
|
||||||
|
if !s.panning_enabled {
|
||||||
|
s.reset_scroll();
|
||||||
|
}
|
||||||
|
s.save();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_enlarge_toggle = move |_| {
|
||||||
|
settings.update(|s| {
|
||||||
|
s.enlarge_props = !s.enlarge_props;
|
||||||
|
s.save();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_zoom_decrease = move |_| {
|
||||||
|
let min_zoom = effective_min_zoom.get();
|
||||||
|
settings.update(|s| {
|
||||||
|
s.adjust_zoom_with_min(-ZOOM_STEP, min_zoom);
|
||||||
|
s.save();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_zoom_increase = move |_| {
|
||||||
|
let min_zoom = effective_min_zoom.get();
|
||||||
|
settings.update(|s| {
|
||||||
|
s.adjust_zoom_with_min(ZOOM_STEP, min_zoom);
|
||||||
|
s.save();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_zoom_input = move |ev| {
|
||||||
|
let val: f64 = event_target_value(&ev).parse().unwrap_or(1.0);
|
||||||
|
let min_zoom = effective_min_zoom.get();
|
||||||
|
settings.update(|s| {
|
||||||
|
s.zoom_level = val.clamp(min_zoom, ZOOM_MAX);
|
||||||
|
s.save();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle escape key to close
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
use leptos::web_sys;
|
||||||
|
use wasm_bindgen::{closure::Closure, JsCast};
|
||||||
|
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if !open.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let on_close_clone = on_close.clone();
|
||||||
|
let closure =
|
||||||
|
Closure::<dyn Fn(web_sys::KeyboardEvent)>::new(move |ev: web_sys::KeyboardEvent| {
|
||||||
|
if ev.key() == "Escape" {
|
||||||
|
on_close_clone.run(());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let _ = window
|
||||||
|
.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intentionally not cleaning up - closure lives for session
|
||||||
|
closure.forget();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let on_close_backdrop = on_close.clone();
|
||||||
|
let on_close_button = on_close.clone();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Show when=move || open.get()>
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="settings-modal-title"
|
||||||
|
>
|
||||||
|
// Backdrop
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||||
|
on:click=move |_| on_close_backdrop.run(())
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Modal content
|
||||||
|
<div class="relative bg-gray-800 rounded-lg shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-700">
|
||||||
|
// Header
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 id="settings-modal-title" class="text-xl font-bold text-white">
|
||||||
|
"Scene Settings"
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-gray-400 hover:text-white transition-colors"
|
||||||
|
on:click=move |_| on_close_button.run(())
|
||||||
|
aria-label="Close settings"
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Settings toggles
|
||||||
|
<div class="space-y-4">
|
||||||
|
// Panning toggle
|
||||||
|
<SettingsToggle
|
||||||
|
label="Native Resolution (Pan Mode)"
|
||||||
|
description="View scene at 1:1 pixel size, scroll to pan around"
|
||||||
|
checked=panning
|
||||||
|
on_change=on_panning_toggle
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Zoom controls (only when panning enabled)
|
||||||
|
<Show when=move || panning.get()>
|
||||||
|
<div class="pl-4 border-l-2 border-gray-600 space-y-2">
|
||||||
|
<label class="block text-white font-medium">
|
||||||
|
"Zoom: " {move || format!("{}%", (zoom.get() * 100.0) as i32)}
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-3 py-1 bg-gray-700 hover:bg-gray-600 text-white rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
on:click=on_zoom_decrease
|
||||||
|
disabled=move || zoom.get() <= effective_min_zoom.get()
|
||||||
|
aria-label="Zoom out"
|
||||||
|
>
|
||||||
|
"-"
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min=move || effective_min_zoom.get().to_string()
|
||||||
|
max=ZOOM_MAX.to_string()
|
||||||
|
step=ZOOM_STEP.to_string()
|
||||||
|
class="flex-1 h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
||||||
|
prop:value=move || zoom.get().to_string()
|
||||||
|
on:input=on_zoom_input
|
||||||
|
aria-label="Zoom level"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-3 py-1 bg-gray-700 hover:bg-gray-600 text-white rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
on:click=on_zoom_increase
|
||||||
|
disabled=move || zoom.get() >= ZOOM_MAX
|
||||||
|
aria-label="Zoom in"
|
||||||
|
>
|
||||||
|
"+"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
// Enlarge props toggle (only when panning disabled)
|
||||||
|
<Show when=move || !panning.get()>
|
||||||
|
<SettingsToggle
|
||||||
|
label="Enlarge Props"
|
||||||
|
description="Scale props relative to 1920x1080 for consistent size"
|
||||||
|
checked=enlarge
|
||||||
|
on_change=on_enlarge_toggle
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Keyboard shortcuts help
|
||||||
|
<div class="mt-6 pt-4 border-t border-gray-700 space-y-1">
|
||||||
|
<p class="text-gray-400 text-sm">
|
||||||
|
"Keyboard: "
|
||||||
|
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-xs font-mono">"s"</kbd>
|
||||||
|
" to open settings"
|
||||||
|
</p>
|
||||||
|
<Show when=move || panning.get()>
|
||||||
|
<p class="text-gray-400 text-sm">
|
||||||
|
"Arrow keys to pan, "
|
||||||
|
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-xs font-mono">"+"</kbd>
|
||||||
|
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-xs font-mono">"-"</kbd>
|
||||||
|
" to zoom"
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Individual toggle switch component.
|
||||||
|
#[component]
|
||||||
|
fn SettingsToggle(
|
||||||
|
/// Label text for the toggle.
|
||||||
|
label: &'static str,
|
||||||
|
/// Description text shown below label.
|
||||||
|
description: &'static str,
|
||||||
|
/// Whether the toggle is currently enabled.
|
||||||
|
#[prop(into)]
|
||||||
|
checked: Signal<bool>,
|
||||||
|
/// Handler called when toggle is clicked.
|
||||||
|
on_change: impl Fn(MouseEvent) + 'static,
|
||||||
|
) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full flex items-start gap-4 p-3 rounded-lg hover:bg-gray-700/50 transition-colors text-left"
|
||||||
|
on:click=on_change
|
||||||
|
role="switch"
|
||||||
|
aria-checked=move || checked.get().to_string()
|
||||||
|
>
|
||||||
|
// Toggle switch
|
||||||
|
<div
|
||||||
|
class=move || format!(
|
||||||
|
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors {}",
|
||||||
|
if checked.get() { "bg-blue-600" } else { "bg-gray-600" }
|
||||||
|
)
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class=move || format!(
|
||||||
|
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition {}",
|
||||||
|
if checked.get() { "translate-x-5" } else { "translate-x-0" }
|
||||||
|
)
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
// Label and description
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<span class="block text-white font-medium">{label}</span>
|
||||||
|
<span class="block text-gray-400 text-sm">{description}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,7 +13,7 @@ use uuid::Uuid;
|
||||||
|
|
||||||
use crate::components::{
|
use crate::components::{
|
||||||
ActiveBubble, Card, ChatInput, ChatMessage, InventoryPopup, MessageLog, RealmHeader,
|
ActiveBubble, Card, ChatInput, ChatMessage, InventoryPopup, MessageLog, RealmHeader,
|
||||||
RealmSceneViewer, DEFAULT_BUBBLE_TIMEOUT_MS,
|
RealmSceneViewer, SettingsPopup, ViewerSettings, DEFAULT_BUBBLE_TIMEOUT_MS,
|
||||||
};
|
};
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use crate::components::use_channel_websocket;
|
use crate::components::use_channel_websocket;
|
||||||
|
|
@ -26,6 +26,44 @@ use chattyness_db::ws_messages::ClientMessage;
|
||||||
|
|
||||||
use crate::components::ws_client::WsSender;
|
use crate::components::ws_client::WsSender;
|
||||||
|
|
||||||
|
/// Parse bounds WKT to extract width and height.
|
||||||
|
///
|
||||||
|
/// Expected format: "POLYGON((0 0, WIDTH 0, WIDTH HEIGHT, 0 HEIGHT, 0 0))"
|
||||||
|
fn parse_bounds_dimensions(bounds_wkt: &str) -> Option<(u32, u32)> {
|
||||||
|
let trimmed = bounds_wkt.trim();
|
||||||
|
let coords_str = trimmed
|
||||||
|
.strip_prefix("POLYGON((")
|
||||||
|
.and_then(|s| s.strip_suffix("))"))?;
|
||||||
|
|
||||||
|
let points: Vec<&str> = coords_str.split(',').collect();
|
||||||
|
if points.len() < 4 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut max_x: f64 = 0.0;
|
||||||
|
let mut max_y: f64 = 0.0;
|
||||||
|
|
||||||
|
for point in points.iter() {
|
||||||
|
let coords: Vec<&str> = point.trim().split_whitespace().collect();
|
||||||
|
if coords.len() >= 2 {
|
||||||
|
if let (Ok(x), Ok(y)) = (coords[0].parse::<f64>(), coords[1].parse::<f64>()) {
|
||||||
|
if x > max_x {
|
||||||
|
max_x = x;
|
||||||
|
}
|
||||||
|
if y > max_y {
|
||||||
|
max_y = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if max_x > 0.0 && max_y > 0.0 {
|
||||||
|
Some((max_x as u32, max_y as u32))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Realm landing page component.
|
/// Realm landing page component.
|
||||||
#[component]
|
#[component]
|
||||||
pub fn RealmPage() -> impl IntoView {
|
pub fn RealmPage() -> impl IntoView {
|
||||||
|
|
@ -58,6 +96,16 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
// Inventory popup state
|
// Inventory popup state
|
||||||
let (inventory_open, set_inventory_open) = signal(false);
|
let (inventory_open, set_inventory_open) = signal(false);
|
||||||
|
|
||||||
|
// Settings popup state
|
||||||
|
let (settings_open, set_settings_open) = signal(false);
|
||||||
|
let viewer_settings = RwSignal::new(ViewerSettings::load());
|
||||||
|
|
||||||
|
// Scene dimensions (extracted from bounds_wkt when scene loads)
|
||||||
|
let (scene_dimensions, set_scene_dimensions) = signal((800.0_f64, 600.0_f64));
|
||||||
|
|
||||||
|
// Chat focus prefix (: or /)
|
||||||
|
let (focus_prefix, set_focus_prefix) = signal(':');
|
||||||
|
|
||||||
// Loose props state
|
// Loose props state
|
||||||
let (loose_props, set_loose_props) = signal(Vec::<LooseProp>::new());
|
let (loose_props, set_loose_props) = signal(Vec::<LooseProp>::new());
|
||||||
|
|
||||||
|
|
@ -205,7 +253,7 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
on_prop_picked_up,
|
on_prop_picked_up,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set channel ID when scene loads (triggers WebSocket connection)
|
// Set channel ID and scene dimensions when scene loads
|
||||||
// Note: Currently using scene.id as the channel_id since channel_members
|
// Note: Currently using scene.id as the channel_id since channel_members
|
||||||
// uses scenes directly. Proper channel infrastructure can be added later.
|
// uses scenes directly. Proper channel infrastructure can be added later.
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
|
|
@ -215,6 +263,11 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
set_channel_id.set(Some(scene.id));
|
set_channel_id.set(Some(scene.id));
|
||||||
|
|
||||||
|
// Extract scene dimensions from bounds_wkt
|
||||||
|
if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) {
|
||||||
|
set_scene_dimensions.set((w as f64, h as f64));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -304,8 +357,22 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle ':' to focus chat input
|
// Handle space to focus chat input (no prefix)
|
||||||
|
if key == " " {
|
||||||
|
set_focus_prefix.set(' ');
|
||||||
|
set_focus_chat_trigger.set(true);
|
||||||
|
use gloo_timers::callback::Timeout;
|
||||||
|
Timeout::new(100, move || {
|
||||||
|
set_focus_chat_trigger.set(false);
|
||||||
|
})
|
||||||
|
.forget();
|
||||||
|
ev.prevent_default();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle ':' to focus chat input with colon prefix
|
||||||
if key == ":" {
|
if key == ":" {
|
||||||
|
set_focus_prefix.set(':');
|
||||||
set_focus_chat_trigger.set(true);
|
set_focus_chat_trigger.set(true);
|
||||||
// Reset trigger after a short delay so it can be triggered again
|
// Reset trigger after a short delay so it can be triggered again
|
||||||
use gloo_timers::callback::Timeout;
|
use gloo_timers::callback::Timeout;
|
||||||
|
|
@ -317,9 +384,68 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle 'i' to open inventory
|
// Handle '/' to focus chat input with slash prefix
|
||||||
|
if key == "/" {
|
||||||
|
set_focus_prefix.set('/');
|
||||||
|
set_focus_chat_trigger.set(true);
|
||||||
|
use gloo_timers::callback::Timeout;
|
||||||
|
Timeout::new(100, move || {
|
||||||
|
set_focus_chat_trigger.set(false);
|
||||||
|
})
|
||||||
|
.forget();
|
||||||
|
ev.prevent_default();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 's' to toggle settings
|
||||||
|
if key == "s" || key == "S" {
|
||||||
|
set_settings_open.update(|v| *v = !*v);
|
||||||
|
ev.prevent_default();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle arrow keys for panning (only in pan mode)
|
||||||
|
let settings = viewer_settings.get_untracked();
|
||||||
|
if settings.panning_enabled {
|
||||||
|
let pan_step = 50.0;
|
||||||
|
let scroll_delta = match key.as_str() {
|
||||||
|
"ArrowLeft" => Some((-pan_step, 0.0)),
|
||||||
|
"ArrowRight" => Some((pan_step, 0.0)),
|
||||||
|
"ArrowUp" => Some((0.0, -pan_step)),
|
||||||
|
"ArrowDown" => Some((0.0, pan_step)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some((dx, dy)) = scroll_delta {
|
||||||
|
// Find the scene container and scroll it
|
||||||
|
if let Some(document) = web_sys::window().and_then(|w| w.document()) {
|
||||||
|
if let Some(container) = document.query_selector(".scene-container").ok().flatten() {
|
||||||
|
let container_el: web_sys::Element = container;
|
||||||
|
container_el.scroll_by_with_x_and_y(dx, dy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ev.prevent_default();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle +/- for zoom
|
||||||
|
let zoom_delta = match key.as_str() {
|
||||||
|
"+" | "=" => Some(0.25),
|
||||||
|
"-" | "_" => Some(-0.25),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(delta) = zoom_delta {
|
||||||
|
viewer_settings.update(|s| s.adjust_zoom(delta));
|
||||||
|
viewer_settings.get_untracked().save();
|
||||||
|
ev.prevent_default();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 'i' to toggle inventory
|
||||||
if key == "i" || key == "I" {
|
if key == "i" || key == "I" {
|
||||||
set_inventory_open.set(true);
|
set_inventory_open.update(|v| *v = !*v);
|
||||||
ev.prevent_default();
|
ev.prevent_default();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -502,6 +628,13 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
let ws_for_chat: StoredValue<Option<WsSender>, LocalStorage> = StoredValue::new_local(None);
|
let ws_for_chat: StoredValue<Option<WsSender>, LocalStorage> = StoredValue::new_local(None);
|
||||||
let active_bubbles_signal = Signal::derive(move || active_bubbles.get());
|
let active_bubbles_signal = Signal::derive(move || active_bubbles.get());
|
||||||
let loose_props_signal = Signal::derive(move || loose_props.get());
|
let loose_props_signal = Signal::derive(move || loose_props.get());
|
||||||
|
let focus_prefix_signal = Signal::derive(move || focus_prefix.get());
|
||||||
|
let on_open_settings_cb = Callback::new(move |_: ()| {
|
||||||
|
set_settings_open.set(true);
|
||||||
|
});
|
||||||
|
let on_open_inventory_cb = Callback::new(move |_: ()| {
|
||||||
|
set_inventory_open.set(true);
|
||||||
|
});
|
||||||
view! {
|
view! {
|
||||||
<div class="relative w-full">
|
<div class="relative w-full">
|
||||||
<RealmSceneViewer
|
<RealmSceneViewer
|
||||||
|
|
@ -512,6 +645,13 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
loose_props=loose_props_signal
|
loose_props=loose_props_signal
|
||||||
on_move=on_move.clone()
|
on_move=on_move.clone()
|
||||||
on_prop_click=on_prop_click.clone()
|
on_prop_click=on_prop_click.clone()
|
||||||
|
settings=Signal::derive(move || viewer_settings.get())
|
||||||
|
on_zoom_change=Callback::new(move |delta: f64| {
|
||||||
|
viewer_settings.update(|s| {
|
||||||
|
s.adjust_zoom(delta);
|
||||||
|
s.save();
|
||||||
|
});
|
||||||
|
})
|
||||||
/>
|
/>
|
||||||
<div class="absolute bottom-0 left-0 right-0 z-10 pb-4 px-4 pointer-events-none">
|
<div class="absolute bottom-0 left-0 right-0 z-10 pb-4 px-4 pointer-events-none">
|
||||||
<ChatInput
|
<ChatInput
|
||||||
|
|
@ -519,7 +659,10 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
emotion_availability=emotion_avail_signal
|
emotion_availability=emotion_avail_signal
|
||||||
skin_preview_path=skin_path_signal
|
skin_preview_path=skin_path_signal
|
||||||
focus_trigger=focus_trigger_signal
|
focus_trigger=focus_trigger_signal
|
||||||
|
focus_prefix=focus_prefix_signal
|
||||||
on_focus_change=on_chat_focus_change.clone()
|
on_focus_change=on_chat_focus_change.clone()
|
||||||
|
on_open_settings=on_open_settings_cb
|
||||||
|
on_open_inventory=on_open_inventory_cb
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -560,6 +703,16 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Settings popup
|
||||||
|
<SettingsPopup
|
||||||
|
open=Signal::derive(move || settings_open.get())
|
||||||
|
settings=viewer_settings
|
||||||
|
on_close=Callback::new(move |_: ()| {
|
||||||
|
set_settings_open.set(false);
|
||||||
|
})
|
||||||
|
scene_dimensions=scene_dimensions.get()
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
.into_any()
|
.into_any()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue