yggdrasil/components/comments/
form.rs1use leptos::prelude::*;
10use leptos::task::spawn_local;
11
12use crate::bridges::library::{
13 library_ready, use_browser_library, BrowserLibrary, LibraryLoadError,
14};
15use crate::bridges::tiptap::{UploadErrorEntry, UploadsInFlight};
16use crate::components::comments::section::{CommentContext, CommentSubmission};
17use crate::components::forms::{AlertBox, FormInput};
18use crate::components::ui::{
19 ime_composing, Button, ButtonSize, ButtonVariant, Composer, ComposerSize, Icon, IconName,
20 UserAvatar, SPINNER_SVG,
21};
22use crate::utils::comment_storage;
23#[cfg(target_arch = "wasm32")]
24use wasm_bindgen::closure::Closure;
25
26#[cfg(target_arch = "wasm32")]
28struct EditorVisibility {
29 observer: web_sys::IntersectionObserver,
30 _callback: Closure<dyn FnMut(js_sys::Array, web_sys::IntersectionObserver)>,
31}
32
33#[cfg(target_arch = "wasm32")]
34impl Drop for EditorVisibility {
35 fn drop(&mut self) {
36 self.observer.disconnect();
37 }
38}
39
40#[component]
54pub fn CommentForm(
55 post_id: i32,
56 parent_id: Option<i64>,
57 parent_indent: Option<i32>,
58) -> impl IntoView {
59 let ctx = expect_context::<CommentContext>();
60 let active_reply = ctx.active_reply;
61 let refresh_trigger = ctx.refresh_trigger;
62 let viewer = ctx.current_user;
63 let image_upload = ctx.image_upload;
64
65 let author_name = RwSignal::new(String::new());
66 let author_email = RwSignal::new(String::new());
67 let author_url = RwSignal::new(String::new());
68 let content_md = RwSignal::new(String::new());
69 let honeypot = RwSignal::new(String::new());
70 let submitting = RwSignal::new(false);
71 let message = RwSignal::new(Option::<(String, &'static str)>::None);
72 let editor_visible = RwSignal::new(false);
73 let is_active = Memo::new(move |_| match parent_id {
75 Some(pid) => active_reply.get() == Some(pid),
76 None => true,
77 });
78 let editor_library = use_browser_library(BrowserLibrary::Tiptap, move || match parent_id {
79 Some(pid) => active_reply.get() == Some(pid),
80 None => editor_visible.get(),
81 });
82 let uploads_in_flight = RwSignal::new(UploadsInFlight::default());
85 let upload_errors: RwSignal<Vec<UploadErrorEntry>> = RwSignal::new(Vec::new());
86
87 Effect::new(move |_| {
89 if let Some(info) = comment_storage::load_author() {
90 author_name.set(info.name);
91 author_email.set(info.email);
92 author_url.set(info.url);
93 }
94 });
95
96 let is_reply = parent_id.is_some();
97
98 let id_suffix = format!(
100 "{}-{}",
101 ctx.id_scope,
102 parent_id.map_or_else(|| "root".to_string(), |pid| pid.to_string())
103 );
104 let image_input_dom_id = format!("comment-image-{id_suffix}");
106
107 let negative_margin = match (is_reply, parent_indent) {
109 (true, Some(px)) if px > 0 => Some(format!("margin-left: -{px}px;")),
110 _ => None,
111 };
112
113 let form_ref = NodeRef::<leptos::html::Div>::new();
114 let editor_mount = NodeRef::<leptos::html::Div>::new();
116
117 #[cfg(target_arch = "wasm32")]
120 let editor_handle: StoredValue<Option<crate::bridges::tiptap::EditorHandle>, LocalStorage> =
121 StoredValue::new_local(None);
122 #[cfg(target_arch = "wasm32")]
126 on_cleanup(move || {
127 let _ = editor_handle.try_update_value(Option::take);
128 });
129
130 #[cfg(target_arch = "wasm32")]
132 {
133 let visibility_observer: StoredValue<Option<EditorVisibility>, LocalStorage> =
134 StoredValue::new_local(None);
135 Effect::new(move |_| {
136 use wasm_bindgen::JsCast;
137 let Some(element) = form_ref.get() else {
138 return;
139 };
140 if is_reply
141 || editor_visible.get_untracked()
142 || visibility_observer.with_value(|o| o.is_some())
143 {
144 return;
145 }
146 let callback = Closure::<dyn FnMut(js_sys::Array, web_sys::IntersectionObserver)>::new(
147 move |entries: js_sys::Array, observer: web_sys::IntersectionObserver| {
148 if entries.iter().any(|entry| {
149 entry
150 .dyn_into::<web_sys::IntersectionObserverEntry>()
151 .is_ok_and(|entry| entry.is_intersecting())
152 }) {
153 observer.disconnect();
154 editor_visible.set(true);
155 }
156 },
157 );
158 let options = web_sys::IntersectionObserverInit::new();
159 options.set_root_margin("300px");
160 match web_sys::IntersectionObserver::new_with_options(
161 callback.as_ref().unchecked_ref(),
162 &options,
163 ) {
164 Ok(observer) => {
165 observer.observe(&element);
166 visibility_observer.set_value(Some(EditorVisibility {
167 observer,
168 _callback: callback,
169 }));
170 }
171 Err(_) => editor_visible.set(true),
172 }
173 });
174 }
175
176 #[cfg(target_arch = "wasm32")]
181 {
182 Effect::new(move |_| {
183 if !is_active.get() {
184 if editor_handle.with_value(|h| h.is_some()) {
186 editor_handle.set_value(None);
187 }
188 return;
189 }
190 if editor_handle.with_value(|h| h.is_some()) {
192 return;
193 }
194 if !library_ready(editor_library) {
195 return;
196 }
197 request_animation_frame(move || {
198 if is_active.try_get_untracked() != Some(true) {
200 return;
201 }
202 if editor_handle.try_with_value(|h| h.is_some()) != Some(false) {
203 return;
204 }
205 let Some(container) = editor_mount.get_untracked() else {
206 web_sys::console::warn_1(&"评论编辑器容器未挂载".into());
207 return;
208 };
209
210 let on_update: Closure<dyn FnMut(String)> =
211 Closure::new(move |md: String| content_md.set(md));
212 let on_ready: Closure<dyn FnMut()> = Closure::new(|| {});
213 let on_image_upload: Closure<dyn Fn(web_sys::File) -> js_sys::Promise> =
214 if image_upload {
215 crate::bridges::tiptap::make_comment_upload_closure()
216 } else {
217 Closure::new(|_file: web_sys::File| {
218 js_sys::Promise::reject(&"评论图片上传已关闭".into())
219 })
220 };
221 let on_upload_event: Closure<dyn FnMut(crate::bridges::tiptap::UploadEventJs)> =
222 Closure::new(move |ev: crate::bridges::tiptap::UploadEventJs| {
223 match ev.kind().as_str() {
226 "error" => {
227 let msg = ev.error_msg().unwrap_or_else(|| "上传失败".to_string());
228 message.set(Some((format!("图片上传失败:{msg}"), "error")));
229 }
230 "success" | "removed"
231 if message.with_untracked(|m| {
232 m.as_ref()
233 .is_some_and(|(m, _)| m.starts_with("图片上传失败"))
234 }) =>
235 {
236 message.set(None);
237 }
238 _ => {}
239 }
240 crate::bridges::tiptap::consume_upload_event(
241 &ev,
242 uploads_in_flight,
243 upload_errors,
244 );
245 });
246
247 let opts = crate::bridges::tiptap::EditorOptions::default();
248 opts.set_variant("comment");
249 opts.set_placeholder(if is_reply {
250 "写下你的回复…"
251 } else {
252 "写下你的想法…"
253 });
254 opts.set_on_update(&on_update);
255 opts.set_on_ready(&on_ready);
256 if image_upload {
257 opts.set_on_image_upload(&on_image_upload);
258 }
259 opts.set_on_upload_event(&on_upload_event);
260
261 match crate::bridges::tiptap::create(&container, &opts) {
263 Ok(inst) => {
264 let draft = content_md.get_untracked();
266 if !draft.is_empty() {
267 let _ = inst.set_markdown(&draft);
268 }
269 let handle = crate::bridges::tiptap::EditorHandle::new_comment(
270 inst,
271 on_update,
272 on_image_upload,
273 on_ready,
274 on_upload_event,
275 );
276 editor_handle.set_value(Some(handle));
277 }
278 Err(e) => {
279 web_sys::console::error_1(&e);
280 message.set(Some((
281 "评论编辑器加载失败,请刷新页面重试".to_string(),
282 "error",
283 )));
284 }
285 }
286 });
287 });
288 }
289 #[cfg(not(target_arch = "wasm32"))]
290 let _ = (upload_errors, image_upload);
291
292 let do_submit = move || {
293 if submitting.get_untracked() || !editor_library.ready_untracked() {
294 return;
295 }
296
297 let is_anon = viewer.with_untracked(|v| v.is_none());
298 let (name, email, url_val) = if is_anon {
300 (
301 author_name.get_untracked(),
302 author_email.get_untracked(),
303 author_url.get_untracked(),
304 )
305 } else {
306 (String::new(), String::new(), String::new())
307 };
308 let content = content_md.get_untracked();
309 let hp = honeypot.get_untracked();
310
311 let in_flight = uploads_in_flight.get_untracked();
313 if in_flight.uploading > 0 || in_flight.error > 0 {
314 let msg = if in_flight.uploading > 0 {
315 format!(
316 "有 {} 张图片正在上传,请等待完成后再发表",
317 in_flight.uploading
318 )
319 } else {
320 format!(
321 "有 {} 张图片上传失败,请重试或移除后再发表",
322 in_flight.error
323 )
324 };
325 message.set(Some((msg, "error")));
326 return;
327 }
328 if content.contains("](blob:") {
330 message.set(Some((
331 "检测到未完成上传的图片,请处理后再发表".to_string(),
332 "error",
333 )));
334 return;
335 }
336
337 if !hp.is_empty() {
339 return;
340 }
341
342 if content.trim().is_empty() {
343 message.set(Some(("请填写评论内容".to_string(), "error")));
344 return;
345 }
346 if is_anon && (name.trim().is_empty() || email.trim().is_empty()) {
347 message.set(Some(("请填写昵称和邮箱".to_string(), "error")));
348 return;
349 }
350 submitting.set(true);
351 message.set(None);
352 let request = ctx.submit.run(CommentSubmission {
353 post_id,
354 parent_id,
355 anonymous: is_anon,
356 author_name: name,
357 author_email: email,
358 author_url: url_val,
359 content_md: content,
360 honeypot: hp,
361 });
362 spawn_local(async move {
363 let result = request.await;
364 let _ = submitting.try_set(false);
365 match result {
366 Ok(msg) => {
367 let _ = content_md.try_set(String::new());
368 #[cfg(target_arch = "wasm32")]
370 let _ = editor_handle.try_with_value(|h| {
371 if let Some(handle) = h {
372 let _ = handle.instance().set_markdown("");
373 }
374 });
375 let _ = message.try_set(Some((msg, "success")));
376 if parent_id.is_some() {
377 let _ = active_reply.try_set(None);
378 }
379 let _ = refresh_trigger.try_update(|v| *v = !*v);
380 }
381 Err(msg) => {
382 let _ = message.try_set(Some((msg, "error")));
383 }
384 }
385 });
386 };
387
388 let form_class = if is_reply {
389 "comment-form is-reply"
390 } else {
391 "comment-form"
392 };
393 let form_label = if is_reply {
394 "回复评论"
395 } else {
396 "发表评论"
397 };
398 let composer_size = if is_reply {
399 ComposerSize::Sm
400 } else {
401 ComposerSize::Md
402 };
403 let mount_class = if is_reply {
404 "comment-editor-mount is-reply"
405 } else {
406 "comment-editor-mount"
407 };
408
409 move || {
410 if !is_active.get() {
411 return ().into_any();
412 }
413 let negative_margin = negative_margin.clone();
414 let fields_suffix = id_suffix.clone();
415 let image_input_dom_id = image_input_dom_id.clone();
416
417 view! {
418 <div
419 class=form_class
420 style=negative_margin
421 role="form"
422 aria-label=form_label
423 node_ref=form_ref
424 on:focusin=move |_| editor_visible.set(true)
425 on:pointerdown=move |_| editor_visible.set(true)
426 on:keydown=move |e| {
429 if !ime_composing(false, &e) && (e.ctrl_key() || e.meta_key())
430 && e.key() == "Enter"
431 {
432 do_submit();
433 }
434 }
435 >
436 {move || {
437 message
438 .get()
439 .map(|(msg, variant)| {
440 view! {
441 <div class="mb-3" aria-live="polite">
442 <AlertBox message=msg variant=variant/>
443 </div>
444 }
445 })
446 }}
447 <LibraryLoadError library=editor_library/>
448
449 <Composer
450 stacked=true
451 size=composer_size
452 class="comment-composer"
453 >
454 {move || {
456 viewer
457 .get()
458 .map(|user| {
459 let label = user.display_label().to_string();
460 let action = if is_reply { "回复" } else { "发表评论" };
461 view! {
462 <div class="comment-identity">
463 <UserAvatar
464 name=label.clone()
465 avatar_url=user.avatar_url.clone()
466 class="comment-identity-avatar"
467 />
468 <span>
469 "以 " <strong>{label}</strong> {format!(" 的身份{action}")}
470 </span>
471 </div>
472 }
473 })
474 }}
475
476 <div class="comment-editor">
479 <div
480 node_ref=editor_mount
481 class=mount_class
482 ></div>
483 {move || {
484 (!library_ready(editor_library))
485 .then(|| {
486 view! {
487 <span class="comment-editor-loading">"正在加载编辑器…"</span>
488 }
489 })
490 }}
491 </div>
492
493 {move || {
496 let fields_suffix = fields_suffix.clone();
497 viewer
498 .with(|v| v.is_none())
499 .then(move || {
500 view! {
501 <textarea
502 class="hidden"
503 aria-hidden="true"
504 tabindex="-1"
505 prop:value=move || honeypot.get()
506 on:input=move |e| honeypot.set(event_target_value(&e))
507 ></textarea>
508 <div class="comment-fields">
509 <FormInput
510 id=format!("comment-name-{fields_suffix}")
511 r#type="text"
512 placeholder="昵称"
513 aria_label="昵称(必填)"
514 value=author_name
515 disabled=submitting
516 class="comment-field"
517 oninput=move |v: String| author_name.set(v)
518 />
519 <FormInput
520 id=format!("comment-email-{fields_suffix}")
521 r#type="email"
522 placeholder="邮箱(不公开)"
523 aria_label="邮箱(必填,不公开)"
524 value=author_email
525 disabled=submitting
526 class="comment-field"
527 oninput=move |v: String| author_email.set(v)
528 />
529 <FormInput
530 id=format!("comment-url-{fields_suffix}")
531 r#type="url"
532 placeholder="网站(可选)"
533 aria_label="网站(可选)"
534 value=author_url
535 disabled=submitting
536 class="comment-field"
537 oninput=move |v: String| author_url.set(v)
538 />
539 </div>
540 }
541 })
542 }}
543
544 <div class="comment-bar">
545 <div class="comment-bar-start">
547 {image_upload
549 .then(|| {
550 view! {
551 <label
552 for=image_input_dom_id.clone()
553 class="btn btn-sm btn-icon btn-ghost"
554 title="上传图片(也可直接粘贴到输入框)"
555 aria-label="上传图片"
556 >
557 <Icon name=IconName::Image size=16/>
558 </label>
559 <input
560 id=image_input_dom_id
561 class="hidden"
562 type="file"
563 accept="image/jpeg,image/png,image/gif,image/webp"
564 multiple=true
565 on:change=move |ev| {
566 #[cfg(target_arch = "wasm32")]
567 {
568 let input = event_target::<
570 web_sys::HtmlInputElement,
571 >(&ev);
572 if let Some(files) = input.files() {
573 for i in 0..files.length() {
574 if let Some(file) = files.get(i) {
575 editor_handle
576 .with_value(|h| {
577 if let Some(handle) = h {
578 handle
579 .instance()
580 .insert_uploading(file);
581 }
582 });
583 }
584 }
585 }
586 input.set_value("");
588 }
589 #[cfg(not(target_arch = "wasm32"))]
590 let _ = ev;
591 }
592 />
593 }
594 })}
595 {move || {
596 if uploads_in_flight.with(|u| u.uploading > 0) {
597 view! {
598 <span class="comment-hint">
599 <span class="inline-block" inner_html=SPINNER_SVG></span>
600 "图片上传中…"
601 </span>
602 }
603 .into_any()
604 } else {
605 view! {
606 <span class="comment-hint comment-hint-wide">
607 {move || {
608 if viewer.with(|v| v.is_none()) {
609 "支持 Markdown 与图片,审核后显示"
610 } else {
611 "支持 Markdown 与图片 · ⌘↵ 发表"
612 }
613 }}
614 </span>
615 }
616 .into_any()
617 }
618 }}
619 </div>
620
621 <div class="comment-bar-end">
623 {is_reply
624 .then(|| {
625 view! {
626 <button
627 type="button"
628 class="btn btn-sm btn-ghost"
629 on:click=move |_| active_reply.set(None)
630 >
631 "取消"
632 </button>
633 }
634 })}
635 <Button
638 variant=ButtonVariant::Primary
639 size=ButtonSize::Sm
640 loading=submitting
641 disabled=Signal::derive(move || {
642 !library_ready(editor_library)
643 || uploads_in_flight.with(|u| u.uploading > 0 || u.error > 0)
644 })
645 onclick=move |_| do_submit()
646 >
647 {if is_reply { "回复" } else { "发表" }}
648 </Button>
649 </div>
650 </div>
651 </Composer>
652 </div>
653 }
654 .into_any()
655 }
656}