use std::sync::{Arc, OnceLock};
use js_sys::wasm_bindgen;
use parking_lot::Mutex;
use re_viewer_context::{CommandSender, open_url};
use wasm_bindgen::closure::Closure;
use wasm_bindgen::prelude::wasm_bindgen;
use wasm_bindgen::{JsCast as _, JsError, JsValue};
use web_sys::{History, UrlSearchParams};
use crate::web_tools::{JsResultExt as _, window};
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct HistoryEntry {
url: String,
}
impl HistoryEntry {
const KEY: &'static str = "__rerun";
pub fn new(url: String) -> Self {
if url == re_redap_browser::EXAMPLES_ORIGIN.as_url()
|| url
== re_uri::RedapUri::Catalog(re_uri::CatalogUri::new(
re_redap_browser::EXAMPLES_ORIGIN.clone(),
))
.to_string()
{
Self::default()
} else {
Self { url }
}
}
pub fn to_query_string(&self) -> Result<String, JsValue> {
use std::fmt::Write as _;
let params = UrlSearchParams::new()?;
params.append("url", &self.url);
let mut out = "?".to_owned();
write!(&mut out, "{}", params.to_string()).ok();
Ok(out)
}
}
fn stored_history_entry() -> &'static Arc<Mutex<Option<HistoryEntry>>> {
static STORED_HISTORY_ENTRY: OnceLock<Arc<Mutex<Option<HistoryEntry>>>> = OnceLock::new();
STORED_HISTORY_ENTRY.get_or_init(|| Arc::new(Mutex::new(None)))
}
fn get_stored_history_entry() -> Option<HistoryEntry> {
stored_history_entry().lock().clone()
}
fn set_stored_history_entry(entry: Option<HistoryEntry>) {
*stored_history_entry().lock() = entry;
}
type EventListener<Event> = dyn FnMut(Event) -> Result<(), JsValue>;
pub fn install_popstate_listener(app: &mut crate::App) -> Result<(), JsValue> {
let egui_ctx = app.egui_ctx.clone();
let command_sender = app.command_sender.clone();
let closure = Closure::wrap(Box::new({
move |event: web_sys::PopStateEvent| {
let new_state = deserialize_from_state(&event.state())?;
handle_popstate(&egui_ctx, &command_sender, new_state);
Ok(())
}
}) as Box<EventListener<_>>);
set_stored_history_entry(history()?.current_entry()?);
window()?
.add_event_listener_with_callback("popstate", closure.as_ref().unchecked_ref())
.ok_or_log_js_error();
app.popstate_listener = Some(PopstateListener::new(closure));
Ok(())
}
pub struct PopstateListener(Option<Closure<EventListener<web_sys::PopStateEvent>>>);
impl PopstateListener {
fn new(closure: Closure<EventListener<web_sys::PopStateEvent>>) -> Self {
Self(Some(closure))
}
}
impl Drop for PopstateListener {
fn drop(&mut self) {
let Some(window) = window().ok_or_log_js_error() else {
return;
};
let Some(closure) = self.0.take() else {
unreachable!();
};
window
.remove_event_listener_with_callback("popstate", closure.as_ref().unchecked_ref())
.ok_or_log_js_error();
drop(closure);
}
}
fn handle_popstate(
egui_ctx: &egui::Context,
command_sender: &CommandSender,
new_state: Option<HistoryEntry>,
) {
let prev_state = get_stored_history_entry();
re_log::debug!("popstate: prev={prev_state:?} new={new_state:?}");
if prev_state == new_state {
re_log::debug!("popstate: no change");
return;
}
if new_state.is_none() || new_state.as_ref().is_some_and(|v| v.url.is_empty()) {
re_log::debug!("popstate: go to welcome screen");
re_redap_browser::switch_to_welcome_screen(command_sender);
egui_ctx.request_repaint();
set_stored_history_entry(new_state);
return;
}
let Some(entry) = new_state else {
unreachable!();
};
match entry.url.parse::<open_url::ViewerOpenUrl>() {
Ok(url) => {
url.open(
egui_ctx,
&open_url::OpenUrlOptions {
follow_if_http: false,
select_redap_source_when_loaded: true,
show_loader: true,
},
command_sender,
);
}
Err(err) => {
re_log::warn!("Failed to open URL {:?}: {err}", entry.url);
}
}
re_log::debug!("popstate: add receiver {}", entry.url);
set_stored_history_entry(Some(entry));
}
pub fn go_back() -> Option<()> {
let history = history().ok_or_log_js_error()?;
history.back().ok_or_log_js_error()
}
pub fn go_forward() -> Option<()> {
let history = history().ok_or_log_js_error()?;
history.forward().ok_or_log_js_error()
}
pub fn history() -> Result<History, JsValue> {
window()?.history()
}
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(catch, js_namespace = ["window"], js_name = structuredClone)]
pub fn structured_clone(value: &JsValue) -> Result<JsValue, JsValue>;
}
fn get_raw_state(history: &History) -> Result<JsValue, JsValue> {
let state = history.state().unwrap_or(JsValue::UNDEFINED);
if state.is_undefined() || state.is_null() {
return Ok(js_sys::Object::new().into());
}
if !state.is_object() {
return Err(JsError::new("history state is not an object").into());
}
structured_clone(&state)
}
fn deserialize_from_state(state: &JsValue) -> Result<Option<HistoryEntry>, JsValue> {
if state.is_undefined() || state.is_null() {
return Ok(None);
}
let key = JsValue::from_str(HistoryEntry::KEY);
let value = js_sys::Reflect::get(state, &key)?;
if value.is_undefined() || value.is_null() {
return Ok(None);
}
let entry = serde_wasm_bindgen::from_value(value)?;
Ok(Some(entry))
}
fn get_updated_state(history: &History, entry: &HistoryEntry) -> Result<JsValue, JsValue> {
let state = get_raw_state(history)?;
let key = JsValue::from_str(HistoryEntry::KEY);
let entry = serde_wasm_bindgen::to_value(entry)?;
js_sys::Reflect::set(&state, &key, &entry)?;
Ok(state)
}
pub trait HistoryExt: private::Sealed {
fn push_entry(&self, entry: HistoryEntry) -> Result<(), JsValue>;
fn replace_entry(&self, entry: HistoryEntry) -> Result<(), JsValue>;
fn current_entry(&self) -> Result<Option<HistoryEntry>, JsValue>;
}
impl private::Sealed for History {}
impl HistoryExt for History {
fn push_entry(&self, entry: HistoryEntry) -> Result<(), JsValue> {
if self.current_entry()?.unwrap_or_default() == entry {
return Ok(());
}
let state = get_updated_state(self, &entry)?;
let url = entry.to_query_string()?;
self.push_state_with_url(&state, "", Some(&url))?;
set_stored_history_entry(Some(entry));
Ok(())
}
fn replace_entry(&self, entry: HistoryEntry) -> Result<(), JsValue> {
let state = get_updated_state(self, &entry)?;
let url = entry.to_query_string()?;
self.replace_state_with_url(&state, "", Some(&url))?;
set_stored_history_entry(Some(entry));
Ok(())
}
fn current_entry(&self) -> Result<Option<HistoryEntry>, JsValue> {
let state = get_raw_state(self)?;
deserialize_from_state(&state)
}
}
mod private {
pub trait Sealed {}
}