fix: avatar center to cursor, and cleanup.
Lots of cleanup, went in with this too
This commit is contained in:
parent
c3320ddcce
commit
fe65835f4a
14 changed files with 769 additions and 708 deletions
|
|
@ -14,6 +14,75 @@ use super::chat_types::{emotion_bubble_colors, ActiveBubble};
|
|||
/// Base text size multiplier. Text at 100% slider = base_sizes * 1.4
|
||||
const BASE_TEXT_SCALE: f64 = 1.4;
|
||||
|
||||
/// Content bounds for a 3x3 avatar grid.
|
||||
/// Tracks which rows/columns contain actual content for centering calculations.
|
||||
struct ContentBounds {
|
||||
min_col: usize,
|
||||
max_col: usize,
|
||||
min_row: usize,
|
||||
max_row: usize,
|
||||
}
|
||||
|
||||
impl ContentBounds {
|
||||
/// Calculate content bounds from 4 layers (9 positions each).
|
||||
fn from_layers(
|
||||
skin: &[Option<String>; 9],
|
||||
clothes: &[Option<String>; 9],
|
||||
accessories: &[Option<String>; 9],
|
||||
emotion: &[Option<String>; 9],
|
||||
) -> Self {
|
||||
let has_content_at = |pos: usize| -> bool {
|
||||
skin[pos].is_some()
|
||||
|| clothes[pos].is_some()
|
||||
|| accessories[pos].is_some()
|
||||
|| emotion[pos].is_some()
|
||||
};
|
||||
|
||||
// Columns: 0 (left), 1 (middle), 2 (right)
|
||||
let left_col = [0, 3, 6].iter().any(|&p| has_content_at(p));
|
||||
let mid_col = [1, 4, 7].iter().any(|&p| has_content_at(p));
|
||||
let right_col = [2, 5, 8].iter().any(|&p| has_content_at(p));
|
||||
|
||||
let min_col = if left_col { 0 } else if mid_col { 1 } else { 2 };
|
||||
let max_col = if right_col { 2 } else if mid_col { 1 } else { 0 };
|
||||
|
||||
// Rows: 0 (top), 1 (middle), 2 (bottom)
|
||||
let top_row = [0, 1, 2].iter().any(|&p| has_content_at(p));
|
||||
let mid_row = [3, 4, 5].iter().any(|&p| has_content_at(p));
|
||||
let bot_row = [6, 7, 8].iter().any(|&p| has_content_at(p));
|
||||
|
||||
let min_row = if top_row { 0 } else if mid_row { 1 } else { 2 };
|
||||
let max_row = if bot_row { 2 } else if mid_row { 1 } else { 0 };
|
||||
|
||||
Self { min_col, max_col, min_row, max_row }
|
||||
}
|
||||
|
||||
/// Content center column (0.0 to 2.0, grid center is 1.0).
|
||||
fn center_col(&self) -> f64 {
|
||||
(self.min_col + self.max_col) as f64 / 2.0
|
||||
}
|
||||
|
||||
/// Content center row (0.0 to 2.0, grid center is 1.0).
|
||||
fn center_row(&self) -> f64 {
|
||||
(self.min_row + self.max_row) as f64 / 2.0
|
||||
}
|
||||
|
||||
/// X offset from grid center to content center.
|
||||
fn x_offset(&self, cell_size: f64) -> f64 {
|
||||
(self.center_col() - 1.0) * cell_size
|
||||
}
|
||||
|
||||
/// Y offset from grid center to content center.
|
||||
fn y_offset(&self, cell_size: f64) -> f64 {
|
||||
(self.center_row() - 1.0) * cell_size
|
||||
}
|
||||
|
||||
/// Number of empty rows at the bottom (for name positioning).
|
||||
fn empty_bottom_rows(&self) -> usize {
|
||||
2 - self.max_row
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a unique key for a member (for Leptos For keying).
|
||||
pub fn member_key(m: &ChannelMemberWithAvatar) -> (Option<Uuid>, Option<Uuid>) {
|
||||
(m.member.user_id, m.member.guest_session_id)
|
||||
|
|
@ -57,39 +126,27 @@ pub fn AvatarCanvas(
|
|||
let display_name = member.member.display_name.clone();
|
||||
let current_emotion = member.member.current_emotion;
|
||||
|
||||
// Helper to check if any layer has content at a position
|
||||
let has_content_at = |pos: usize| -> bool {
|
||||
skin_layer[pos].is_some()
|
||||
|| clothes_layer[pos].is_some()
|
||||
|| accessories_layer[pos].is_some()
|
||||
|| emotion_layer[pos].is_some()
|
||||
};
|
||||
// Calculate content bounds for centering on actual content
|
||||
let content_bounds = ContentBounds::from_layers(
|
||||
&skin_layer,
|
||||
&clothes_layer,
|
||||
&accessories_layer,
|
||||
&emotion_layer,
|
||||
);
|
||||
|
||||
// Calculate content bounds for positioning
|
||||
// X-axis: which columns have content
|
||||
let left_col_has_content = [0, 3, 6].iter().any(|&p| has_content_at(p));
|
||||
let middle_col_has_content = [1, 4, 7].iter().any(|&p| has_content_at(p));
|
||||
let right_col_has_content = [2, 5, 8].iter().any(|&p| has_content_at(p));
|
||||
|
||||
let min_col = if left_col_has_content { 0 } else if middle_col_has_content { 1 } else { 2 };
|
||||
let max_col = if right_col_has_content { 2 } else if middle_col_has_content { 1 } else { 0 };
|
||||
let content_center_col = (min_col + max_col) as f64 / 2.0;
|
||||
let x_content_offset = (content_center_col - 1.0) * prop_size;
|
||||
|
||||
// Y-axis: which rows have content
|
||||
let bottom_row_has_content = [6, 7, 8].iter().any(|&p| has_content_at(p));
|
||||
let middle_row_has_content = [3, 4, 5].iter().any(|&p| has_content_at(p));
|
||||
|
||||
let max_row = if bottom_row_has_content { 2 } else if middle_row_has_content { 1 } else { 0 };
|
||||
let empty_bottom_rows = 2 - max_row;
|
||||
let y_content_offset = empty_bottom_rows as f64 * prop_size;
|
||||
// Get offsets from grid center to content center
|
||||
let x_content_offset = content_bounds.x_offset(prop_size);
|
||||
let y_content_offset = content_bounds.y_offset(prop_size);
|
||||
let empty_bottom_rows = content_bounds.empty_bottom_rows();
|
||||
|
||||
// Avatar is a 3x3 grid of props, each prop is prop_size
|
||||
let avatar_size = prop_size * 3.0;
|
||||
|
||||
// Calculate canvas position from scene coordinates, adjusted for content bounds
|
||||
let canvas_x = member.member.position_x * scale_x + offset_x - avatar_size / 2.0 - x_content_offset * scale_x;
|
||||
let canvas_y = member.member.position_y * scale_y + offset_y - avatar_size + y_content_offset * scale_y;
|
||||
// Both X and Y center the avatar content on the click point
|
||||
// Note: x_content_offset and y_content_offset are already in viewport pixels (prop_size includes scale)
|
||||
let canvas_x = member.member.position_x * scale_x + offset_x - avatar_size / 2.0 - x_content_offset;
|
||||
let canvas_y = member.member.position_y * scale_y + offset_y - avatar_size / 2.0 - y_content_offset;
|
||||
|
||||
// Fixed text dimensions (independent of prop_size/zoom)
|
||||
// Text stays readable regardless of zoom level - only affected by text_em_size slider
|
||||
|
|
@ -289,44 +346,15 @@ pub fn AvatarCanvas(
|
|||
let _ = ctx.fill_text(&format!("{}", current_emotion), badge_x, badge_y);
|
||||
}
|
||||
|
||||
// Helper to check if any layer has content at a position
|
||||
let has_content_at = |pos: usize| -> bool {
|
||||
skin_layer_clone[pos].is_some()
|
||||
|| clothes_layer_clone[pos].is_some()
|
||||
|| accessories_layer_clone[pos].is_some()
|
||||
|| emotion_layer_clone[pos].is_some()
|
||||
};
|
||||
|
||||
// Calculate empty bottom rows to adjust name Y position
|
||||
let mut empty_bottom_rows = 0;
|
||||
|
||||
// Check row 2 (positions 6, 7, 8)
|
||||
let row2_has_content = (6..=8).any(&has_content_at);
|
||||
if !row2_has_content {
|
||||
empty_bottom_rows += 1;
|
||||
// Check row 1 (positions 3, 4, 5)
|
||||
let row1_has_content = (3..=5).any(&has_content_at);
|
||||
if !row1_has_content {
|
||||
empty_bottom_rows += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate X offset to center name on columns with content
|
||||
// Column 0 (left): positions 0, 3, 6
|
||||
// Column 1 (middle): positions 1, 4, 7
|
||||
// Column 2 (right): positions 2, 5, 8
|
||||
let left_col_has_content = [0, 3, 6].iter().any(|&p| has_content_at(p));
|
||||
let middle_col_has_content = [1, 4, 7].iter().any(|&p| has_content_at(p));
|
||||
let right_col_has_content = [2, 5, 8].iter().any(|&p| has_content_at(p));
|
||||
|
||||
// Find leftmost and rightmost columns with content
|
||||
let min_col = if left_col_has_content { 0 } else if middle_col_has_content { 1 } else { 2 };
|
||||
let max_col = if right_col_has_content { 2 } else if middle_col_has_content { 1 } else { 0 };
|
||||
|
||||
// Calculate center of content columns (grid center is column 1)
|
||||
let content_center_col = (min_col + max_col) as f64 / 2.0;
|
||||
let x_offset = (content_center_col - 1.0) * cell_size;
|
||||
let name_x = avatar_cx + x_offset;
|
||||
// Calculate content bounds for name positioning
|
||||
let content_bounds = ContentBounds::from_layers(
|
||||
&skin_layer_clone,
|
||||
&clothes_layer_clone,
|
||||
&accessories_layer_clone,
|
||||
&emotion_layer_clone,
|
||||
);
|
||||
let name_x = avatar_cx + content_bounds.x_offset(cell_size);
|
||||
let empty_bottom_rows = content_bounds.empty_bottom_rows();
|
||||
|
||||
// Draw display name below avatar (with black outline for readability)
|
||||
ctx.set_font(&format!("{}px sans-serif", 12.0 * text_scale));
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ use chattyness_db::models::{AvatarWithPaths, InventoryItem};
|
|||
use chattyness_db::ws_messages::ClientMessage;
|
||||
|
||||
use super::ws_client::WsSenderStorage;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use crate::utils::normalize_asset_path;
|
||||
use crate::utils::use_escape_key;
|
||||
|
||||
/// Tab selection for the editor
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
|
|
@ -203,16 +206,6 @@ fn RenderedPreview(#[prop(into)] avatar: Signal<Option<AvatarWithPaths>>) -> imp
|
|||
}
|
||||
}
|
||||
|
||||
/// Normalize an asset path to be absolute, prefixing with /static/ if needed.
|
||||
#[cfg(feature = "hydrate")]
|
||||
fn normalize_asset_path(path: &str) -> String {
|
||||
if path.starts_with('/') {
|
||||
path.to_string()
|
||||
} else {
|
||||
format!("/static/{}", path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Avatar Editor popup component.
|
||||
///
|
||||
/// Props:
|
||||
|
|
@ -312,33 +305,7 @@ pub fn AvatarEditorPopup(
|
|||
}
|
||||
|
||||
// Handle escape key to close
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
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(),
|
||||
);
|
||||
}
|
||||
|
||||
closure.forget();
|
||||
});
|
||||
}
|
||||
use_escape_key(open, on_close.clone());
|
||||
|
||||
// Close context menu when clicking elsewhere
|
||||
let close_context_menu = move |_| {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ use chattyness_db::models::{InventoryItem, PublicProp};
|
|||
#[cfg(feature = "hydrate")]
|
||||
use chattyness_db::ws_messages::ClientMessage;
|
||||
|
||||
use super::modals::Modal;
|
||||
use super::tabs::{Tab, TabBar};
|
||||
use super::ws_client::WsSender;
|
||||
|
||||
/// Inventory popup component.
|
||||
|
|
@ -191,36 +193,6 @@ pub fn InventoryPopup(
|
|||
});
|
||||
}
|
||||
|
||||
// Handle escape key to close
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
// Handle drop action via WebSocket
|
||||
#[cfg(feature = "hydrate")]
|
||||
let handle_drop = {
|
||||
|
|
@ -250,135 +222,64 @@ pub fn InventoryPopup(
|
|||
#[cfg(not(feature = "hydrate"))]
|
||||
let handle_drop = |_item_id: Uuid| {};
|
||||
|
||||
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="inventory-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
|
||||
open=open
|
||||
on_close=on_close
|
||||
title="Inventory"
|
||||
title_id="inventory-modal-title"
|
||||
max_width="max-w-2xl"
|
||||
class="max-h-[80vh] flex flex-col"
|
||||
>
|
||||
// Tab bar
|
||||
<TabBar
|
||||
tabs=vec![
|
||||
Tab::new("my_inventory", "My Inventory"),
|
||||
Tab::new("server", "Server"),
|
||||
Tab::new("realm", "Realm"),
|
||||
]
|
||||
active=Signal::derive(move || active_tab.get())
|
||||
on_select=Callback::new(move |id| set_active_tab.set(id))
|
||||
/>
|
||||
|
||||
// Modal content
|
||||
<div class="relative bg-gray-800 rounded-lg shadow-2xl max-w-2xl w-full mx-4 p-6 border border-gray-700 max-h-[80vh] flex flex-col">
|
||||
// Header
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 id="inventory-modal-title" class="text-xl font-bold text-white">
|
||||
"Inventory"
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-white transition-colors"
|
||||
on:click=move |_| on_close_button.run(())
|
||||
aria-label="Close inventory"
|
||||
>
|
||||
<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>
|
||||
// Tab content
|
||||
<div class="flex-1 overflow-y-auto min-h-[300px]">
|
||||
// My Inventory tab
|
||||
<Show when=move || active_tab.get() == "my_inventory">
|
||||
<MyInventoryTab
|
||||
items=items
|
||||
loading=loading
|
||||
error=error
|
||||
selected_item=selected_item
|
||||
set_selected_item=set_selected_item
|
||||
dropping=dropping
|
||||
on_drop=Callback::new(handle_drop)
|
||||
/>
|
||||
</Show>
|
||||
|
||||
// Tab bar
|
||||
<div class="flex border-b border-gray-700 mb-4" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected=move || active_tab.get() == "my_inventory"
|
||||
class=move || format!(
|
||||
"px-4 py-2 font-medium transition-colors border-b-2 -mb-px {}",
|
||||
if active_tab.get() == "my_inventory" {
|
||||
"text-blue-400 border-blue-400"
|
||||
} else {
|
||||
"text-gray-400 border-transparent hover:text-gray-300"
|
||||
}
|
||||
)
|
||||
on:click=move |_| set_active_tab.set("my_inventory")
|
||||
>
|
||||
"My Inventory"
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected=move || active_tab.get() == "server"
|
||||
class=move || format!(
|
||||
"px-4 py-2 font-medium transition-colors border-b-2 -mb-px {}",
|
||||
if active_tab.get() == "server" {
|
||||
"text-blue-400 border-blue-400"
|
||||
} else {
|
||||
"text-gray-400 border-transparent hover:text-gray-300"
|
||||
}
|
||||
)
|
||||
on:click=move |_| set_active_tab.set("server")
|
||||
>
|
||||
"Server"
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected=move || active_tab.get() == "realm"
|
||||
class=move || format!(
|
||||
"px-4 py-2 font-medium transition-colors border-b-2 -mb-px {}",
|
||||
if active_tab.get() == "realm" {
|
||||
"text-blue-400 border-blue-400"
|
||||
} else {
|
||||
"text-gray-400 border-transparent hover:text-gray-300"
|
||||
}
|
||||
)
|
||||
on:click=move |_| set_active_tab.set("realm")
|
||||
>
|
||||
"Realm"
|
||||
</button>
|
||||
</div>
|
||||
// Server tab
|
||||
<Show when=move || active_tab.get() == "server">
|
||||
<PublicPropsTab
|
||||
props=server_props
|
||||
loading=server_loading
|
||||
error=server_error
|
||||
tab_name="Server"
|
||||
empty_message="No public server props available"
|
||||
/>
|
||||
</Show>
|
||||
|
||||
// Tab content
|
||||
<div class="flex-1 overflow-y-auto min-h-[300px]">
|
||||
// My Inventory tab
|
||||
<Show when=move || active_tab.get() == "my_inventory">
|
||||
<MyInventoryTab
|
||||
items=items
|
||||
loading=loading
|
||||
error=error
|
||||
selected_item=selected_item
|
||||
set_selected_item=set_selected_item
|
||||
dropping=dropping
|
||||
on_drop=Callback::new(handle_drop)
|
||||
/>
|
||||
</Show>
|
||||
|
||||
// Server tab
|
||||
<Show when=move || active_tab.get() == "server">
|
||||
<PublicPropsTab
|
||||
props=server_props
|
||||
loading=server_loading
|
||||
error=server_error
|
||||
tab_name="Server"
|
||||
empty_message="No public server props available"
|
||||
/>
|
||||
</Show>
|
||||
|
||||
// Realm tab
|
||||
<Show when=move || active_tab.get() == "realm">
|
||||
<PublicPropsTab
|
||||
props=realm_props
|
||||
loading=realm_loading
|
||||
error=realm_error
|
||||
tab_name="Realm"
|
||||
empty_message="No public realm props available"
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
// Realm tab
|
||||
<Show when=move || active_tab.get() == "realm">
|
||||
<PublicPropsTab
|
||||
props=realm_props
|
||||
loading=realm_loading
|
||||
error=realm_error
|
||||
tab_name="Realm"
|
||||
empty_message="No public realm props available"
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Modal>
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@
|
|||
use chattyness_db::models::EmotionState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// LocalStorage key for emotion keybindings.
|
||||
const KEYBINDINGS_KEY: &str = "chattyness_emotion_keybindings";
|
||||
use crate::utils::LocalStoragePersist;
|
||||
|
||||
/// Key slot names for the 12 emotion keybindings.
|
||||
/// Maps to e1, e2, ..., e9, e0, eq, ew
|
||||
|
|
@ -64,54 +63,17 @@ impl Default for EmotionKeybindings {
|
|||
}
|
||||
}
|
||||
|
||||
// Implement LocalStoragePersist trait for automatic load/save
|
||||
impl LocalStoragePersist for EmotionKeybindings {
|
||||
const STORAGE_KEY: &'static str = "chattyness_emotion_keybindings";
|
||||
|
||||
/// Ensure slot 0 is always Happy (enforce lock) after loading.
|
||||
fn post_load(&mut self) {
|
||||
self.slots[0] = EmotionState::Happy;
|
||||
}
|
||||
}
|
||||
|
||||
impl EmotionKeybindings {
|
||||
/// Load keybindings 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(KEYBINDINGS_KEY) else {
|
||||
return Self::default();
|
||||
};
|
||||
|
||||
let mut loaded: Self = serde_json::from_str(&json).unwrap_or_default();
|
||||
// Ensure slot 0 is always Happy (enforce lock)
|
||||
loaded.slots[0] = EmotionState::Happy;
|
||||
loaded
|
||||
}
|
||||
|
||||
/// Stub for SSR - returns default keybindings.
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
pub fn load() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Save keybindings 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(KEYBINDINGS_KEY, &json);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stub for SSR - no-op.
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
pub fn save(&self) {}
|
||||
|
||||
/// Get the emotion for a given key (after 'e' was pressed).
|
||||
///
|
||||
/// Keys: "1"-"9", "0", "q", "w"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ use chattyness_db::models::{EmotionAvailability, EmotionState};
|
|||
|
||||
use super::emotion_picker::EmoteListPopup;
|
||||
use super::keybindings::EmotionKeybindings;
|
||||
use super::modals::Modal;
|
||||
use crate::utils::LocalStoragePersist;
|
||||
|
||||
/// Keybindings popup component for customizing emotion hotkeys.
|
||||
///
|
||||
|
|
@ -29,115 +31,46 @@ pub fn KeybindingsPopup(
|
|||
// Current active tab (for future extensibility)
|
||||
let (active_tab, _set_active_tab) = signal("emotions");
|
||||
|
||||
// Handle escape key to close (only when no picker is open)
|
||||
#[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| {
|
||||
// Only close on Escape if the event target is not the picker input
|
||||
if ev.key() == "Escape" {
|
||||
if let Some(target) = ev.target() {
|
||||
if let Ok(el) = target.dyn_into::<web_sys::HtmlElement>() {
|
||||
// Check if it's the picker filter input - if so, don't close the main popup
|
||||
if el.class_list().contains("emotion-picker-filter") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
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="keybindings-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-2xl w-full mx-4 p-6 border border-gray-700">
|
||||
// Header
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 id="keybindings-modal-title" class="text-xl font-bold text-white">
|
||||
"Keybindings"
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-white transition-colors"
|
||||
on:click=move |_| on_close_button.run(())
|
||||
aria-label="Close keybindings"
|
||||
>
|
||||
<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>
|
||||
|
||||
// Tab bar
|
||||
<div class="flex border-b border-gray-700 mb-4" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
class="px-4 py-2 font-medium transition-colors border-b-2 -mb-px text-blue-400 border-blue-400"
|
||||
>
|
||||
"Emotions"
|
||||
</button>
|
||||
</div>
|
||||
|
||||
// Tab content
|
||||
<Show when=move || active_tab.get() == "emotions">
|
||||
<EmotionsTab
|
||||
keybindings=keybindings
|
||||
emotion_availability=emotion_availability
|
||||
skin_preview_path=skin_preview_path
|
||||
/>
|
||||
</Show>
|
||||
|
||||
// Footer with keyboard hint
|
||||
<div class="mt-6 pt-4 border-t border-gray-700">
|
||||
<p class="text-gray-400 text-sm">
|
||||
"Press "
|
||||
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-xs font-mono">"e"</kbd>
|
||||
" + key to trigger emotion (e.g., "
|
||||
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-xs font-mono">"e1"</kbd>
|
||||
" for happy)"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
open=open
|
||||
on_close=on_close
|
||||
title="Keybindings"
|
||||
title_id="keybindings-modal-title"
|
||||
max_width="max-w-2xl"
|
||||
>
|
||||
// Tab bar
|
||||
<div class="flex border-b border-gray-700 mb-4" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
class="px-4 py-2 font-medium transition-colors border-b-2 -mb-px text-blue-400 border-blue-400"
|
||||
>
|
||||
"Emotions"
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
// Tab content
|
||||
<Show when=move || active_tab.get() == "emotions">
|
||||
<EmotionsTab
|
||||
keybindings=keybindings
|
||||
emotion_availability=emotion_availability
|
||||
skin_preview_path=skin_preview_path
|
||||
/>
|
||||
</Show>
|
||||
|
||||
// Footer with keyboard hint
|
||||
<div class="mt-6 pt-4 border-t border-gray-700">
|
||||
<p class="text-gray-400 text-sm">
|
||||
"Press "
|
||||
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-xs font-mono">"e"</kbd>
|
||||
" + key to trigger emotion (e.g., "
|
||||
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-xs font-mono">"e1"</kbd>
|
||||
" for happy)"
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,122 @@
|
|||
|
||||
use leptos::prelude::*;
|
||||
|
||||
use crate::utils::use_escape_key;
|
||||
|
||||
// ============================================================================
|
||||
// Base Modal Component
|
||||
// ============================================================================
|
||||
|
||||
/// Base modal component that handles common modal patterns.
|
||||
///
|
||||
/// Provides:
|
||||
/// - Backdrop with blur effect
|
||||
/// - Escape key to close
|
||||
/// - Click outside to close
|
||||
/// - Accessible ARIA attributes
|
||||
/// - Close button in header
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// <Modal
|
||||
/// open=open_signal
|
||||
/// on_close=on_close_callback
|
||||
/// title="My Modal"
|
||||
/// title_id="my-modal-title"
|
||||
/// >
|
||||
/// <p>"Modal content here"</p>
|
||||
/// </Modal>
|
||||
/// ```
|
||||
#[component]
|
||||
pub fn Modal(
|
||||
#[prop(into)] open: Signal<bool>,
|
||||
on_close: Callback<()>,
|
||||
title: &'static str,
|
||||
/// Unique ID for the title element (for aria-labelledby).
|
||||
title_id: &'static str,
|
||||
/// Maximum width class (e.g., "max-w-md", "max-w-2xl"). Defaults to "max-w-md".
|
||||
#[prop(default = "max-w-md")]
|
||||
max_width: &'static str,
|
||||
/// Whether to show the close button. Defaults to true.
|
||||
#[prop(default = true)]
|
||||
show_close_button: bool,
|
||||
/// Optional extra classes for the modal container.
|
||||
#[prop(optional)]
|
||||
class: Option<&'static str>,
|
||||
children: Children,
|
||||
) -> impl IntoView {
|
||||
// Handle escape key
|
||||
use_escape_key(open, on_close.clone());
|
||||
|
||||
let on_close_backdrop = on_close.clone();
|
||||
let on_close_button = on_close.clone();
|
||||
|
||||
let container_class = format!(
|
||||
"relative bg-gray-800 rounded-lg shadow-2xl {} w-full mx-4 p-6 border border-gray-700 {}",
|
||||
max_width,
|
||||
class.unwrap_or("")
|
||||
);
|
||||
|
||||
// Render children once
|
||||
let content = children();
|
||||
|
||||
// Use CSS-based visibility instead of Show for better children handling
|
||||
let outer_class = move || {
|
||||
if open.get() {
|
||||
"fixed inset-0 z-50 flex items-center justify-center"
|
||||
} else {
|
||||
"hidden"
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<div
|
||||
class=outer_class
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby=title_id
|
||||
aria-hidden=move || (!open.get()).to_string()
|
||||
>
|
||||
// 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=container_class>
|
||||
// Header with title and close button
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 id=title_id class="text-xl font-bold text-white">
|
||||
{title}
|
||||
</h2>
|
||||
{show_close_button.then(|| view! {
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-white transition-colors"
|
||||
on:click=move |_| on_close_button.run(())
|
||||
aria-label="Close"
|
||||
>
|
||||
<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>
|
||||
|
||||
// Content
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Specialized Modals
|
||||
// ============================================================================
|
||||
|
||||
/// Confirmation modal for joining a realm.
|
||||
#[component]
|
||||
pub fn JoinRealmModal(
|
||||
|
|
|
|||
|
|
@ -20,44 +20,7 @@ use super::chat_types::ActiveBubble;
|
|||
use super::settings::{
|
||||
calculate_min_zoom, ViewerSettings, BASE_PROP_SIZE, REFERENCE_HEIGHT, REFERENCE_WIDTH,
|
||||
};
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
use crate::utils::parse_bounds_dimensions;
|
||||
|
||||
/// Scene viewer component for displaying a realm scene with avatars.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// LocalStorage key for viewer settings.
|
||||
const SETTINGS_KEY: &str = "chattyness_viewer_settings";
|
||||
use crate::utils::LocalStoragePersist;
|
||||
|
||||
/// Reference resolution for enlarged props calculation.
|
||||
pub const REFERENCE_WIDTH: f64 = 1920.0;
|
||||
|
|
@ -99,51 +98,12 @@ impl Default for ViewerSettings {
|
|||
}
|
||||
}
|
||||
|
||||
// Implement LocalStoragePersist trait for automatic load/save
|
||||
impl LocalStoragePersist for ViewerSettings {
|
||||
const STORAGE_KEY: &'static str = "chattyness_viewer_settings";
|
||||
}
|
||||
|
||||
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 without enlarge, returns base size * zoom level.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
use leptos::ev::MouseEvent;
|
||||
use leptos::prelude::*;
|
||||
|
||||
use super::modals::Modal;
|
||||
use super::settings::{calculate_min_zoom, ViewerSettings, ZOOM_MAX, ZOOM_STEP, TEXT_EM_MIN, TEXT_EM_MAX, TEXT_EM_STEP};
|
||||
use crate::utils::LocalStoragePersist;
|
||||
|
||||
/// Settings popup component for scene viewer configuration.
|
||||
///
|
||||
|
|
@ -109,189 +111,128 @@ pub fn SettingsPopup(
|
|||
});
|
||||
};
|
||||
|
||||
// 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
|
||||
open=open
|
||||
on_close=on_close
|
||||
title="Scene Settings"
|
||||
title_id="settings-modal-title"
|
||||
>
|
||||
// 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
|
||||
/>
|
||||
|
||||
// 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="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 (always visible)
|
||||
<SettingsToggle
|
||||
label="Enlarge Props"
|
||||
description="Scale props relative to 1920x1080 for consistent size"
|
||||
checked=enlarge
|
||||
on_change=on_enlarge_toggle
|
||||
/>
|
||||
|
||||
// Text size controls
|
||||
<div class="space-y-2">
|
||||
<label class="block text-white font-medium">
|
||||
"Text Size: " {move || format!("{}%", (text_em.get() * 100.0) as i32)}
|
||||
</label>
|
||||
<p class="text-gray-400 text-sm mb-2">
|
||||
"Scale display names, chat bubbles, and emotion badges"
|
||||
</p>
|
||||
<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_text_em_decrease
|
||||
disabled={move || text_em.get() <= TEXT_EM_MIN}
|
||||
aria-label="Decrease text size"
|
||||
>
|
||||
"-"
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min=TEXT_EM_MIN.to_string()
|
||||
max=TEXT_EM_MAX.to_string()
|
||||
step=TEXT_EM_STEP.to_string()
|
||||
class="flex-1 h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
||||
prop:value=move || text_em.get().to_string()
|
||||
on:input=on_text_em_input
|
||||
aria-label="Text size"
|
||||
/>
|
||||
<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_text_em_increase
|
||||
disabled={move || text_em.get() >= TEXT_EM_MAX}
|
||||
aria-label="Increase text size"
|
||||
>
|
||||
"+"
|
||||
</button>
|
||||
</div>
|
||||
// Zoom controls (only when panning enabled)
|
||||
<Show when=move || panning.get()>
|
||||
<div class="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>
|
||||
|
||||
// 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>
|
||||
// Enlarge props toggle (always visible)
|
||||
<SettingsToggle
|
||||
label="Enlarge Props"
|
||||
description="Scale props relative to 1920x1080 for consistent size"
|
||||
checked=enlarge
|
||||
on_change=on_enlarge_toggle
|
||||
/>
|
||||
|
||||
// Text size controls
|
||||
<div class="space-y-2">
|
||||
<label class="block text-white font-medium">
|
||||
"Text Size: " {move || format!("{}%", (text_em.get() * 100.0) as i32)}
|
||||
</label>
|
||||
<p class="text-gray-400 text-sm mb-2">
|
||||
"Scale display names, chat bubbles, and emotion badges"
|
||||
</p>
|
||||
<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_text_em_decrease
|
||||
disabled={move || text_em.get() <= TEXT_EM_MIN}
|
||||
aria-label="Decrease text size"
|
||||
>
|
||||
"-"
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min=TEXT_EM_MIN.to_string()
|
||||
max=TEXT_EM_MAX.to_string()
|
||||
step=TEXT_EM_STEP.to_string()
|
||||
class="flex-1 h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
||||
prop:value=move || text_em.get().to_string()
|
||||
on:input=on_text_em_input
|
||||
aria-label="Text size"
|
||||
/>
|
||||
<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_text_em_increase
|
||||
disabled={move || text_em.get() >= TEXT_EM_MAX}
|
||||
aria-label="Increase text size"
|
||||
>
|
||||
"+"
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
// 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>
|
||||
</Modal>
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
93
crates/chattyness-user-ui/src/components/tabs.rs
Normal file
93
crates/chattyness-user-ui/src/components/tabs.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
//! Tab components for tabbed interfaces.
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// A single tab definition.
|
||||
#[derive(Clone)]
|
||||
pub struct Tab {
|
||||
/// Unique identifier for this tab.
|
||||
pub id: &'static str,
|
||||
/// Display label for the tab button.
|
||||
pub label: &'static str,
|
||||
}
|
||||
|
||||
impl Tab {
|
||||
/// Create a new tab.
|
||||
pub const fn new(id: &'static str, label: &'static str) -> Self {
|
||||
Self { id, label }
|
||||
}
|
||||
}
|
||||
|
||||
/// Tab bar component for switching between tabs.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// let (active_tab, set_active_tab) = signal("tab1");
|
||||
///
|
||||
/// view! {
|
||||
/// <TabBar
|
||||
/// tabs=vec![
|
||||
/// Tab::new("tab1", "First Tab"),
|
||||
/// Tab::new("tab2", "Second Tab"),
|
||||
/// ]
|
||||
/// active=active_tab
|
||||
/// on_select=Callback::new(move |id| set_active_tab.set(id))
|
||||
/// />
|
||||
///
|
||||
/// <Show when=move || active_tab.get() == "tab1">
|
||||
/// <p>"First tab content"</p>
|
||||
/// </Show>
|
||||
/// <Show when=move || active_tab.get() == "tab2">
|
||||
/// <p>"Second tab content"</p>
|
||||
/// </Show>
|
||||
/// }
|
||||
/// ```
|
||||
#[component]
|
||||
pub fn TabBar(
|
||||
/// List of tabs to display.
|
||||
tabs: Vec<Tab>,
|
||||
/// Currently active tab ID.
|
||||
#[prop(into)]
|
||||
active: Signal<&'static str>,
|
||||
/// Callback when a tab is selected.
|
||||
on_select: Callback<&'static str>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class="flex border-b border-gray-700 mb-4" role="tablist">
|
||||
<For
|
||||
each=move || tabs.clone()
|
||||
key=|tab| tab.id
|
||||
children=move |tab: Tab| {
|
||||
let tab_id = tab.id;
|
||||
let on_select = on_select.clone();
|
||||
let is_active = move || active.get() == tab_id;
|
||||
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected=is_active
|
||||
class=move || tab_button_class(is_active())
|
||||
on:click=move |_| on_select.run(tab_id)
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate the CSS class for a tab button based on active state.
|
||||
fn tab_button_class(is_active: bool) -> String {
|
||||
format!(
|
||||
"px-4 py-2 font-medium transition-colors border-b-2 -mb-px {}",
|
||||
if is_active {
|
||||
"text-blue-400 border-blue-400"
|
||||
} else {
|
||||
"text-gray-400 border-transparent hover:text-gray-300"
|
||||
}
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue