Skip to main content

yggdrasil/pages/admin/
note_drafts.rs

1//! 同一标签页的恢复副本与离开保护;不替代服务端保存。
2use dioxus::prelude::*;
3use serde::{de::DeserializeOwned, Serialize};
4
5pub(super) fn use_draft_protection<
6    T: Clone + PartialEq + Serialize + DeserializeOwned + 'static,
7>(
8    slot: String,
9    current: Signal<T>,
10    dirty: impl Fn() -> bool + Copy + 'static,
11) -> (Signal<Option<T>>, Signal<bool>) {
12    #[cfg(target_arch = "wasm32")]
13    {
14        use std::rc::Rc;
15        use wasm_bindgen::{closure::Closure, JsCast};
16        let user: crate::context::UserContext = use_context();
17        let key = use_hook(move || {
18            format!(
19                "ygg.note-draft.{}.{}",
20                user.user.peek().as_ref().map(|u| u.id).unwrap_or_default(),
21                slot
22            )
23        });
24        let read_key = key.clone();
25        let recovery = use_signal(move || {
26            let storage = web_sys::window()?.session_storage().ok()??;
27            let draft: T = serde_json::from_str(&storage.get_item(&read_key).ok()??).ok()?;
28            (draft != current()).then_some(draft)
29        });
30        let failed = use_signal(|| false);
31        let flush = use_hook(move || {
32            Rc::new(move || {
33                let mut failed = failed;
34                let unsaved = dirty();
35                let snapshot = current();
36                if recovery().is_some() {
37                    return;
38                }
39                let result = (|| {
40                    let storage = web_sys::window()?.session_storage().ok()??;
41                    if unsaved {
42                        storage
43                            .set_item(&key, &serde_json::to_string(&snapshot).ok()?)
44                            .ok()?;
45                    } else {
46                        storage.remove_item(&key).ok()?;
47                    }
48                    Some(())
49                })();
50                let error = result.is_none();
51                if *failed.peek() != error {
52                    failed.set(error);
53                }
54            })
55        });
56        let cache = flush.clone();
57        use_effect(move || cache());
58        let listeners = use_hook(move || {
59            let cache = flush.clone();
60            let unload = Rc::new(Closure::<dyn FnMut(web_sys::Event)>::new(
61                move |event: web_sys::Event| {
62                    cache();
63                    if dirty() {
64                        event.prevent_default();
65                        let _ =
66                            js_sys::Reflect::set(event.as_ref(), &"returnValue".into(), &"".into());
67                    }
68                },
69            ));
70            let leave = Rc::new(Closure::<dyn FnMut() -> bool>::new(move || {
71                // Read live signals and flush synchronously: a click can precede the cache effect.
72                flush();
73                !dirty()
74                    || web_sys::window().is_some_and(|w| {
75                        w.confirm_with_message(
76                    "还有未保存内容,或保存 / 上传尚未完成。确定离开?本地恢复副本不等于保存成功。"
77                ).unwrap_or(false)
78                    })
79            }));
80            if let Some(window) = web_sys::window() {
81                let _ = window.add_event_listener_with_callback(
82                    "beforeunload",
83                    unload.as_ref().as_ref().unchecked_ref(),
84                );
85            }
86            let module = crate::bridges::navigation::wasm::get_module().map(Rc::new);
87            let id = module.as_ref().map(|m| m.set_leave_guard(&leave));
88            (unload, leave, module, id)
89        });
90        use_drop(move || {
91            let (unload, _leave, module, id) = listeners;
92            if let (Some(module), Some(id)) = (module, id) {
93                module.clear_leave_guard(id);
94            }
95            if let Some(window) = web_sys::window() {
96                let _ = window.remove_event_listener_with_callback(
97                    "beforeunload",
98                    unload.as_ref().as_ref().unchecked_ref(),
99                );
100            }
101        });
102        (recovery, failed)
103    }
104    #[cfg(not(target_arch = "wasm32"))]
105    {
106        let _ = (slot, current, dirty);
107        (use_signal(|| None), use_signal(|| false))
108    }
109}
110
111/// A completed request must never clear text entered after its snapshot was submitted.
112pub(super) fn clear_submitted(current: &mut String, submitted: &str) -> bool {
113    if current != submitted {
114        return false;
115    }
116    current.clear();
117    true
118}
119
120#[cfg(test)]
121mod tests {
122    use super::clear_submitted;
123
124    #[test]
125    fn quick_save_clears_only_the_submitted_snapshot() {
126        let mut current = "first".to_string();
127        assert!(clear_submitted(&mut current, "first"));
128        assert!(current.is_empty());
129        current = "first plus a new thought".to_string();
130        assert!(!clear_submitted(&mut current, "first"));
131        assert_eq!(current, "first plus a new thought");
132        current.clear();
133        assert!(!clear_submitted(&mut current, "first"));
134    }
135}