Skip to main content

yggdrasil/pages/admin/note_editor/
state.rs

1use 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/// 编辑器的全部可变状态。字段是 `Copy` 的信号句柄;编辑器卸载后异步回调里的写入
13/// 一律走 `try_*`,迟到的响应因此不会落进下一个编辑器。
14#[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    /// 发布、收录、恢复等明确操作的结果用提示条告知;自动保存只改页头状态。
21    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
40/// Advance only server identity/version when the user edited after submission.
41pub(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
50/// 工作稿相对已保存版本是否有改动;新笔记只要有标题或正文就算有改动。
51fn 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    /// 响应式读取,供视图、memo 与 effect 使用。
60    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    /// 以下为异步路径用的非响应式读取:编辑器已卸载时返回 `None`。
75    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/// 页头保存状态:圆点 + 文字。`Failed` 整条可点重试。
131#[derive(Clone, Debug, PartialEq)]
132pub(super) enum SaveStatus {
133    /// 新笔记还没有内容。
134    Empty,
135    /// 等待选择是否恢复本地副本。
136    Recovering,
137    Saving,
138    Failed,
139    /// 有修改,停止输入后自动保存。
140    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    /// 回归脚本按它等待保存完成。
181    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}