278 lines
11 KiB
Rust
278 lines
11 KiB
Rust
//! 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="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
|
|
/>
|
|
</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>
|
|
}
|
|
}
|