use std::cell::RefCell;
use std::rc::Rc;
use js_sys::{Function, Reflect};
use wasm_bindgen::closure::Closure;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{AddEventListenerOptions, Element, Event, EventTarget, HtmlElement, ResizeObserver};
use crate::reactive::ScopeId;
use crate::refs;
const STATE_KEY: &str = "__pp_anchor_state";
#[derive(Copy, Clone, PartialEq, Eq)]
pub enum Side {
Top,
Bottom,
Left,
Right,
}
impl Side {
pub fn parse(s: &str) -> Self {
match s.to_ascii_lowercase().as_str() {
"top" => Side::Top,
"left" => Side::Left,
"right" => Side::Right,
_ => Side::Bottom,
}
}
}
#[derive(Copy, Clone, PartialEq, Eq)]
pub enum Align {
Start,
Center,
End,
}
impl Align {
pub fn parse(s: &str) -> Self {
match s.to_ascii_lowercase().as_str() {
"start" => Align::Start,
"end" => Align::End,
_ => Align::Center,
}
}
}
#[derive(Copy, Clone)]
pub struct Placement {
pub side: Side,
pub align: Align,
}
impl Placement {
fn opposite(self) -> Self {
Self {
side: match self.side {
Side::Top => Side::Bottom,
Side::Bottom => Side::Top,
Side::Left => Side::Right,
Side::Right => Side::Left,
},
align: self.align,
}
}
}
pub fn install_opaque(
el: &Element,
arg: Option<&str>,
modifiers: &[&str],
value: &str,
scope_id: ScopeId,
proxy: &JsValue,
) {
let Some(anchor) = resolve_anchor(el, value, scope_id, proxy) else {
return;
};
let floater: HtmlElement = match el.clone().dyn_into() {
Ok(h) => h,
Err(_) => return,
};
let placement = parse_placement(arg);
let offset = parse_offset(modifiers);
let flip = modifiers.contains(&"flip");
install(&floater, &anchor, placement, offset, flip);
}
pub fn install(
floater: &HtmlElement,
anchor: &Element,
placement: Placement,
offset: f64,
flip: bool,
) {
release(floater.as_ref());
let floater = floater.clone();
let anchor = anchor.clone();
let inner = Rc::new(RefCell::new(AnchorInner {
anchor,
floater: floater.clone(),
placement,
offset,
flip,
}));
let reposition_closure: Closure<dyn FnMut()> = {
let inner = inner.clone();
Closure::wrap(Box::new(move || {
let guard = inner.borrow();
reposition(&guard);
}) as Box<dyn FnMut()>)
};
let reposition_fn: Function = reposition_closure
.as_ref()
.unchecked_ref::<Function>()
.clone();
crate::tick::next({
let f = reposition_fn.clone();
move || {
let _ = f.call0(&JsValue::UNDEFINED);
}
});
let obs_callback: Closure<dyn FnMut(JsValue, JsValue)> = {
let f = reposition_fn.clone();
Closure::wrap(Box::new(move |_entries: JsValue, _obs: JsValue| {
let _ = f.call0(&JsValue::UNDEFINED);
}) as Box<dyn FnMut(JsValue, JsValue)>)
};
let obs_cb_fn = obs_callback.as_ref().unchecked_ref::<Function>().clone();
let anchor_observer = match ResizeObserver::new(&obs_cb_fn) {
Ok(o) => {
o.observe(&inner.borrow().anchor);
Some(o)
}
Err(_) => None,
};
let floater_observer = match ResizeObserver::new(&obs_cb_fn) {
Ok(o) => {
o.observe(inner.borrow().floater.as_ref());
Some(o)
}
Err(_) => None,
};
let Some(window) = web_sys::window() else {
return;
};
let window_target: EventTarget = window.into();
let event_closure: Closure<dyn FnMut(Event)> = {
let f = reposition_fn.clone();
Closure::wrap(Box::new(move |_: Event| {
let _ = f.call0(&JsValue::UNDEFINED);
}) as Box<dyn FnMut(Event)>)
};
let scroll_opts = AddEventListenerOptions::new();
scroll_opts.set_capture(true);
scroll_opts.set_passive(true);
let _ = window_target.add_event_listener_with_callback_and_add_event_listener_options(
"scroll",
event_closure.as_ref().unchecked_ref(),
&scroll_opts,
);
let _ = window_target
.add_event_listener_with_callback("resize", event_closure.as_ref().unchecked_ref());
let state = AnchorState {
inner,
window_target,
anchor_observer,
floater_observer,
reposition_closure,
obs_callback,
event_closure,
};
let boxed: Box<AnchorState> = Box::new(state);
let ptr = Box::into_raw(boxed) as usize as f64;
let _ = Reflect::set(floater.as_ref(), &STATE_KEY.into(), &JsValue::from_f64(ptr));
}
pub fn release(el: &Element) {
let v = match Reflect::get(el.as_ref(), &STATE_KEY.into()) {
Ok(v) => v,
Err(_) => return,
};
if v.is_undefined() || v.is_null() {
return;
}
let Some(ptr_f) = v.as_f64() else { return };
let ptr = ptr_f as usize as *mut AnchorState;
if ptr.is_null() {
return;
}
let state = unsafe { Box::from_raw(ptr) };
let target = state.window_target.clone();
let _ = target.remove_event_listener_with_callback_and_bool(
"scroll",
state.event_closure.as_ref().unchecked_ref(),
true,
);
let _ = target.remove_event_listener_with_callback(
"resize",
state.event_closure.as_ref().unchecked_ref(),
);
if let Some(obs) = &state.anchor_observer {
obs.disconnect();
}
if let Some(obs) = &state.floater_observer {
obs.disconnect();
}
let _ = Reflect::set(el.as_ref(), &STATE_KEY.into(), &JsValue::UNDEFINED);
drop(state);
}
struct AnchorInner {
anchor: Element,
floater: HtmlElement,
placement: Placement,
offset: f64,
flip: bool,
}
#[allow(dead_code)]
struct AnchorState {
inner: Rc<RefCell<AnchorInner>>,
window_target: EventTarget,
anchor_observer: Option<ResizeObserver>,
floater_observer: Option<ResizeObserver>,
reposition_closure: Closure<dyn FnMut()>,
obs_callback: Closure<dyn FnMut(JsValue, JsValue)>,
event_closure: Closure<dyn FnMut(Event)>,
}
fn reposition(inner: &AnchorInner) {
let Some(window) = web_sys::window() else {
return;
};
let vw = window
.inner_width()
.ok()
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let vh = window
.inner_height()
.ok()
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let a = inner.anchor.get_bounding_client_rect();
let f = inner.floater.get_bounding_client_rect();
let mut side = inner.placement.side;
if inner.flip {
side = maybe_flip(side, &a, &f, vw, vh, inner.offset);
}
let eff = Placement {
side,
align: inner.placement.align,
};
let (x, y) = compute_xy(eff, &a, &f, inner.offset);
let style = inner.floater.style();
let _ = style.set_property("position", "fixed");
let _ = style.set_property("top", &format!("{}px", y.round()));
let _ = style.set_property("left", &format!("{}px", x.round()));
let _ = style.set_property("right", "auto");
let _ = style.set_property("bottom", "auto");
}
fn maybe_flip(
side: Side,
a: &web_sys::DomRect,
f: &web_sys::DomRect,
vw: f64,
vh: f64,
offset: f64,
) -> Side {
let needed_main = match side {
Side::Top | Side::Bottom => f.height() + offset,
Side::Left | Side::Right => f.width() + offset,
};
let (room, opp_room) = match side {
Side::Top => (a.top(), vh - a.bottom()),
Side::Bottom => (vh - a.bottom(), a.top()),
Side::Left => (a.left(), vw - a.right()),
Side::Right => (vw - a.right(), a.left()),
};
if room < needed_main && opp_room > room {
Placement {
side,
align: Align::Center,
}
.opposite()
.side
} else {
side
}
}
fn compute_xy(p: Placement, a: &web_sys::DomRect, f: &web_sys::DomRect, offset: f64) -> (f64, f64) {
match p.side {
Side::Top => {
let y = a.top() - f.height() - offset;
let x = cross_axis_x(p.align, a, f);
(x, y)
}
Side::Bottom => {
let y = a.bottom() + offset;
let x = cross_axis_x(p.align, a, f);
(x, y)
}
Side::Left => {
let x = a.left() - f.width() - offset;
let y = cross_axis_y(p.align, a, f);
(x, y)
}
Side::Right => {
let x = a.right() + offset;
let y = cross_axis_y(p.align, a, f);
(x, y)
}
}
}
fn cross_axis_x(align: Align, a: &web_sys::DomRect, f: &web_sys::DomRect) -> f64 {
match align {
Align::Start => a.left(),
Align::Center => a.left() + (a.width() - f.width()) / 2.0,
Align::End => a.right() - f.width(),
}
}
fn cross_axis_y(align: Align, a: &web_sys::DomRect, f: &web_sys::DomRect) -> f64 {
match align {
Align::Start => a.top(),
Align::Center => a.top() + (a.height() - f.height()) / 2.0,
Align::End => a.bottom() - f.height(),
}
}
fn parse_placement(raw: Option<&str>) -> Placement {
let raw = raw.unwrap_or("bottom");
let (side_s, align_s) = match raw.split_once('-') {
Some((s, a)) => (s, Some(a)),
None => (raw, None),
};
let side = match side_s {
"top" => Side::Top,
"bottom" => Side::Bottom,
"left" => Side::Left,
"right" => Side::Right,
_ => Side::Bottom,
};
let align = match align_s {
Some("start") => Align::Start,
Some("end") => Align::End,
_ => Align::Center,
};
Placement { side, align }
}
fn parse_offset(modifiers: &[impl AsRef<str>]) -> f64 {
for (i, m) in modifiers.iter().enumerate() {
if m.as_ref() == "offset" {
if let Some(n) = modifiers
.get(i + 1)
.and_then(|s| s.as_ref().parse::<f64>().ok())
{
return n;
}
}
}
0.0
}
fn resolve_anchor(
_el: &Element,
value: &str,
scope_id: ScopeId,
proxy: &JsValue,
) -> Option<Element> {
let raw = value.trim();
if raw.is_empty() {
return None;
}
if is_identifier(raw) {
if let Some(el) = refs::get_on(scope_id, raw) {
return Some(el);
}
}
let doc = web_sys::window()?.document()?;
if let Some(el) = doc.query_selector(raw).ok().flatten() {
return Some(el);
}
if is_identifier(raw) {
let v = Reflect::get(proxy, &JsValue::from_str(raw)).unwrap_or(JsValue::UNDEFINED);
if let Some(s) = v.as_string() {
let s = s.trim();
if !s.is_empty() {
if is_identifier(s) {
if let Some(el) = refs::get_on(scope_id, s) {
return Some(el);
}
}
return doc.query_selector(s).ok().flatten();
}
}
}
None
}
fn is_identifier(s: &str) -> bool {
let mut it = s.chars();
match it.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
it.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
#[cfg(test)]
mod tests {
use super::*;
fn mods(xs: &[&str]) -> Vec<String> {
xs.iter().map(|s| s.to_string()).collect()
}
#[test]
fn parse_placement_default_is_bottom_center() {
let p = parse_placement(None);
assert!(matches!(p.side, Side::Bottom));
assert!(matches!(p.align, Align::Center));
}
#[test]
fn parse_placement_side_only() {
let p = parse_placement(Some("top"));
assert!(matches!(p.side, Side::Top));
assert!(matches!(p.align, Align::Center));
}
#[test]
fn parse_placement_side_plus_align() {
let p = parse_placement(Some("bottom-end"));
assert!(matches!(p.side, Side::Bottom));
assert!(matches!(p.align, Align::End));
}
#[test]
fn parse_placement_invalid_side_falls_back_to_bottom() {
let p = parse_placement(Some("upwards"));
assert!(matches!(p.side, Side::Bottom));
}
#[test]
fn parse_placement_invalid_align_becomes_center() {
let p = parse_placement(Some("top-sideways"));
assert!(matches!(p.side, Side::Top));
assert!(matches!(p.align, Align::Center));
}
#[test]
fn parse_offset_missing_is_zero() {
assert_eq!(parse_offset(&mods(&[])), 0.0);
}
#[test]
fn parse_offset_positive_and_negative() {
assert_eq!(parse_offset(&mods(&["offset", "12"])), 12.0);
assert_eq!(parse_offset(&mods(&["offset", "-4"])), -4.0);
}
#[test]
fn parse_offset_ignores_junk_value() {
assert_eq!(parse_offset(&mods(&["offset", "nope"])), 0.0);
}
#[test]
fn is_identifier_accepts_ref_names() {
assert!(is_identifier("trigger"));
assert!(is_identifier("my-button"));
assert!(is_identifier("_hidden"));
}
#[test]
fn is_identifier_rejects_selectors() {
assert!(!is_identifier("#trigger"));
assert!(!is_identifier(".btn"));
assert!(!is_identifier("[data-anchor]"));
assert!(!is_identifier("1bad"));
assert!(!is_identifier(""));
}
#[test]
fn opposite_flips_main_axis_only() {
let p = Placement {
side: Side::Top,
align: Align::End,
};
let o = p.opposite();
assert!(matches!(o.side, Side::Bottom));
assert!(matches!(o.align, Align::End));
}
}