1use super::note_drafts::use_draft_protection;
3use super::notes::client_request;
4use crate::api::notes::*;
5use crate::components::forms::FormSelect;
6use crate::components::ui::{Checkbox, BTN_PRIMARY_SM, BTN_SECONDARY};
7use crate::models::note::*;
8use crate::router::Route;
9use dioxus::prelude::*;
10
11#[derive(Clone, Copy)]
12struct EditorState {
13 draft: Signal<NoteDraft>,
14 saved: Signal<Option<Note>>,
15 busy: Signal<bool>,
16 uploading: Signal<bool>,
17 error: Signal<String>,
18 status: Signal<String>,
19}
20
21impl EditorState {
22 fn dirty(self) -> bool {
23 self.saved()
24 .map(|n| n.draft() != self.draft())
25 .unwrap_or_else(|| {
26 !self.draft().content_md.is_empty() || !self.draft().title.is_empty()
27 })
28 }
29 fn saved(self) -> Option<Note> {
30 (self.saved)()
31 }
32 fn draft(self) -> NoteDraft {
33 (self.draft)()
34 }
35}
36
37async fn persist(mut state: EditorState, action: Option<NoteAction>) {
38 if (state.busy)() {
39 return;
40 }
41 if (state.uploading)() {
42 state
43 .error
44 .set("请先等待图片上传完成,或移除失败的图片".into());
45 return;
46 }
47 state.busy.set(true);
48 state.error.set(String::new());
49 state.status.set("正在保存…".into());
50 let snapshot = state.draft();
51 let result = async {
52 let note = if state.dirty() || state.saved().is_none() {
53 save_note(snapshot.clone()).await?
54 } else {
55 state.saved().unwrap()
56 };
57 if state.draft() == snapshot {
59 state.draft.set(note.draft());
60 } else {
61 state.draft.with_mut(|d| {
62 d.id = Some(note.id);
63 d.expected_version = Some(note.version);
64 });
65 }
66 state.saved.set(Some(note.clone()));
67 if let Some(action) = action {
68 act_on_note(note.id, note.version, action).await
69 } else {
70 Ok(note)
71 }
72 }
73 .await;
74 match result {
75 Ok(note) => {
76 state.saved.set(Some(note));
77 state.status.set(
78 match action {
79 Some(NoteAction::Publish) => "公开版本已更新",
80 Some(NoteAction::IncludeKnowledge) => "已收录当前版本",
81 Some(NoteAction::Unpublish) => "已撤回公开版本",
82 Some(NoteAction::ExcludeKnowledge) => "已移出知识库",
83 Some(NoteAction::Trash) => "已移入回收站",
84 _ => "草稿已保存",
85 }
86 .into(),
87 );
88 }
89 Err(e) => {
90 state.error.set(e.to_string());
91 state.status.set("保存未完成".into());
92 }
93 }
94 state.busy.set(false);
95}
96
97#[component]
98pub fn NewNote() -> Element {
99 rsx! { NoteEditor { initial: None } }
100}
101
102#[component]
103pub fn EditNote(id: i32) -> Element {
104 rsx! {for key in std::iter::once(id) {LoadNoteEditor {key:"{key}",id:key}}}
105}
106
107#[component]
108fn LoadNoteEditor(id: i32) -> Element {
109 let response = use_resource(move || client_request(get_owned_note(id, None)));
110 let data = response.read();
111 match data.as_ref() {
112 Some(Ok(note)) => rsx! {NoteEditor {initial:Some(note.clone())}},
113 Some(Err(e)) => rsx! {p {class:"notes-error",role:"alert","{e}"}},
114 None => rsx! {crate::pages::notes::NotesSkeleton {}},
115 }
116}
117
118#[component]
119fn NoteEditor(initial: Option<Note>) -> Element {
120 let mut state = EditorState {
121 draft: use_signal(|| initial.as_ref().map(Note::draft).unwrap_or_default()),
122 saved: use_signal(|| initial.clone()),
123 busy: use_signal(|| false),
124 uploading: use_signal(|| false),
125 error: use_signal(String::new),
126 status: use_signal(|| "自动保存工作稿".to_string()),
127 };
128 let mut editor_epoch = use_signal(|| 0_u32);
129 let mut tags = use_signal(|| state.draft().tags.join(", "));
130 let slot = state
131 .draft()
132 .id
133 .map(|id| id.to_string())
134 .unwrap_or_else(|| "new".into());
135 let (mut recovery, cache_failed) = use_draft_protection(slot, state.draft, move || {
136 state.dirty() || (state.busy)() || (state.uploading)()
137 });
138 let books = use_resource(|| client_request(owned_notebooks()));
139 let mut preview = use_signal(|| false);
140 let mut revisions = use_signal(Vec::<NoteRevision>::new);
141 let mut show_history = use_signal(|| false);
142 let mut confirm_trash = use_signal(|| false);
143 let navigator = navigator();
144
145 #[cfg(target_arch = "wasm32")]
146 use_effect(move || {
147 let snapshot = state.draft();
148 let busy = (state.busy)();
149 let uploading = (state.uploading)();
150 let failed = !(state.error)().is_empty();
151 if busy || uploading || failed || recovery().is_some() || !state.dirty() {
152 return;
153 }
154 spawn(async move {
155 crate::utils::time::sleep_ms(1200).await;
156 if state.draft() == snapshot
157 && !(state.busy)()
158 && !(state.uploading)()
159 && state.dirty()
160 && (state.error)().is_empty()
161 && recovery().is_none()
162 {
163 persist(state, None).await;
164 }
165 });
166 });
167
168 let deleted = state.saved().is_some_and(|n| n.deleted_at.is_some());
169 rsx! {
170 div {class:"notes-admin",
171 div {class:"notes-admin-heading",
172 div {
173 Link {class:"note-read",to:Route::AdminNotes {},"← 笔记管理"}
174 p {class:"notes-status",role:"status",aria_live:"polite","{state.status}" if state.dirty() {" · 有未保存修改"}}
175 }
176 div {class:"notes-admin-actions",
177 button {class:BTN_SECONDARY,disabled:(state.busy)() || deleted || recovery().is_some(),onclick:move |_|async move {persist(state,None).await;},"保存草稿"}
178 button {class:BTN_SECONDARY,disabled:(state.busy)() || state.saved().is_none() || recovery().is_some(),onclick:move |_| {spawn(async move {persist(state,None).await;if (state.error)().is_empty(){preview.set(!preview());}});},if preview(){"返回编辑"}else{"预览"}}
179 button {class:BTN_PRIMARY_SM,disabled:(state.busy)() || deleted || recovery().is_some(),onclick:move |_|async move {persist(state,Some(NoteAction::Publish)).await;},
180 if state.saved().is_some_and(|n|n.published_version.is_some()) {"更新公开版"} else {"发布到前台"}
181 }
182 }
183 }
184 if !(state.error)().is_empty() {p {class:"notes-error",role:"alert","{state.error}"}}
185 if cache_failed(){p {class:"notes-error",role:"alert","浏览器无法保存本地恢复副本,请先保存到服务端再离开。"}}
186 if let Some(local) = recovery() {
187 div {class:"notes-error",role:"alert",
188 p {"此标签页留有未保存内容。恢复只修改工作稿,不会发布;若服务端已有新版本,请先核对内容。"}
189 button {class:BTN_SECONDARY,onclick:move |_| {
190 let mut draft=local.clone();
191 if let Some(saved)=state.saved(){draft.id=Some(saved.id);draft.expected_version=Some(saved.version);}
192 tags.set(draft.tags.join(", "));state.draft.set(draft);editor_epoch+=1;recovery.set(None);
193 },"恢复未保存内容"}
194 button {class:"note-read",onclick:move |_|recovery.set(None),"使用服务端版本"}
195 }
196 }
197 if recovery().is_some() {
198 p {class:"notes-status","请选择恢复或使用服务端版本,再继续编辑。"}
199 } else if deleted {
200 p {class:"notes-error","这条笔记在回收站中。恢复后可继续编辑,公开状态与知识库收录需要重新确认。"}
201 button {class:BTN_PRIMARY_SM,onclick:move |_|async move {persist(state,Some(NoteAction::Restore)).await;},"恢复笔记"}
202 } else if preview() {
203 if let Some(note)=state.saved() {crate::pages::notes::NoteDocument {key:"{note.version}",note,preview:true}}
204 } else {
205 div {class:"notes-editor-grid",
206 div {class:"notes-editor-main",
207 input {aria_label:"笔记标题",placeholder:if state.draft().kind==NoteKind::Moment {"标题可选,先记下来…"} else {"给这篇笔记一个标题"},maxlength:200,
208 value:state.draft().title,oninput:move |ev|state.draft.with_mut(|d|d.title=ev.value())}
209 for epoch in std::iter::once(editor_epoch()) {
210 NoteComposer {key:"{epoch}",initial:state.draft().content_md,on_change:move |md|state.draft.with_mut(|d|d.content_md=md),uploading:state.uploading}
211 }
212 }
213 aside {class:"notes-editor-side",
214 div {class:"notes-editor-field",label {r#for:"note-kind","记录方式"}
215 FormSelect {
216 id: Some("note-kind".to_string()),
217 value: state.draft().kind,
218 options: vec![(NoteKind::Moment, "随记"), (NoteKind::Topic, "主题笔记")],
219 onchange: move |kind: NoteKind| state.draft.with_mut(|d| d.kind = kind),
220 }
221 }
222 div {class:"notes-editor-field",label {r#for:"note-tags","标签"} input {id:"note-tags",r#type:"text",placeholder:"Rust, 排错, 读书",value:"{tags}",oninput:move |ev| {
223 let value=ev.value();state.draft.with_mut(|d|d.tags=value.split([',',',']).map(str::trim).filter(|s|!s.is_empty()).map(str::to_string).collect());tags.set(value);
224 }}}
225 fieldset {class:"notes-editor-section notes-editor-notebooks",legend {"收录到笔记本"}
226 if let Some(Ok(items))=books.read().as_ref() {
227 for book in items {label {class:"notebook-check",key:"{book.id}",
228 Checkbox {checked:state.draft().notebook_ids.contains(&book.id),onchange:{let id=book.id;move |checked|state.draft.with_mut(|d| {d.notebook_ids.retain(|b|*b!=id);if checked{d.notebook_ids.push(id);d.notebook_ids.sort_unstable();}})}} "{book.title}" if book.archived_at.is_some(){"(已归档)"}
229 }}
230 if items.is_empty() {Link {to:Route::AdminNotebooks {},"创建第一个笔记本 →"}}
231 }
232 }
233 div {class:"notes-editor-section notes-editor-knowledge",h2 {"AI 知识库"}
234 p {"收录后,获得授权的 AI 可以读取这一版内容。无需公开发布。"}
235 if let Some(version)=state.saved().and_then(|n|n.knowledge_version) {p {"当前收录:v{version}"}}
236 button {class:BTN_SECONDARY,disabled:(state.busy)(),onclick:move |_|async move {persist(state,Some(NoteAction::IncludeKnowledge)).await;},"收录当前版本"}
237 if state.saved().is_some_and(|n|n.knowledge_version.is_some()) {button {class:"note-read",disabled:(state.busy)(),onclick:move |_|async move {persist(state,Some(NoteAction::ExcludeKnowledge)).await;},"移出知识库"}}
238 }
239 if let Some(note)=state.saved() {
240 div {class:"notes-editor-section notes-editor-public",h2 {"公开状态"}
241 if let Some(version)=note.published_version {p {"已公开 v{version},草稿修改不会自动发布。"}
242 div {class:"notes-editor-inline-actions",
243 a {class:"note-read",href:format!("/notes/{}",note.slug),target:"_blank",rel:"noopener","查看公开页面 ↗"}
244 button {class:"note-read",disabled:(state.busy)(),onclick:move |_|async move {persist(state,Some(NoteAction::Unpublish)).await;},"撤回公开版"}
245 }
246 } else {p {"仅自己可见。"}}
247 }
248 div {class:"notes-editor-section notes-editor-history-section",
249 button {class:"note-read notes-editor-history-toggle",onclick:move |_| {show_history.set(!show_history());if let Some(n)=state.saved(){spawn(async move {match note_history(n.id).await {Ok(items)=>revisions.set(items),Err(e)=>state.error.set(e.to_string())}});}},"历史版本"}
250 if show_history() {
251 p {"恢复会生成新草稿,保留现有历史。"}
252 div {class:"notes-editor-history",
253 for revision in revisions() {
254 button {disabled:(state.busy)(),onclick:move |_| {
255 if let Some(current)=state.saved() {spawn(async move {
256 persist(state,None).await;
257 if !(state.error)().is_empty(){return;}
258 match get_owned_note(current.id,Some(revision.version)).await {
259 Ok(old)=>{let mut draft=old.draft();draft.expected_version=state.saved().map(|n|n.version);tags.set(draft.tags.join(", "));state.draft.set(draft);editor_epoch+=1;persist(state,None).await;},
260 Err(e)=>state.error.set(e.to_string()),
261 }
262 });}
263 },{format!("v{} · {} · 恢复",revision.version,revision.created_at.format("%m-%d %H:%M"))}}
264 }
265 }
266 }
267 }
268 div {class:"notes-editor-section notes-editor-danger",
269 if confirm_trash() {
270 p {"移入回收站将撤回公开版并移出知识库,之后可以恢复。"}
271 button {class:BTN_SECONDARY,disabled:(state.busy)(),onclick:move |_|async move {persist(state,Some(NoteAction::Trash)).await;if (state.error)().is_empty(){navigator.push(Route::AdminNotes {});}},"确认移入回收站"}
272 button {class:"note-read",onclick:move |_|confirm_trash.set(false),"取消"}
273 } else {button {class:"note-read",onclick:move |_|confirm_trash.set(true),"移入回收站"}}
274 }
275 }
276 }
277 }
278 }
279 }
280 }
281}
282
283#[component]
285fn NoteComposer(
286 initial: String,
287 on_change: EventHandler<String>,
288 mut uploading: Signal<bool>,
289) -> Element {
290 #[cfg(target_arch = "wasm32")]
291 {
292 use crate::bridges::library::{library_ready, use_browser_library};
293 use crate::bridges::tiptap::{
294 self, EditorHandle, EditorOptions, UploadErrorEntry, UploadsInFlight,
295 };
296 use wasm_bindgen::prelude::*;
297 let library = use_browser_library("tiptap", || true);
298 let mut editor = use_signal(|| None::<EditorHandle>);
299 let mut ready = use_signal(|| false);
300 let mut error = use_signal(String::new);
301 let counts = use_signal(UploadsInFlight::default);
302 let errors = use_signal(Vec::<UploadErrorEntry>::new);
303 let mut pick = use_signal(|| false);
304 let picking_upload = use_signal(|| false);
305 use_drop(move || editor.set(None));
306 use_effect(move || {
307 uploading.set(counts().uploading > 0 || counts().error > 0 || picking_upload())
308 });
309 use_effect(move || {
310 if editor.peek().is_some() || !library_ready(library) {
311 return;
312 }
313 let update = Closure::new(move |md: String| on_change.call(md));
314 let on_ready = Closure::new(move || ready.set(true));
315 let upload = Closure::new(move |file: web_sys::File| {
316 wasm_bindgen_futures::future_to_promise(async move {
317 tiptap::wasm::upload_note_image_file(file)
318 .await
319 .map(|s| JsValue::from_str(&s))
320 .map_err(|e| JsValue::from_str(&e))
321 })
322 });
323 let upload_event = Closure::new(move |ev: tiptap::UploadEventJs| {
324 tiptap::consume_upload_event(&ev, counts, errors)
325 });
326 let run = tiptap::make_run_code_closure();
327 let picker = Closure::new(move || pick.set(true));
328 let opts = EditorOptions::new();
329 opts.set_placeholder("从一个想法开始。输入 / 插入代码、图片、公式或引用…");
330 opts.set_on_update(&update);
331 opts.set_on_ready(&on_ready);
332 opts.set_on_image_upload(&upload);
333 opts.set_on_upload_event(&upload_event);
334 opts.set_on_run_code(&run);
335 opts.set_on_pick_from_library(&picker);
336 match tiptap::get_module().create("note-tiptap", &opts) {
337 Ok(Some(inst)) => {
338 inst.set_markdown(&initial);
339 editor.set(Some(EditorHandle::new(
340 inst,
341 update,
342 upload,
343 on_ready,
344 upload_event,
345 run,
346 picker,
347 )));
348 }
349 _ => error.set("编辑器初始化失败,请刷新后重试".into()),
350 }
351 });
352 return rsx! {
353 div {class:"notes-editor-surface",
354 crate::bridges::library::LibraryLoadError {library}
355 if !ready() {p {role:"status","正在加载编辑器…"}}
356 if !error().is_empty() {p {class:"notes-error",role:"alert","{error}"}}
357 for item in errors() {p {class:"notes-error",key:"{item.id}","{item.message}"
358 button {class:"note-read",onclick:move |_| {if let Some(handle)=editor.peek().as_ref(){handle.instance().remove_upload_by_upload_id(&item.id);}},"移除失败图片"}
359 }}
360 div {id:"note-tiptap"}
361 }
362 if pick() {
363 p {class:"notes-status","素材库中的图片原本就是公开素材。需要私密图片时,请在编辑器中上传。"}
364 crate::components::assets::AssetPickerModal {visible:pick,cover_uploading:picking_upload,title:"选择公开素材",multi:true,on_select:move |items:Vec<crate::components::assets::AssetSelection>| {
365 if let Some(handle)=editor.peek().as_ref(){if let Ok(json)=serde_json::to_string(&items){handle.instance().insert_images_from_library(&json);}}
366 pick.set(false);
367 }}
368 }
369 };
370 }
371 #[cfg(not(target_arch = "wasm32"))]
372 {
373 let _ = (initial, on_change, uploading);
374 rsx! {div {class:"notes-editor-surface",p {role:"status","正在加载编辑器…"}}}
375 }
376}