yggdrasil/pages/admin/
note_drafts.rs1use leptos::prelude::*;
3use serde::{de::DeserializeOwned, Serialize};
4
5pub(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 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 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 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
122pub(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}