Skip to main content

yggdrasil/components/comments/
form.rs

1//! 评论表单组件
2//!
3//! 提供发表评论与回复评论的表单,包含昵称、邮箱、网站、内容与反垃圾蜜罐字段。
4//! 内容编辑用 tiptap 所见即所得编辑器(comment variant——后台文章编辑器的
5//! 精简子集):选中浮出气泡菜单(B/I/S/code/link)、StarterKit 输入规则
6//! (`> `、`- `、``` 等)、数学公式、图片上传(点击按钮/粘贴/拖放均走
7//! coordinator 占位符上传,loading/error 态与后台完全一致)。
8
9use 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/// 顶层表单的视口观察器:表单接近视口(300px)时才加载编辑器;drop 时断开观察。
27#[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/// 评论表单组件,用于顶层评论或回复评论。
41///
42/// Props:
43/// - `post_id`:所属文章 ID
44/// - `parent_id`:回复目标评论 ID,`None` 表示顶层评论
45/// - `parent_indent`:回复时父评论的缩进像素值,用于用负 margin 把表单拉回内容区左边缘
46///
47/// 关键事件:
48/// - 挂载时从本地存储恢复上次填写的作者信息
49/// - 提交时校验必填项与蜜罐字段
50/// - 提交交给 CommentContext::submit;成功后清空内容、关闭回复并触发列表刷新
51///
52/// 回复表单未激活时不渲染任何 DOM(与此前的早退行为一致),草稿信号随组件存活保留。
53#[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    // 回复表单:当前未激活回复时隐藏;顶层表单恒为激活。
74    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    // 图片上传状态(tiptap coordinator 事件驱动):
83    // 进行中计数用于提交门控;失败条目由编辑器内错误态兜底(重试/移除)。
84    let uploads_in_flight = RwSignal::new(UploadsInFlight::default());
85    let upload_errors: RwSignal<Vec<UploadErrorEntry>> = RwSignal::new(Vec::new());
86
87    // 首次挂载时从本地存储加载作者信息(Effect 只在浏览器执行,不影响水合)
88    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    // 用于区分顶层表单与多个回复表单的 id 后缀,保证页面内 label/for 关联唯一。
99    let id_suffix = format!(
100        "{}-{}",
101        ctx.id_scope,
102        parent_id.map_or_else(|| "root".to_string(), |pid| pid.to_string())
103    );
104    // 图片上传 file input 的 DOM id(label for 关联)。
105    let image_input_dom_id = format!("comment-image-{id_suffix}");
106
107    // 回复表单抵消父评论缩进,让表单回到内容区左边缘,避免深层回复时被越挤越右。
108    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    // tiptap 编辑器挂载容器(顶层表单与多个回复表单并存,各自持有自己的元素)。
115    let editor_mount = NodeRef::<leptos::html::Div>::new();
116
117    // 编辑器实例句柄(WASM 端):持有 JS 实例与全部 closure,drop(含组件卸载时
118    // StoredValue 释放)时销毁。
119    #[cfg(target_arch = "wasm32")]
120    let editor_handle: StoredValue<Option<crate::bridges::tiptap::EditorHandle>, LocalStorage> =
121        StoredValue::new_local(None);
122    // 卸载时提前销毁编辑器:Owner 先跑 cleanup 再释放信号,destroy 触发的
123    // 上传 removed 回调仍能读 message;若等 StoredValue 随 arena 析构才 drop,
124    // 回调读已释放的信号会 panic。take 出锁外再 drop,回调不在锁内执行。
125    #[cfg(target_arch = "wasm32")]
126    on_cleanup(move || {
127        let _ = editor_handle.try_update_value(Option::take);
128    });
129
130    // 顶层表单:表单节点挂载后用 IntersectionObserver 等其接近视口再加载编辑器。
131    #[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    // 挂载/销毁编辑器。顶层表单接近视口时加载;回复表单随 active_reply 激活挂载、
177    // 取消时销毁(容器 div 随视图分支消失,JS 实例必须同步回收)。
178    // content_md 在组件存活期内持久,重挂载时经 set_markdown 回填(取消不丢草稿)。
179    // 挂载推迟到下一帧:保证激活后的表单 DOM 已提交,容器可被找到。
180    #[cfg(target_arch = "wasm32")]
181    {
182        Effect::new(move |_| {
183            if !is_active.get() {
184                // 隐藏:销毁已挂载的编辑器(handle drop → JS destroy + closure 释放)。
185                if editor_handle.with_value(|h| h.is_some()) {
186                    editor_handle.set_value(None);
187                }
188                return;
189            }
190            // 防重复挂载(effect 可能因订阅的信号多次触发)。
191            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                // 组件已卸载、已取消回复或已挂载:放弃本次挂载。
199                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                        // 失败时借表单 AlertBox 同步一行错误(编辑器内错误态为主,
224                        // 这里兜底可见性);成功/移除时若当前消息恰是上传错误则清除。
225                        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                // create 同步返回。
262                match crate::bridges::tiptap::create(&container, &opts) {
263                    Ok(inst) => {
264                        // 草稿回填:回复表单取消再激活时恢复 content_md。
265                        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        // 登录用户的身份字段由服务端从会话推导,表单值仅匿名路径使用。
299        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        // 图片上传未完成拦截:占位节点未落定前提交会丢图/留下 blob 半成品。
312        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        // 防御:markdown 中检出 blob 图片 URL(异常路径,如上传事件丢失)时拦截。
329        if content.contains("](blob:") {
330            message.set(Some((
331                "检测到未完成上传的图片,请处理后再发表".to_string(),
332                "error",
333            )));
334            return;
335        }
336
337        // 蜜罐被填充则直接丢弃
338        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                    // 同步清空编辑器文档(onUpdate 会把空 markdown 回写 signal)。
369                    #[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                // Ctrl/Cmd+Enter 提交:编辑器内按键事件冒泡到表单根(ProseMirror
427                // 不消费 Mod-Enter),身份输入栏同样生效。
428                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                    // 登录用户:身份行;访客的昵称、邮箱、网站放在编辑区下方。
455                    {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                    // 编辑区(WYSIWYG:tiptap comment variant)。SSR 输出空容器,
477                    // hydration 后由编辑器接管;min-height 与编辑器对齐避免 CLS。
478                    <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                    // 蜜罐字段:对普通用户隐藏,用于拦截简单机器人(仅匿名渲染;
494                    // 登录用户的身份由会话保证,服务端也跳过蜜罐校验)。
495                    {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                        // 左侧:图片上传(点击选择 / 直接粘贴图片到输入框)与提示
546                        <div class="comment-bar-start">
547                            // label + 隐藏 file input:点击天然触发文件选择对话框,无需 JS。
548                            {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                                                    // 与粘贴/拖放同一条 coordinator 占位上传路径。
569                                                    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                                                    // 重置 value:连续选择同一文件也能再次触发 change。
587                                                    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                        // 右侧:取消(若为回复)+ 提交按钮
622                        <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                            // 提交中或图片上传未完成(含失败态)均禁用:占位节点未落定前
636                            // 提交会丢图或残留 blob URL。
637                            <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}