yggdrasil/pages/admin/note_editor/
state.rs1use crate::components::ui::Toaster;
2use crate::models::note::{Note, NoteDraft};
3use leptos::prelude::*;
4
5#[derive(Clone, Debug, PartialEq)]
6pub(super) enum SaveState {
7 Idle,
8 Saving,
9 Failed(String),
10}
11
12#[derive(Clone, Copy)]
15pub(super) struct EditorState {
16 pub draft: RwSignal<NoteDraft>,
17 pub saved: RwSignal<Option<Note>>,
18 pub save: RwSignal<SaveState>,
19 pub uploading: RwSignal<bool>,
20 pub toast: Toaster,
22 pub tags: RwSignal<String>,
23 pub editor_epoch: RwSignal<u32>,
24}
25
26pub(super) fn use_editor_state(initial: Option<Note>, toast: Toaster) -> EditorState {
27 let draft = initial.as_ref().map(Note::draft).unwrap_or_default();
28 let tags = draft.tags.join(", ");
29 EditorState {
30 draft: RwSignal::new(draft),
31 saved: RwSignal::new(initial),
32 save: RwSignal::new(SaveState::Idle),
33 uploading: RwSignal::new(false),
34 toast,
35 tags: RwSignal::new(tags),
36 editor_epoch: RwSignal::new(0),
37 }
38}
39
40pub(super) fn reconcile_save(current: &mut NoteDraft, submitted: &NoteDraft, saved: NoteDraft) {
42 if current == submitted {
43 *current = saved;
44 } else {
45 current.id = saved.id;
46 current.expected_version = saved.expected_version;
47 }
48}
49
50fn is_dirty(saved: &Option<Note>, draft: &NoteDraft) -> bool {
52 match saved {
53 Some(note) => note.draft() != *draft,
54 None => !draft.content_md.is_empty() || !draft.title.is_empty(),
55 }
56}
57
58impl EditorState {
59 pub fn dirty(self) -> bool {
61 self.saved
62 .with(|saved| self.draft.with(|draft| is_dirty(saved, draft)))
63 }
64 pub fn busy(self) -> bool {
65 self.save.with(|save| matches!(save, SaveState::Saving))
66 }
67 pub fn error(self) -> String {
68 self.save.with(|save| match save {
69 SaveState::Failed(error) => error.clone(),
70 _ => String::new(),
71 })
72 }
73
74 pub fn dirty_now(self) -> Option<bool> {
76 self.saved
77 .try_with_untracked(|saved| {
78 self.draft
79 .try_with_untracked(|draft| is_dirty(saved, draft))
80 })
81 .flatten()
82 }
83 pub fn saved_now(self) -> Option<Option<Note>> {
84 self.saved.try_get_untracked()
85 }
86 pub fn draft_now(self) -> Option<NoteDraft> {
87 self.draft.try_get_untracked()
88 }
89 pub fn busy_now(self) -> bool {
90 self.save
91 .try_with_untracked(|save| matches!(save, SaveState::Saving))
92 .unwrap_or(false)
93 }
94 pub fn error_now(self) -> bool {
95 self.save
96 .try_with_untracked(|save| matches!(save, SaveState::Failed(_)))
97 .unwrap_or(false)
98 }
99
100 pub fn fail(self, message: impl ToString) {
101 let _ = self.save.try_set(SaveState::Failed(message.to_string()));
102 }
103 pub fn begin(self) -> bool {
104 if self
105 .save
106 .with_untracked(|save| matches!(save, SaveState::Saving))
107 {
108 return false;
109 }
110 if self.uploading.get_untracked() {
111 self.fail("请先等待图片上传完成,或移除失败的图片");
112 return false;
113 }
114 self.save.set(SaveState::Saving);
115 true
116 }
117 pub fn restore_local(self, mut draft: NoteDraft) {
118 if let Some(Some(saved)) = self.saved_now() {
119 draft.id = Some(saved.id);
120 draft.expected_version = Some(saved.version);
121 }
122 let _ = self.tags.try_set(draft.tags.join(", "));
123 let _ = self.draft.try_set(draft);
124 let _ = self
125 .editor_epoch
126 .try_update(|epoch| *epoch = epoch.wrapping_add(1));
127 }
128}
129
130#[derive(Clone, Debug, PartialEq)]
132pub(super) enum SaveStatus {
133 Empty,
135 Recovering,
137 Saving,
138 Failed,
139 Dirty,
141 Saved {
142 version: i32,
143 },
144}
145
146impl SaveStatus {
147 pub fn of(
148 saving: bool,
149 failed: bool,
150 dirty: bool,
151 recovering: bool,
152 saved: Option<i32>,
153 ) -> Self {
154 if recovering {
155 Self::Recovering
156 } else if saving {
157 Self::Saving
158 } else if failed {
159 Self::Failed
160 } else if dirty {
161 Self::Dirty
162 } else if let Some(version) = saved {
163 Self::Saved { version }
164 } else {
165 Self::Empty
166 }
167 }
168
169 pub fn label(&self) -> String {
170 match self {
171 Self::Empty => "新笔记 · 尚未保存".into(),
172 Self::Recovering => "等待选择本地副本".into(),
173 Self::Saving => "正在保存…".into(),
174 Self::Failed => "保存失败 · 点此重试".into(),
175 Self::Dirty => "有未保存修改".into(),
176 Self::Saved { version } => format!("工作稿 v{version} · 已保存"),
177 }
178 }
179
180 pub fn key(&self) -> &'static str {
182 match self {
183 Self::Empty => "empty",
184 Self::Recovering => "recovering",
185 Self::Saving => "saving",
186 Self::Failed => "failed",
187 Self::Dirty => "dirty",
188 Self::Saved { .. } => "saved",
189 }
190 }
191
192 pub fn tone(&self) -> &'static str {
193 match self {
194 Self::Failed => "danger",
195 Self::Dirty | Self::Saving => "clay",
196 _ => "muted",
197 }
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 #[test]
205 fn delayed_save_keeps_new_text_tags_and_notebooks_but_advances_version() {
206 let submitted = NoteDraft {
207 content_md: "first".into(),
208 ..Default::default()
209 };
210 let mut current = NoteDraft {
211 content_md: "new text".into(),
212 tags: vec!["new".into()],
213 notebook_ids: vec![3],
214 ..submitted.clone()
215 };
216 reconcile_save(
217 &mut current,
218 &submitted,
219 NoteDraft {
220 id: Some(4),
221 expected_version: Some(2),
222 ..submitted.clone()
223 },
224 );
225 assert_eq!(current.content_md, "new text");
226 assert_eq!(current.tags, ["new"]);
227 assert_eq!(current.notebook_ids, [3]);
228 assert_eq!((current.id, current.expected_version), (Some(4), Some(2)));
229 }
230 #[test]
231 fn save_status_prefers_recovery_then_saving_then_failure() {
232 assert_eq!(
233 SaveStatus::of(true, true, true, true, Some(3)),
234 SaveStatus::Recovering
235 );
236 assert_eq!(
237 SaveStatus::of(true, true, true, false, Some(3)),
238 SaveStatus::Saving
239 );
240 assert_eq!(
241 SaveStatus::of(false, true, true, false, Some(3)),
242 SaveStatus::Failed
243 );
244 assert_eq!(
245 SaveStatus::of(false, false, true, false, Some(3)),
246 SaveStatus::Dirty
247 );
248 let saved = SaveStatus::of(false, false, false, false, Some(3));
249 assert_eq!(saved.label(), "工作稿 v3 · 已保存");
250 assert_eq!(
251 SaveStatus::of(false, false, false, false, None),
252 SaveStatus::Empty
253 );
254 }
255 #[test]
256 fn unchanged_save_accepts_server_normalization() {
257 let submitted = NoteDraft {
258 title: " title ".into(),
259 ..Default::default()
260 };
261 let mut current = submitted.clone();
262 let normalized = NoteDraft {
263 title: "title".into(),
264 id: Some(1),
265 expected_version: Some(1),
266 ..submitted.clone()
267 };
268 reconcile_save(&mut current, &submitted, normalized.clone());
269 assert_eq!(current, normalized);
270 }
271}