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
|
|
@ -8,6 +8,8 @@ pub mod inventory;
|
|||
pub mod layout;
|
||||
pub mod modals;
|
||||
pub mod scene_viewer;
|
||||
pub mod settings;
|
||||
pub mod settings_popup;
|
||||
pub mod ws_client;
|
||||
|
||||
pub use chat::*;
|
||||
|
|
@ -18,4 +20,6 @@ pub use inventory::*;
|
|||
pub use layout::*;
|
||||
pub use modals::*;
|
||||
pub use scene_viewer::*;
|
||||
pub use settings::*;
|
||||
pub use settings_popup::*;
|
||||
pub use ws_client::*;
|
||||
|
|
|
|||
|
|
@ -28,8 +28,10 @@ const EMOTIONS: &[&str] = &[
|
|||
enum CommandMode {
|
||||
/// Normal chat mode, no command active.
|
||||
None,
|
||||
/// Showing command hint (`:e[mote], :l[ist]`).
|
||||
ShowingHint,
|
||||
/// Showing command hint for colon commands (`:e[mote], :l[ist]`).
|
||||
ShowingColonHint,
|
||||
/// Showing command hint for slash commands (`/setting`).
|
||||
ShowingSlashHint,
|
||||
/// Showing emotion list popup.
|
||||
ShowingList,
|
||||
}
|
||||
|
|
@ -63,32 +65,72 @@ fn parse_emote_command(cmd: &str) -> Option<String> {
|
|||
/// - `ws_sender`: WebSocket sender for emotion updates
|
||||
/// - `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)
|
||||
/// - `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_open_settings`: Callback to open settings popup
|
||||
/// - `on_open_inventory`: Callback to open inventory popup
|
||||
#[component]
|
||||
pub fn ChatInput(
|
||||
ws_sender: WsSenderStorage,
|
||||
emotion_availability: Signal<Option<EmotionAvailability>>,
|
||||
skin_preview_path: Signal<Option<String>>,
|
||||
focus_trigger: Signal<bool>,
|
||||
#[prop(default = Signal::derive(|| ':'))]
|
||||
focus_prefix: Signal<char>,
|
||||
on_focus_change: Callback<bool>,
|
||||
#[prop(optional)]
|
||||
on_open_settings: Option<Callback<()>>,
|
||||
#[prop(optional)]
|
||||
on_open_inventory: Option<Callback<()>>,
|
||||
) -> impl IntoView {
|
||||
let (message, set_message) = signal(String::new());
|
||||
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();
|
||||
|
||||
// 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")]
|
||||
{
|
||||
Effect::new(move |_| {
|
||||
if focus_trigger.get() {
|
||||
if let Some(input) = input_ref.get() {
|
||||
let _ = input.focus();
|
||||
// Also set the message to ':' and show the hint
|
||||
set_message.set(":".to_string());
|
||||
set_command_mode.set(CommandMode::ShowingHint);
|
||||
let prefix = focus_prefix.get();
|
||||
|
||||
// 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
|
||||
input.set_value(":");
|
||||
input.set_value(&prefix_str);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -109,59 +151,207 @@ pub fn ChatInput(
|
|||
};
|
||||
|
||||
// Handle input changes to detect commands
|
||||
let on_input = move |ev| {
|
||||
let value = event_target_value(&ev);
|
||||
set_message.set(value.clone());
|
||||
let on_input = {
|
||||
move |ev| {
|
||||
let value = event_target_value(&ev);
|
||||
set_message.set(value.clone());
|
||||
|
||||
if value.starts_with(':') {
|
||||
let cmd = value[1..].to_lowercase();
|
||||
// If list is showing, update filter (input is the filter text)
|
||||
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 cmd == "l" || cmd == "list" {
|
||||
set_command_mode.set(CommandMode::ShowingList);
|
||||
} else if cmd.is_empty()
|
||||
|| cmd.starts_with('e')
|
||||
|| cmd.starts_with('l')
|
||||
|| cmd.starts_with("em")
|
||||
|| cmd.starts_with("li")
|
||||
{
|
||||
// Show hint for incomplete commands
|
||||
set_command_mode.set(CommandMode::ShowingHint);
|
||||
} else if cmd.starts_with("e ") || cmd.starts_with("emote ") {
|
||||
// Typing an emote command - keep hint visible
|
||||
set_command_mode.set(CommandMode::ShowingHint);
|
||||
if value.starts_with(':') {
|
||||
let cmd = value[1..].to_lowercase();
|
||||
|
||||
// Show hint for colon commands
|
||||
if cmd.is_empty()
|
||||
|| "list".starts_with(&cmd)
|
||||
|| "emote".starts_with(&cmd)
|
||||
|| cmd.starts_with("e ")
|
||||
|| cmd.starts_with("emote ")
|
||||
{
|
||||
set_command_mode.set(CommandMode::ShowingColonHint);
|
||||
} else {
|
||||
set_command_mode.set(CommandMode::None);
|
||||
}
|
||||
} 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 {
|
||||
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")]
|
||||
let on_keydown = {
|
||||
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| {
|
||||
let key = ev.key();
|
||||
let current_mode = command_mode.get_untracked();
|
||||
|
||||
if key == "Escape" {
|
||||
set_command_mode.set(CommandMode::None);
|
||||
set_list_filter.set(String::new());
|
||||
set_selected_index.set(0);
|
||||
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();
|
||||
return;
|
||||
}
|
||||
|
||||
if key == "Enter" {
|
||||
let msg = message.get();
|
||||
if msg.starts_with(':') {
|
||||
// Try to parse as emote command
|
||||
if let Some(emotion_idx) = parse_emote_command(&msg) {
|
||||
apply_emotion(emotion_idx);
|
||||
ev.prevent_default();
|
||||
|
||||
// Handle slash commands - NEVER send as message
|
||||
if msg.starts_with('/') {
|
||||
let cmd = msg[1..].to_lowercase();
|
||||
// /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() {
|
||||
// Send regular chat message
|
||||
// /i, /in, /inv, /inve, /inven, /invent, /invento, /inventor, /inventory
|
||||
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| {
|
||||
if let Some(send_fn) = sender {
|
||||
send_fn(ClientMessage::SendChatMessage {
|
||||
|
|
@ -170,6 +360,9 @@ pub fn ChatInput(
|
|||
}
|
||||
});
|
||||
set_message.set(String::new());
|
||||
if let Some(input) = input_ref.get() {
|
||||
input.set_value("");
|
||||
}
|
||||
ev.prevent_default();
|
||||
}
|
||||
}
|
||||
|
|
@ -197,17 +390,21 @@ pub fn ChatInput(
|
|||
|
||||
// Popup select handler
|
||||
let on_popup_select = Callback::new(move |emotion: String| {
|
||||
set_list_filter.set(String::new());
|
||||
apply_emotion(emotion);
|
||||
});
|
||||
|
||||
let on_popup_close = Callback::new(move |_: ()| {
|
||||
set_list_filter.set(String::new());
|
||||
set_command_mode.set(CommandMode::None);
|
||||
});
|
||||
|
||||
let filter_signal = Signal::derive(move || list_filter.get());
|
||||
|
||||
view! {
|
||||
<div class="chat-input-container w-full max-w-4xl mx-auto pointer-events-auto relative">
|
||||
// Command hint bar
|
||||
<Show when=move || command_mode.get() == CommandMode::ShowingHint>
|
||||
// Colon command hint bar (:e[mote], :l[ist])
|
||||
<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">
|
||||
<span class="text-gray-400">":"</span>
|
||||
<span class="text-blue-400">"e"</span>
|
||||
|
|
@ -219,6 +416,19 @@ pub fn ChatInput(
|
|||
</div>
|
||||
</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
|
||||
<Show when=move || command_mode.get() == CommandMode::ShowingList>
|
||||
<EmoteListPopup
|
||||
|
|
@ -226,13 +436,15 @@ pub fn ChatInput(
|
|||
skin_preview_path=skin_preview_path
|
||||
on_select=on_popup_select
|
||||
on_close=on_popup_close
|
||||
emotion_filter=filter_signal
|
||||
selected_idx=Signal::derive(move || selected_index.get())
|
||||
/>
|
||||
</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
|
||||
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"
|
||||
prop:value=move || message.get()
|
||||
on:input=on_input
|
||||
|
|
@ -243,13 +455,6 @@ pub fn ChatInput(
|
|||
autocomplete="off"
|
||||
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>
|
||||
}
|
||||
|
|
@ -257,17 +462,21 @@ pub fn ChatInput(
|
|||
|
||||
/// 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]
|
||||
fn EmoteListPopup(
|
||||
emotion_availability: Signal<Option<EmotionAvailability>>,
|
||||
skin_preview_path: Signal<Option<String>>,
|
||||
on_select: Callback<String>,
|
||||
#[prop(into)] on_close: Callback<()>,
|
||||
#[prop(into)] emotion_filter: Signal<String>,
|
||||
#[prop(into)] selected_idx: Signal<usize>,
|
||||
) -> impl IntoView {
|
||||
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 filter_text = emotion_filter.get().to_lowercase();
|
||||
emotion_availability
|
||||
.get()
|
||||
.map(|avail| {
|
||||
|
|
@ -275,6 +484,10 @@ fn EmoteListPopup(
|
|||
.iter()
|
||||
.enumerate()
|
||||
.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)| {
|
||||
let preview = avail.preview_paths.get(idx).cloned().flatten();
|
||||
((*name).to_string(), preview)
|
||||
|
|
@ -284,41 +497,71 @@ fn EmoteListPopup(
|
|||
.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! {
|
||||
<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"
|
||||
role="listbox"
|
||||
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">
|
||||
<For
|
||||
each=move || available_emotions()
|
||||
key=|(name, _): &(String, Option<String>)| name.clone()
|
||||
children=move |(emotion_name, preview_path): (String, Option<String>)| {
|
||||
let on_select = on_select.clone();
|
||||
let emotion_name_for_click = emotion_name.clone();
|
||||
let _skin_path = skin_preview_path.get();
|
||||
let _emotion_path = preview_path.clone();
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 p-2 rounded hover:bg-gray-700 transition-colors text-left w-full"
|
||||
on:click=move |_| on_select.run(emotion_name_for_click.clone())
|
||||
role="option"
|
||||
>
|
||||
<EmotionPreview
|
||||
skin_path=_skin_path.clone()
|
||||
emotion_path=_emotion_path.clone()
|
||||
/>
|
||||
<span class="text-white text-sm">
|
||||
":e "
|
||||
{emotion_name.clone()}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
/>
|
||||
{move || {
|
||||
indexed_emotions()
|
||||
.into_iter()
|
||||
.map(|(idx, (emotion_name, preview_path))| {
|
||||
let on_select = on_select.clone();
|
||||
let emotion_name_for_click = emotion_name.clone();
|
||||
let emotion_name_display = emotion_name.clone();
|
||||
let _skin_path = skin_preview_path.get();
|
||||
let _emotion_path = preview_path.clone();
|
||||
let is_selected = move || selected_idx.get() == idx;
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class=move || {
|
||||
if is_selected() {
|
||||
"flex items-center gap-2 p-2 rounded bg-blue-600 text-left w-full"
|
||||
} else {
|
||||
"flex items-center gap-2 p-2 rounded hover:bg-gray-700 transition-colors text-left w-full"
|
||||
}
|
||||
}
|
||||
on:click=move |_| on_select.run(emotion_name_for_click.clone())
|
||||
role="option"
|
||||
aria-selected=is_selected
|
||||
>
|
||||
<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>
|
||||
<Show when=move || available_emotions().is_empty()>
|
||||
<div class="text-gray-500 text-sm text-center py-4">
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@
|
|||
//! Uses layered canvases for efficient rendering:
|
||||
//! - Background canvas: Static, drawn once when scene loads
|
||||
//! - Avatar canvas: Dynamic, redrawn when members change
|
||||
//!
|
||||
//! Supports two rendering modes:
|
||||
//! - **Fit mode** (default): Background scales to fit viewport with letterboxing
|
||||
//! - **Pan mode**: Canvas at native resolution with optional zoom, user can scroll
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
|
|
@ -12,6 +16,9 @@ use uuid::Uuid;
|
|||
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene};
|
||||
|
||||
use super::chat_types::{emotion_bubble_colors, ActiveBubble};
|
||||
use super::settings::{
|
||||
calculate_min_zoom, ViewerSettings, BASE_PROP_SIZE, REFERENCE_HEIGHT, REFERENCE_WIDTH,
|
||||
};
|
||||
|
||||
/// Parse bounds WKT to extract width and height.
|
||||
///
|
||||
|
|
@ -72,9 +79,48 @@ pub fn RealmSceneViewer(
|
|||
on_move: Callback<(f64, f64)>,
|
||||
#[prop(into)]
|
||||
on_prop_click: Callback<Uuid>,
|
||||
/// Viewer settings for pan/zoom/enlarge modes.
|
||||
#[prop(optional)]
|
||||
settings: Option<Signal<ViewerSettings>>,
|
||||
/// Callback for zoom changes (from mouse wheel). Receives zoom delta.
|
||||
#[prop(optional)]
|
||||
on_zoom_change: Option<Callback<f64>>,
|
||||
) -> impl IntoView {
|
||||
// Use default settings if none provided
|
||||
let settings = settings.unwrap_or_else(|| Signal::derive(ViewerSettings::default));
|
||||
let dimensions = parse_bounds_dimensions(&scene.bounds_wkt);
|
||||
let (scene_width, scene_height) = dimensions.unwrap_or((800, 600));
|
||||
let scene_width_f = scene_width as f64;
|
||||
let scene_height_f = scene_height as f64;
|
||||
|
||||
// Derived signals for rendering mode
|
||||
let is_pan_mode = Signal::derive(move || settings.get().panning_enabled);
|
||||
|
||||
// Signal for viewport dimensions (outer container size)
|
||||
// Used to calculate effective minimum zoom in pan mode
|
||||
let (viewport_dimensions, set_viewport_dimensions) = signal((800.0_f64, 600.0_f64));
|
||||
|
||||
// Calculate effective minimum zoom based on scene and viewport
|
||||
let effective_min_zoom = Signal::derive(move || {
|
||||
let (vp_w, vp_h) = viewport_dimensions.get();
|
||||
calculate_min_zoom(scene_width_f, scene_height_f, vp_w, vp_h)
|
||||
});
|
||||
|
||||
// Zoom level clamped to effective minimum
|
||||
let zoom_level = Signal::derive(move || {
|
||||
let s = settings.get();
|
||||
if s.panning_enabled {
|
||||
let min_zoom = effective_min_zoom.get();
|
||||
s.zoom_level.max(min_zoom)
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
});
|
||||
|
||||
let enlarge_props = Signal::derive(move || {
|
||||
let s = settings.get();
|
||||
!s.panning_enabled && s.enlarge_props
|
||||
});
|
||||
|
||||
let bg_color = scene
|
||||
.background_color
|
||||
|
|
@ -91,6 +137,9 @@ pub fn RealmSceneViewer(
|
|||
let props_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
||||
let avatar_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
||||
|
||||
// Outer container ref for middle-mouse drag scrolling
|
||||
let outer_container_ref = NodeRef::<leptos::html::Div>::new();
|
||||
|
||||
// Store scale factors for coordinate conversion (shared between both canvases)
|
||||
let scale_x = StoredValue::new(1.0_f64);
|
||||
let scale_y = StoredValue::new(1.0_f64);
|
||||
|
|
@ -160,103 +209,193 @@ pub fn RealmSceneViewer(
|
|||
|
||||
let image_path_clone = image_path.clone();
|
||||
let bg_color_clone = bg_color.clone();
|
||||
let scene_width_f = scene_width as f64;
|
||||
let scene_height_f = scene_height as f64;
|
||||
|
||||
// Flag to track if background has been drawn
|
||||
let bg_drawn = Rc::new(RefCell::new(false));
|
||||
|
||||
// =========================================================
|
||||
// Background Effect - runs once on mount, draws static background
|
||||
// Viewport Dimensions Effect - tracks outer container size
|
||||
// Uses setTimeout to ensure DOM is ready after mount
|
||||
// =========================================================
|
||||
Effect::new(move |_| {
|
||||
// Don't track any reactive signals - this should only run once
|
||||
// Track pan mode to re-run when it changes (affects container layout)
|
||||
let _ = is_pan_mode.get();
|
||||
|
||||
let Some(container) = outer_container_ref.get() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let container_el: web_sys::HtmlElement = container.into();
|
||||
|
||||
// Use setTimeout to ensure DOM has settled after any layout changes
|
||||
let update_dimensions = Closure::once(Box::new(move || {
|
||||
let width = container_el.client_width() as f64;
|
||||
let height = container_el.client_height() as f64;
|
||||
|
||||
if width > 0.0 && height > 0.0 {
|
||||
set_viewport_dimensions.set((width, height));
|
||||
}
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
let window = web_sys::window().unwrap();
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
update_dimensions.as_ref().unchecked_ref(),
|
||||
150, // Slightly longer delay to ensure DOM settles
|
||||
);
|
||||
update_dimensions.forget();
|
||||
});
|
||||
|
||||
// Track the last settings to detect changes
|
||||
let last_pan_mode = Rc::new(RefCell::new(None::<bool>));
|
||||
let last_zoom = Rc::new(RefCell::new(None::<f64>));
|
||||
|
||||
// =========================================================
|
||||
// Background Effect - redraws when settings change
|
||||
// =========================================================
|
||||
Effect::new(move |_| {
|
||||
// Track settings signals - this Effect reruns when they change
|
||||
let current_pan_mode = is_pan_mode.get();
|
||||
let current_zoom = zoom_level.get();
|
||||
|
||||
let Some(canvas) = bg_canvas_ref.get() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Skip if already drawn
|
||||
if *bg_drawn.borrow() {
|
||||
// Check if we need to redraw (settings changed or first render)
|
||||
let needs_redraw = {
|
||||
let last_pan = *last_pan_mode.borrow();
|
||||
let last_z = *last_zoom.borrow();
|
||||
last_pan != Some(current_pan_mode)
|
||||
|| (current_pan_mode && last_z != Some(current_zoom))
|
||||
};
|
||||
|
||||
if !needs_redraw {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update last values
|
||||
*last_pan_mode.borrow_mut() = Some(current_pan_mode);
|
||||
*last_zoom.borrow_mut() = Some(current_zoom);
|
||||
|
||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||
let canvas_el = canvas_el.clone();
|
||||
let bg_color = bg_color_clone.clone();
|
||||
let image_path = image_path_clone.clone();
|
||||
let bg_drawn_inner = bg_drawn.clone();
|
||||
|
||||
// Use setTimeout to ensure DOM is ready before drawing
|
||||
let draw_bg = Closure::once(Box::new(move || {
|
||||
let display_width = canvas_el.client_width() as u32;
|
||||
let display_height = canvas_el.client_height() as u32;
|
||||
if current_pan_mode {
|
||||
// Pan mode: canvas at native resolution * zoom
|
||||
let canvas_width = (scene_width_f * current_zoom) as u32;
|
||||
let canvas_height = (scene_height_f * current_zoom) as u32;
|
||||
|
||||
// If still no dimensions, the canvas likely isn't visible - skip drawing
|
||||
if display_width == 0 || display_height == 0 {
|
||||
return;
|
||||
}
|
||||
canvas_el.set_width(canvas_width);
|
||||
canvas_el.set_height(canvas_height);
|
||||
|
||||
canvas_el.set_width(display_width);
|
||||
canvas_el.set_height(display_height);
|
||||
// Store scale factors (zoom level, no offset)
|
||||
scale_x.set_value(current_zoom);
|
||||
scale_y.set_value(current_zoom);
|
||||
offset_x.set_value(0.0);
|
||||
offset_y.set_value(0.0);
|
||||
|
||||
// Calculate scale to fit scene in canvas
|
||||
let canvas_aspect = display_width as f64 / display_height as f64;
|
||||
let scene_aspect = scene_width_f / scene_height_f;
|
||||
// Signal that scale factors are ready
|
||||
set_scales_ready.set(true);
|
||||
|
||||
let (draw_width, draw_height, draw_x, draw_y) = if canvas_aspect > scene_aspect {
|
||||
let h = display_height as f64;
|
||||
let w = h * scene_aspect;
|
||||
let x = (display_width as f64 - w) / 2.0;
|
||||
(w, h, x, 0.0)
|
||||
if let Ok(Some(ctx)) = canvas_el.get_context("2d") {
|
||||
let ctx: web_sys::CanvasRenderingContext2d =
|
||||
ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap();
|
||||
|
||||
// Fill entire canvas with background color (no letterboxing)
|
||||
ctx.set_fill_style_str(&bg_color);
|
||||
ctx.fill_rect(0.0, 0.0, canvas_width as f64, canvas_height as f64);
|
||||
|
||||
// Draw background image if available
|
||||
if has_background_image && !image_path.is_empty() {
|
||||
let img = web_sys::HtmlImageElement::new().unwrap();
|
||||
let img_clone = img.clone();
|
||||
let ctx_clone = ctx.clone();
|
||||
|
||||
let onload = Closure::once(Box::new(move || {
|
||||
let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh(
|
||||
&img_clone,
|
||||
0.0,
|
||||
0.0,
|
||||
canvas_width as f64,
|
||||
canvas_height as f64,
|
||||
);
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
img.set_onload(Some(onload.as_ref().unchecked_ref()));
|
||||
onload.forget();
|
||||
img.set_src(&image_path);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let w = display_width as f64;
|
||||
let h = w / scene_aspect;
|
||||
let y = (display_height as f64 - h) / 2.0;
|
||||
(w, h, 0.0, y)
|
||||
};
|
||||
// Fit mode: scale to viewport with letterboxing
|
||||
let display_width = canvas_el.client_width() as u32;
|
||||
let display_height = canvas_el.client_height() as u32;
|
||||
|
||||
// Store scale factors
|
||||
let sx = draw_width / scene_width_f;
|
||||
let sy = draw_height / scene_height_f;
|
||||
scale_x.set_value(sx);
|
||||
scale_y.set_value(sy);
|
||||
offset_x.set_value(draw_x);
|
||||
offset_y.set_value(draw_y);
|
||||
|
||||
// Signal that scale factors are ready
|
||||
set_scales_ready.set(true);
|
||||
|
||||
if let Ok(Some(ctx)) = canvas_el.get_context("2d") {
|
||||
let ctx: web_sys::CanvasRenderingContext2d =
|
||||
ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap();
|
||||
|
||||
// Fill letterbox area with black
|
||||
ctx.set_fill_style_str("#000");
|
||||
ctx.fill_rect(0.0, 0.0, display_width as f64, display_height as f64);
|
||||
|
||||
// Fill scene area with background color
|
||||
ctx.set_fill_style_str(&bg_color);
|
||||
ctx.fill_rect(draw_x, draw_y, draw_width, draw_height);
|
||||
|
||||
// Draw background image if available
|
||||
if has_background_image && !image_path.is_empty() {
|
||||
let img = web_sys::HtmlImageElement::new().unwrap();
|
||||
let img_clone = img.clone();
|
||||
let ctx_clone = ctx.clone();
|
||||
|
||||
let onload = Closure::once(Box::new(move || {
|
||||
let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh(
|
||||
&img_clone, draw_x, draw_y, draw_width, draw_height,
|
||||
);
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
img.set_onload(Some(onload.as_ref().unchecked_ref()));
|
||||
onload.forget();
|
||||
img.set_src(&image_path);
|
||||
// If still no dimensions, the canvas likely isn't visible - skip drawing
|
||||
if display_width == 0 || display_height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark background as drawn
|
||||
*bg_drawn_inner.borrow_mut() = true;
|
||||
canvas_el.set_width(display_width);
|
||||
canvas_el.set_height(display_height);
|
||||
|
||||
// Calculate scale to fit scene in canvas
|
||||
let canvas_aspect = display_width as f64 / display_height as f64;
|
||||
let scene_aspect = scene_width_f / scene_height_f;
|
||||
|
||||
let (draw_width, draw_height, draw_x, draw_y) = if canvas_aspect > scene_aspect {
|
||||
let h = display_height as f64;
|
||||
let w = h * scene_aspect;
|
||||
let x = (display_width as f64 - w) / 2.0;
|
||||
(w, h, x, 0.0)
|
||||
} else {
|
||||
let w = display_width as f64;
|
||||
let h = w / scene_aspect;
|
||||
let y = (display_height as f64 - h) / 2.0;
|
||||
(w, h, 0.0, y)
|
||||
};
|
||||
|
||||
// Store scale factors
|
||||
let sx = draw_width / scene_width_f;
|
||||
let sy = draw_height / scene_height_f;
|
||||
scale_x.set_value(sx);
|
||||
scale_y.set_value(sy);
|
||||
offset_x.set_value(draw_x);
|
||||
offset_y.set_value(draw_y);
|
||||
|
||||
// Signal that scale factors are ready
|
||||
set_scales_ready.set(true);
|
||||
|
||||
if let Ok(Some(ctx)) = canvas_el.get_context("2d") {
|
||||
let ctx: web_sys::CanvasRenderingContext2d =
|
||||
ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap();
|
||||
|
||||
// Fill letterbox area with black
|
||||
ctx.set_fill_style_str("#000");
|
||||
ctx.fill_rect(0.0, 0.0, display_width as f64, display_height as f64);
|
||||
|
||||
// Fill scene area with background color
|
||||
ctx.set_fill_style_str(&bg_color);
|
||||
ctx.fill_rect(draw_x, draw_y, draw_width, draw_height);
|
||||
|
||||
// Draw background image if available
|
||||
if has_background_image && !image_path.is_empty() {
|
||||
let img = web_sys::HtmlImageElement::new().unwrap();
|
||||
let img_clone = img.clone();
|
||||
let ctx_clone = ctx.clone();
|
||||
|
||||
let onload = Closure::once(Box::new(move || {
|
||||
let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh(
|
||||
&img_clone, draw_x, draw_y, draw_width, draw_height,
|
||||
);
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
img.set_onload(Some(onload.as_ref().unchecked_ref()));
|
||||
onload.forget();
|
||||
img.set_src(&image_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
|
|
@ -270,12 +409,15 @@ pub fn RealmSceneViewer(
|
|||
});
|
||||
|
||||
// =========================================================
|
||||
// Avatar Effect - runs when members or bubbles change
|
||||
// Avatar Effect - runs when members, bubbles, or settings change
|
||||
// =========================================================
|
||||
Effect::new(move |_| {
|
||||
// Track both signals - this Effect reruns when either changes
|
||||
// Track signals - this Effect reruns when any changes
|
||||
let current_members = members.get();
|
||||
let current_bubbles = active_bubbles.get();
|
||||
let current_pan_mode = is_pan_mode.get();
|
||||
let current_zoom = zoom_level.get();
|
||||
let current_enlarge = enlarge_props.get();
|
||||
|
||||
// Skip drawing if scale factors haven't been calculated yet
|
||||
if !scales_ready.get() {
|
||||
|
|
@ -290,25 +432,19 @@ pub fn RealmSceneViewer(
|
|||
let canvas_el = canvas_el.clone();
|
||||
|
||||
let draw_avatars_closure = Closure::once(Box::new(move || {
|
||||
let display_width = canvas_el.client_width() as u32;
|
||||
let display_height = canvas_el.client_height() as u32;
|
||||
let canvas_width = canvas_el.width();
|
||||
let canvas_height = canvas_el.height();
|
||||
|
||||
if display_width == 0 || display_height == 0 {
|
||||
if canvas_width == 0 || canvas_height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resize avatar canvas to match (if needed)
|
||||
if canvas_el.width() != display_width || canvas_el.height() != display_height {
|
||||
canvas_el.set_width(display_width);
|
||||
canvas_el.set_height(display_height);
|
||||
}
|
||||
|
||||
if let Ok(Some(ctx)) = canvas_el.get_context("2d") {
|
||||
let ctx: web_sys::CanvasRenderingContext2d =
|
||||
ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap();
|
||||
|
||||
// Clear with transparency (not fill - keeps canvas transparent)
|
||||
ctx.clear_rect(0.0, 0.0, display_width as f64, display_height as f64);
|
||||
ctx.clear_rect(0.0, 0.0, canvas_width as f64, canvas_height as f64);
|
||||
|
||||
// Get stored scale factors
|
||||
let sx = scale_x.get_value();
|
||||
|
|
@ -316,8 +452,19 @@ pub fn RealmSceneViewer(
|
|||
let ox = offset_x.get_value();
|
||||
let oy = offset_y.get_value();
|
||||
|
||||
// Calculate prop size based on mode
|
||||
let prop_size = calculate_prop_size(
|
||||
current_pan_mode,
|
||||
current_zoom,
|
||||
current_enlarge,
|
||||
sx,
|
||||
sy,
|
||||
scene_width_f,
|
||||
scene_height_f,
|
||||
);
|
||||
|
||||
// Draw avatars first
|
||||
draw_avatars(&ctx, ¤t_members, sx, sy, ox, oy);
|
||||
draw_avatars(&ctx, ¤t_members, sx, sy, ox, oy, prop_size);
|
||||
|
||||
// Draw speech bubbles on top
|
||||
let current_time = js_sys::Date::now() as i64;
|
||||
|
|
@ -330,6 +477,7 @@ pub fn RealmSceneViewer(
|
|||
ox,
|
||||
oy,
|
||||
current_time,
|
||||
prop_size,
|
||||
);
|
||||
}
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
|
@ -340,11 +488,14 @@ pub fn RealmSceneViewer(
|
|||
});
|
||||
|
||||
// =========================================================
|
||||
// Props Effect - runs when loose_props changes
|
||||
// Props Effect - runs when loose_props or settings change
|
||||
// =========================================================
|
||||
Effect::new(move |_| {
|
||||
// Track loose_props signal
|
||||
// Track signals
|
||||
let current_props = loose_props.get();
|
||||
let current_pan_mode = is_pan_mode.get();
|
||||
let current_zoom = zoom_level.get();
|
||||
let current_enlarge = enlarge_props.get();
|
||||
|
||||
// Skip drawing if scale factors haven't been calculated yet
|
||||
if !scales_ready.get() {
|
||||
|
|
@ -359,25 +510,19 @@ pub fn RealmSceneViewer(
|
|||
let canvas_el = canvas_el.clone();
|
||||
|
||||
let draw_props_closure = Closure::once(Box::new(move || {
|
||||
let display_width = canvas_el.client_width() as u32;
|
||||
let display_height = canvas_el.client_height() as u32;
|
||||
let canvas_width = canvas_el.width();
|
||||
let canvas_height = canvas_el.height();
|
||||
|
||||
if display_width == 0 || display_height == 0 {
|
||||
if canvas_width == 0 || canvas_height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resize props canvas to match (if needed)
|
||||
if canvas_el.width() != display_width || canvas_el.height() != display_height {
|
||||
canvas_el.set_width(display_width);
|
||||
canvas_el.set_height(display_height);
|
||||
}
|
||||
|
||||
if let Ok(Some(ctx)) = canvas_el.get_context("2d") {
|
||||
let ctx: web_sys::CanvasRenderingContext2d =
|
||||
ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap();
|
||||
|
||||
// Clear with transparency
|
||||
ctx.clear_rect(0.0, 0.0, display_width as f64, display_height as f64);
|
||||
ctx.clear_rect(0.0, 0.0, canvas_width as f64, canvas_height as f64);
|
||||
|
||||
// Get stored scale factors
|
||||
let sx = scale_x.get_value();
|
||||
|
|
@ -385,8 +530,19 @@ pub fn RealmSceneViewer(
|
|||
let ox = offset_x.get_value();
|
||||
let oy = offset_y.get_value();
|
||||
|
||||
// Calculate prop size based on mode
|
||||
let prop_size = calculate_prop_size(
|
||||
current_pan_mode,
|
||||
current_zoom,
|
||||
current_enlarge,
|
||||
sx,
|
||||
sy,
|
||||
scene_width_f,
|
||||
scene_height_f,
|
||||
);
|
||||
|
||||
// Draw loose props
|
||||
draw_loose_props(&ctx, ¤t_props, sx, sy, ox, oy);
|
||||
draw_loose_props(&ctx, ¤t_props, sx, sy, ox, oy, prop_size);
|
||||
}
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
|
|
@ -394,38 +550,312 @@ pub fn RealmSceneViewer(
|
|||
let _ = window.request_animation_frame(draw_props_closure.as_ref().unchecked_ref());
|
||||
draw_props_closure.forget();
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// Sync canvas sizes when mode or zoom changes
|
||||
// =========================================================
|
||||
Effect::new(move |_| {
|
||||
let current_pan_mode = is_pan_mode.get();
|
||||
let current_zoom = zoom_level.get();
|
||||
|
||||
// Wait for scales to be ready (background drawn)
|
||||
if !scales_ready.get() {
|
||||
return;
|
||||
}
|
||||
|
||||
if current_pan_mode {
|
||||
// Pan mode: resize props and avatar canvases to match background
|
||||
let canvas_width = (scene_width_f * current_zoom) as u32;
|
||||
let canvas_height = (scene_height_f * current_zoom) as u32;
|
||||
|
||||
if let Some(canvas) = props_canvas_ref.get() {
|
||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||
if canvas_el.width() != canvas_width || canvas_el.height() != canvas_height {
|
||||
canvas_el.set_width(canvas_width);
|
||||
canvas_el.set_height(canvas_height);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(canvas) = avatar_canvas_ref.get() {
|
||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||
if canvas_el.width() != canvas_width || canvas_el.height() != canvas_height {
|
||||
canvas_el.set_width(canvas_width);
|
||||
canvas_el.set_height(canvas_height);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fit mode: sync props and avatar canvases to background canvas size
|
||||
if let Some(bg_canvas) = bg_canvas_ref.get() {
|
||||
let bg_el: &web_sys::HtmlCanvasElement = &bg_canvas;
|
||||
let canvas_width = bg_el.width();
|
||||
let canvas_height = bg_el.height();
|
||||
|
||||
if canvas_width > 0 && canvas_height > 0 {
|
||||
if let Some(canvas) = props_canvas_ref.get() {
|
||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||
if canvas_el.width() != canvas_width || canvas_el.height() != canvas_height {
|
||||
canvas_el.set_width(canvas_width);
|
||||
canvas_el.set_height(canvas_height);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(canvas) = avatar_canvas_ref.get() {
|
||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||
if canvas_el.width() != canvas_width || canvas_el.height() != canvas_height {
|
||||
canvas_el.set_width(canvas_width);
|
||||
canvas_el.set_height(canvas_height);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// Middle mouse button drag-to-pan (only in pan mode)
|
||||
// =========================================================
|
||||
Effect::new(move |_| {
|
||||
// Track pan mode - re-run when it changes
|
||||
let pan_mode_enabled = is_pan_mode.get();
|
||||
|
||||
let Some(container) = outer_container_ref.get() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let container_el: web_sys::HtmlElement = container.into();
|
||||
|
||||
if !pan_mode_enabled {
|
||||
// Reset cursor when not in pan mode
|
||||
let _ = container_el.style().set_property("cursor", "");
|
||||
return;
|
||||
}
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
|
||||
let is_dragging = Rc::new(Cell::new(false));
|
||||
let last_x = Rc::new(Cell::new(0i32));
|
||||
let last_y = Rc::new(Cell::new(0i32));
|
||||
|
||||
let container_for_move = container_el.clone();
|
||||
let is_dragging_move = is_dragging.clone();
|
||||
let last_x_move = last_x.clone();
|
||||
let last_y_move = last_y.clone();
|
||||
|
||||
let container_for_down = container_el.clone();
|
||||
let is_dragging_down = is_dragging.clone();
|
||||
let last_x_down = last_x.clone();
|
||||
let last_y_down = last_y.clone();
|
||||
|
||||
// Middle mouse down - start drag
|
||||
let onmousedown = Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
|
||||
// Button 1 is middle mouse button
|
||||
if ev.button() == 1 {
|
||||
is_dragging_down.set(true);
|
||||
last_x_down.set(ev.client_x());
|
||||
last_y_down.set(ev.client_y());
|
||||
let _ = container_for_down.style().set_property("cursor", "grabbing");
|
||||
ev.prevent_default();
|
||||
}
|
||||
});
|
||||
|
||||
// Mouse move - drag scroll
|
||||
let onmousemove = Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
|
||||
if is_dragging_move.get() {
|
||||
let dx = last_x_move.get() - ev.client_x();
|
||||
let dy = last_y_move.get() - ev.client_y();
|
||||
last_x_move.set(ev.client_x());
|
||||
last_y_move.set(ev.client_y());
|
||||
container_for_move.set_scroll_left(container_for_move.scroll_left() + dx);
|
||||
container_for_move.set_scroll_top(container_for_move.scroll_top() + dy);
|
||||
}
|
||||
});
|
||||
|
||||
let container_for_up = container_el.clone();
|
||||
let is_dragging_up = is_dragging.clone();
|
||||
|
||||
// Mouse up - stop drag
|
||||
let onmouseup = Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |_ev: web_sys::MouseEvent| {
|
||||
if is_dragging_up.get() {
|
||||
is_dragging_up.set(false);
|
||||
let _ = container_for_up.style().set_property("cursor", "");
|
||||
}
|
||||
});
|
||||
|
||||
// Add event listeners
|
||||
let _ = container_el.add_event_listener_with_callback(
|
||||
"mousedown",
|
||||
onmousedown.as_ref().unchecked_ref(),
|
||||
);
|
||||
let _ = container_el.add_event_listener_with_callback(
|
||||
"mousemove",
|
||||
onmousemove.as_ref().unchecked_ref(),
|
||||
);
|
||||
let _ = container_el.add_event_listener_with_callback(
|
||||
"mouseup",
|
||||
onmouseup.as_ref().unchecked_ref(),
|
||||
);
|
||||
|
||||
// Also listen for mouseup on window (in case mouse released outside container)
|
||||
if let Some(window) = web_sys::window() {
|
||||
let is_dragging_window = is_dragging.clone();
|
||||
let container_for_window = container_el.clone();
|
||||
let onmouseup_window = Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |_ev: web_sys::MouseEvent| {
|
||||
if is_dragging_window.get() {
|
||||
is_dragging_window.set(false);
|
||||
let _ = container_for_window.style().set_property("cursor", "");
|
||||
}
|
||||
});
|
||||
let _ = window.add_event_listener_with_callback(
|
||||
"mouseup",
|
||||
onmouseup_window.as_ref().unchecked_ref(),
|
||||
);
|
||||
onmouseup_window.forget();
|
||||
}
|
||||
|
||||
// Prevent context menu on middle click
|
||||
let oncontextmenu = Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
|
||||
if ev.button() == 1 {
|
||||
ev.prevent_default();
|
||||
}
|
||||
});
|
||||
let _ = container_el.add_event_listener_with_callback(
|
||||
"auxclick",
|
||||
oncontextmenu.as_ref().unchecked_ref(),
|
||||
);
|
||||
|
||||
// Forget closures to keep them alive
|
||||
onmousedown.forget();
|
||||
onmousemove.forget();
|
||||
onmouseup.forget();
|
||||
oncontextmenu.forget();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
// Create wheel handler closure for use in view
|
||||
let handle_wheel = move |ev: leptos::web_sys::WheelEvent| {
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
// Only zoom in pan mode and without Ctrl key
|
||||
if is_pan_mode.get() && !ev.ctrl_key() {
|
||||
if let Some(zoom_callback) = on_zoom_change {
|
||||
let delta_y = ev.delta_y();
|
||||
// Normalize: scroll up (negative deltaY) = zoom in (positive delta)
|
||||
// Scroll down (positive deltaY) = zoom out (negative delta)
|
||||
let zoom_delta = if delta_y < 0.0 { 0.1 } else { -0.1 };
|
||||
zoom_callback.run(zoom_delta);
|
||||
ev.prevent_default();
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
let _ = ev;
|
||||
};
|
||||
|
||||
let aspect_ratio = scene_width as f64 / scene_height as f64;
|
||||
|
||||
// Computed styles based on mode
|
||||
let container_class = move || {
|
||||
if is_pan_mode.get() {
|
||||
"scene-canvas relative cursor-pointer"
|
||||
} else {
|
||||
"scene-canvas relative overflow-hidden cursor-pointer"
|
||||
}
|
||||
};
|
||||
|
||||
let outer_container_class = move || {
|
||||
if is_pan_mode.get() {
|
||||
let zoom = zoom_level.get();
|
||||
let (vp_w, vp_h) = viewport_dimensions.get();
|
||||
let canvas_w = scene_width_f * zoom;
|
||||
let canvas_h = scene_height_f * zoom;
|
||||
|
||||
// Center canvas if smaller than viewport in both dimensions
|
||||
if canvas_w <= vp_w && canvas_h <= vp_h {
|
||||
"scene-container w-full overflow-auto flex justify-center items-center"
|
||||
} else {
|
||||
"scene-container w-full overflow-auto"
|
||||
}
|
||||
} else {
|
||||
"scene-container w-full h-full flex justify-center items-center"
|
||||
}
|
||||
};
|
||||
|
||||
// Outer container needs max-height in pan mode to enable vertical scrolling
|
||||
let outer_container_style = move || {
|
||||
if is_pan_mode.get() {
|
||||
"max-height: calc(100vh - 64px)".to_string()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
|
||||
let container_style = move || {
|
||||
if is_pan_mode.get() {
|
||||
let zoom = zoom_level.get();
|
||||
format!(
|
||||
"width: {}px; height: {}px; background-color: {}",
|
||||
(scene_width as f64 * zoom) as u32,
|
||||
(scene_height as f64 * zoom) as u32,
|
||||
bg_color
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"aspect-ratio: {} / {}; width: min(100%, calc((100vh - 64px) * {})); max-height: calc(100vh - 64px); background-color: {}",
|
||||
scene_width, scene_height, aspect_ratio, bg_color
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let canvas_class = move || {
|
||||
if is_pan_mode.get() {
|
||||
"absolute inset-0"
|
||||
} else {
|
||||
"absolute inset-0 w-full h-full"
|
||||
}
|
||||
};
|
||||
|
||||
let canvas_style = move |z_index: i32| {
|
||||
if is_pan_mode.get() {
|
||||
let zoom = zoom_level.get();
|
||||
format!(
|
||||
"z-index: {}; width: {}px; height: {}px",
|
||||
z_index,
|
||||
(scene_width as f64 * zoom) as u32,
|
||||
(scene_height as f64 * zoom) as u32
|
||||
)
|
||||
} else {
|
||||
format!("z-index: {}; width: 100%; height: 100%", z_index)
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="scene-container w-full h-full flex justify-center items-center">
|
||||
<div node_ref=outer_container_ref class=outer_container_class style=outer_container_style on:wheel=handle_wheel>
|
||||
<div
|
||||
class="scene-canvas relative overflow-hidden cursor-pointer"
|
||||
style:background-color=bg_color.clone()
|
||||
style:aspect-ratio=format!("{} / {}", scene_width, scene_height)
|
||||
style:width=format!("min(100%, calc((100vh - 64px) * {}))", aspect_ratio)
|
||||
style:max-height="calc(100vh - 64px)"
|
||||
class=container_class
|
||||
style=container_style
|
||||
>
|
||||
// Background layer - static, drawn once
|
||||
<canvas
|
||||
node_ref=bg_canvas_ref
|
||||
class="absolute inset-0 w-full h-full"
|
||||
style="z-index: 0"
|
||||
class=canvas_class
|
||||
style=move || canvas_style(0)
|
||||
aria-hidden="true"
|
||||
/>
|
||||
// Props layer - loose props, redrawn on drop/pickup
|
||||
<canvas
|
||||
node_ref=props_canvas_ref
|
||||
class="absolute inset-0 w-full h-full"
|
||||
style="z-index: 1"
|
||||
class=canvas_class
|
||||
style=move || canvas_style(1)
|
||||
aria-hidden="true"
|
||||
/>
|
||||
// Avatar layer - dynamic, transparent background
|
||||
<canvas
|
||||
node_ref=avatar_canvas_ref
|
||||
class="absolute inset-0 w-full h-full"
|
||||
style="z-index: 2"
|
||||
class=canvas_class
|
||||
style=move || canvas_style(2)
|
||||
aria-label=format!("Scene: {}", scene.name)
|
||||
role="img"
|
||||
on:click=move |ev| {
|
||||
|
|
@ -445,6 +875,34 @@ pub fn RealmSceneViewer(
|
|||
#[cfg(feature = "hydrate")]
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
/// Calculate prop/avatar size based on current rendering mode.
|
||||
///
|
||||
/// - Pan mode: BASE_PROP_SIZE * zoom_level
|
||||
/// - Fit mode with enlarge: Reference scaling based on 1920x1080
|
||||
/// - Fit mode without enlarge: BASE_PROP_SIZE * min(scale_x, scale_y)
|
||||
#[cfg(feature = "hydrate")]
|
||||
fn calculate_prop_size(
|
||||
pan_mode: bool,
|
||||
zoom_level: f64,
|
||||
enlarge_props: bool,
|
||||
scale_x: f64,
|
||||
scale_y: f64,
|
||||
scene_width: f64,
|
||||
scene_height: f64,
|
||||
) -> f64 {
|
||||
if pan_mode {
|
||||
// Pan mode: base size * zoom
|
||||
BASE_PROP_SIZE * zoom_level
|
||||
} else if enlarge_props {
|
||||
// Reference scaling: scale props relative to 1920x1080 reference
|
||||
let ref_scale = (scene_width / REFERENCE_WIDTH).max(scene_height / REFERENCE_HEIGHT);
|
||||
BASE_PROP_SIZE * ref_scale * scale_x.min(scale_y)
|
||||
} else {
|
||||
// Default: base size scaled to viewport
|
||||
BASE_PROP_SIZE * scale_x.min(scale_y)
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize an asset path to be absolute, prefixing with /static/ if needed.
|
||||
#[cfg(feature = "hydrate")]
|
||||
fn normalize_asset_path(path: &str) -> String {
|
||||
|
|
@ -463,13 +921,14 @@ fn draw_avatars(
|
|||
scale_y: f64,
|
||||
offset_x: f64,
|
||||
offset_y: f64,
|
||||
prop_size: f64,
|
||||
) {
|
||||
let avatar_size = prop_size;
|
||||
|
||||
for member in members {
|
||||
let x = member.member.position_x * scale_x + offset_x;
|
||||
let y = member.member.position_y * scale_y + offset_y;
|
||||
|
||||
let avatar_size = 60.0 * scale_x.min(scale_y);
|
||||
|
||||
// Draw avatar placeholder circle
|
||||
ctx.begin_path();
|
||||
let _ = ctx.arc(x, y - avatar_size / 2.0, avatar_size / 2.0, 0.0, std::f64::consts::PI * 2.0);
|
||||
|
|
@ -516,11 +975,14 @@ fn draw_avatars(
|
|||
img.set_src(&normalize_asset_path(emotion_path));
|
||||
}
|
||||
|
||||
// Scale factor for text/badges relative to avatar size
|
||||
let text_scale = avatar_size / BASE_PROP_SIZE;
|
||||
|
||||
// Draw emotion indicator on avatar
|
||||
let emotion = member.member.current_emotion;
|
||||
if emotion > 0 {
|
||||
// Draw emotion number in a small badge
|
||||
let badge_size = 16.0 * scale_x.min(scale_y);
|
||||
let badge_size = 16.0 * text_scale;
|
||||
let badge_x = x + avatar_size / 2.0 - badge_size / 2.0;
|
||||
let badge_y = y - avatar_size - badge_size / 2.0;
|
||||
|
||||
|
|
@ -532,7 +994,7 @@ fn draw_avatars(
|
|||
|
||||
// Emotion number
|
||||
ctx.set_fill_style_str("#000");
|
||||
ctx.set_font(&format!("bold {}px sans-serif", 10.0 * scale_x.min(scale_y)));
|
||||
ctx.set_font(&format!("bold {}px sans-serif", 10.0 * text_scale));
|
||||
ctx.set_text_align("center");
|
||||
ctx.set_text_baseline("middle");
|
||||
let _ = ctx.fill_text(&format!("{}", emotion), badge_x, badge_y);
|
||||
|
|
@ -540,10 +1002,10 @@ fn draw_avatars(
|
|||
|
||||
// Draw display name
|
||||
ctx.set_fill_style_str("#fff");
|
||||
ctx.set_font(&format!("{}px sans-serif", 12.0 * scale_x.min(scale_y)));
|
||||
ctx.set_font(&format!("{}px sans-serif", 12.0 * text_scale));
|
||||
ctx.set_text_align("center");
|
||||
ctx.set_text_baseline("alphabetic");
|
||||
let _ = ctx.fill_text(&member.member.display_name, x, y + 10.0 * scale_y);
|
||||
let _ = ctx.fill_text(&member.member.display_name, x, y + 10.0 * text_scale);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -558,15 +1020,16 @@ fn draw_speech_bubbles(
|
|||
offset_x: f64,
|
||||
offset_y: f64,
|
||||
current_time_ms: i64,
|
||||
prop_size: f64,
|
||||
) {
|
||||
let scale = scale_x.min(scale_y);
|
||||
let avatar_size = 60.0 * scale;
|
||||
let max_bubble_width = 200.0 * scale;
|
||||
let padding = 8.0 * scale;
|
||||
let font_size = 12.0 * scale;
|
||||
let line_height = 16.0 * scale;
|
||||
let tail_size = 8.0 * scale;
|
||||
let border_radius = 8.0 * scale;
|
||||
let avatar_size = prop_size;
|
||||
let text_scale = avatar_size / BASE_PROP_SIZE;
|
||||
let max_bubble_width = 200.0 * text_scale;
|
||||
let padding = 8.0 * text_scale;
|
||||
let font_size = 12.0 * text_scale;
|
||||
let line_height = 16.0 * text_scale;
|
||||
let tail_size = 8.0 * text_scale;
|
||||
let border_radius = 8.0 * text_scale;
|
||||
|
||||
for member in members {
|
||||
let key = (member.member.user_id, member.member.guest_session_id);
|
||||
|
|
@ -599,12 +1062,12 @@ fn draw_speech_bubbles(
|
|||
})
|
||||
.fold(0.0_f64, |a: f64, b: f64| a.max(b))
|
||||
+ padding * 2.0;
|
||||
let bubble_width = bubble_width.max(60.0 * scale); // Minimum width
|
||||
let bubble_width = bubble_width.max(60.0 * text_scale); // Minimum width
|
||||
let bubble_height = (lines.len() as f64) * line_height + padding * 2.0;
|
||||
|
||||
// Position bubble above avatar
|
||||
let bubble_x = x - bubble_width / 2.0;
|
||||
let bubble_y = y - avatar_size - bubble_height - tail_size - 5.0 * scale;
|
||||
let bubble_y = y - avatar_size - bubble_height - tail_size - 5.0 * text_scale;
|
||||
|
||||
// Draw bubble background with rounded corners
|
||||
draw_rounded_rect(ctx, bubble_x, bubble_y, bubble_width, bubble_height, border_radius);
|
||||
|
|
@ -719,8 +1182,8 @@ fn draw_loose_props(
|
|||
scale_y: f64,
|
||||
offset_x: f64,
|
||||
offset_y: f64,
|
||||
prop_size: f64,
|
||||
) {
|
||||
let prop_size = 60.0 * scale_x.min(scale_y);
|
||||
|
||||
for prop in props {
|
||||
let x = prop.position_x * scale_x + offset_x;
|
||||
|
|
@ -755,8 +1218,9 @@ fn draw_loose_props(
|
|||
ctx.stroke();
|
||||
|
||||
// Draw prop name below
|
||||
let text_scale = prop_size / BASE_PROP_SIZE;
|
||||
ctx.set_fill_style_str("#fff");
|
||||
ctx.set_font(&format!("{}px sans-serif", 10.0 * scale_x.min(scale_y)));
|
||||
ctx.set_font(&format!("{}px sans-serif", 10.0 * text_scale));
|
||||
ctx.set_text_align("center");
|
||||
ctx.set_text_baseline("top");
|
||||
let _ = ctx.fill_text(&prop.prop_name, x, y + prop_size / 2.0 + 2.0);
|
||||
|
|
|
|||
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::{
|
||||
ActiveBubble, Card, ChatInput, ChatMessage, InventoryPopup, MessageLog, RealmHeader,
|
||||
RealmSceneViewer, DEFAULT_BUBBLE_TIMEOUT_MS,
|
||||
RealmSceneViewer, SettingsPopup, ViewerSettings, DEFAULT_BUBBLE_TIMEOUT_MS,
|
||||
};
|
||||
#[cfg(feature = "hydrate")]
|
||||
use crate::components::use_channel_websocket;
|
||||
|
|
@ -26,6 +26,44 @@ use chattyness_db::ws_messages::ClientMessage;
|
|||
|
||||
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.
|
||||
#[component]
|
||||
pub fn RealmPage() -> impl IntoView {
|
||||
|
|
@ -58,6 +96,16 @@ pub fn RealmPage() -> impl IntoView {
|
|||
// Inventory popup state
|
||||
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
|
||||
let (loose_props, set_loose_props) = signal(Vec::<LooseProp>::new());
|
||||
|
||||
|
|
@ -205,7 +253,7 @@ pub fn RealmPage() -> impl IntoView {
|
|||
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
|
||||
// uses scenes directly. Proper channel infrastructure can be added later.
|
||||
#[cfg(feature = "hydrate")]
|
||||
|
|
@ -215,6 +263,11 @@ pub fn RealmPage() -> impl IntoView {
|
|||
return;
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 == ":" {
|
||||
set_focus_prefix.set(':');
|
||||
set_focus_chat_trigger.set(true);
|
||||
// Reset trigger after a short delay so it can be triggered again
|
||||
use gloo_timers::callback::Timeout;
|
||||
|
|
@ -317,9 +384,68 @@ pub fn RealmPage() -> impl IntoView {
|
|||
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" {
|
||||
set_inventory_open.set(true);
|
||||
set_inventory_open.update(|v| *v = !*v);
|
||||
ev.prevent_default();
|
||||
return;
|
||||
}
|
||||
|
|
@ -502,6 +628,13 @@ pub fn RealmPage() -> impl IntoView {
|
|||
let ws_for_chat: StoredValue<Option<WsSender>, LocalStorage> = StoredValue::new_local(None);
|
||||
let active_bubbles_signal = Signal::derive(move || active_bubbles.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! {
|
||||
<div class="relative w-full">
|
||||
<RealmSceneViewer
|
||||
|
|
@ -512,6 +645,13 @@ pub fn RealmPage() -> impl IntoView {
|
|||
loose_props=loose_props_signal
|
||||
on_move=on_move.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">
|
||||
<ChatInput
|
||||
|
|
@ -519,7 +659,10 @@ pub fn RealmPage() -> impl IntoView {
|
|||
emotion_availability=emotion_avail_signal
|
||||
skin_preview_path=skin_path_signal
|
||||
focus_trigger=focus_trigger_signal
|
||||
focus_prefix=focus_prefix_signal
|
||||
on_focus_change=on_chat_focus_change.clone()
|
||||
on_open_settings=on_open_settings_cb
|
||||
on_open_inventory=on_open_inventory_cb
|
||||
/>
|
||||
</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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue