Skip to main content

yggdrasil/pages/admin/
note_drafts.rs

1//! 同一标签页的恢复副本与离开保护;不替代服务端保存。
2use leptos::prelude::*;
3use serde::{de::DeserializeOwned, Serialize};
4
5/// 返回(待恢复的本地副本,副本写入失败标记)。
6///
7/// 恢复副本在水合之后的首个 Effect 里读取:服务端与水合首帧一律没有副本,
8/// 两端 DOM 一致;`beforeunload` 与导航协调器的离开保护只在浏览器注册。
9pub(super) fn use_draft_protection<
10    T: Clone + PartialEq + Serialize + DeserializeOwned + Send + Sync + 'static,
11>(
12    slot: String,
13    current: RwSignal<T>,
14    dirty: impl Fn() -> bool + Copy + Send + Sync + 'static,
15) -> (RwSignal<Option<T>>, RwSignal<bool>) {
16    #[cfg(target_arch = "wasm32")]
17    {
18        use crate::context::UserContext;
19        use std::rc::Rc;
20        use wasm_bindgen::closure::Closure;
21
22        fn session_storage() -> Option<web_sys::Storage> {
23            web_sys::window()?.session_storage().ok()?
24        }
25
26        let user = expect_context::<UserContext>();
27        let key = StoredValue::new(format!(
28            "ygg.note-draft.{}.{}",
29            user.user
30                .with_untracked(|u| u.as_ref().map(|u| u.id))
31                .unwrap_or_default(),
32            slot
33        ));
34        let recovery = RwSignal::new(None::<T>);
35        let failed = RwSignal::new(false);
36
37        // 调用方必须保证在 Effect 内(订阅 current / dirty / recovery)或 `untrack` 内调用。
38        let flush = move || {
39            let unsaved = dirty();
40            if recovery.with_untracked(Option::is_some) {
41                return;
42            }
43            let result = (|| {
44                let storage = session_storage()?;
45                let key = key.try_get_value()?;
46                if unsaved {
47                    let json = current
48                        .try_with_untracked(|c| serde_json::to_string(c))?
49                        .ok()?;
50                    storage.set_item(&key, &json).ok()?;
51                } else {
52                    storage.remove_item(&key).ok()?;
53                }
54                Some(())
55            })();
56            let error = result.is_none();
57            if failed.try_get_untracked().is_some_and(|f| f != error) {
58                let _ = failed.try_set(error);
59            }
60        };
61
62        let loaded = StoredValue::new(false);
63        Effect::new(move |_| {
64            if !loaded.get_value() {
65                loaded.set_value(true);
66                let draft = (|| {
67                    let storage = session_storage()?;
68                    let raw = storage.get_item(&key.get_value()).ok()??;
69                    let draft: T = serde_json::from_slice(raw.as_bytes()).ok()?;
70                    current.with_untracked(|c| draft != *c).then_some(draft)
71                })();
72                if draft.is_some() {
73                    recovery.set(draft);
74                }
75            }
76            current.track();
77            recovery.track();
78            flush();
79        });
80
81        let unload = window_event_listener_untyped("beforeunload", move |event| {
82            untrack(flush);
83            if untrack(dirty) {
84                event.prevent_default();
85                let _ = js_sys::Reflect::set(event.as_ref(), &"returnValue".into(), &"".into());
86            }
87        });
88        // JS 持有闭包引用:句柄放进本地存储,卸载时先撤销守卫再丢弃。
89        let guard = StoredValue::new_local(None::<(Rc<Closure<dyn FnMut() -> bool>>, u32)>);
90        if let Some(module) = crate::bridges::navigation::wasm::get_module() {
91            let leave = Rc::new(Closure::<dyn FnMut() -> bool>::new(move || {
92                // 直接读取实时信号并同步写入:点击可能先于缓存 Effect 触发。
93                untrack(flush);
94                !untrack(dirty)
95                    || web_sys::window().is_some_and(|w| {
96                        w.confirm_with_message(
97                            "还有未保存内容,或保存 / 上传尚未完成。确定离开?本地恢复副本不等于保存成功。",
98                        )
99                        .unwrap_or(false)
100                    })
101            }));
102            let id = module.set_leave_guard(&leave);
103            guard.set_value(Some((leave, id)));
104        }
105        on_cleanup(move || {
106            unload.remove();
107            if let Some(Some((_leave, id))) = guard.try_update_value(Option::take) {
108                if let Some(module) = crate::bridges::navigation::wasm::get_module() {
109                    module.clear_leave_guard(id);
110                }
111            }
112        });
113        (recovery, failed)
114    }
115    #[cfg(not(target_arch = "wasm32"))]
116    {
117        let _ = (slot, current, dirty);
118        (RwSignal::new(None), RwSignal::new(false))
119    }
120}
121
122/// A completed request must never clear text entered after its snapshot was submitted.
123pub(super) fn clear_submitted(current: &mut String, submitted: &str) -> bool {
124    if current != submitted {
125        return false;
126    }
127    current.clear();
128    true
129}
130
131#[cfg(test)]
132mod tests {
133    use super::clear_submitted;
134
135    #[test]
136    fn quick_save_clears_only_the_submitted_snapshot() {
137        let mut current = "first".to_string();
138        assert!(clear_submitted(&mut current, "first"));
139        assert!(current.is_empty());
140        current = "first plus a new thought".to_string();
141        assert!(!clear_submitted(&mut current, "first"));
142        assert_eq!(current, "first plus a new thought");
143        current.clear();
144        assert!(!clear_submitted(&mut current, "first"));
145    }
146}