Skip to main content

yggdrasil/bridges/
tiptap.rs

1//! Tiptap 编辑器的 wasm-bindgen 绑定层。
2//!
3//! 封装与 `window.TiptapEditor`(IIFE 暴露的全局对象)的全部交互,
4//! 替代旧版 `js_sys::eval` 字符串拼贴 + window 全局变量通信。
5//!
6//! wasm-bindgen extern 与 `EditorHandle`、上传 closure 等**仅在 WASM 前端**编译
7//! (server 构建无 `window`);共享的纯数据类型 `UploadsInFlight`/`UploadErrorEntry`
8//! 在两端都编译,供 `write.rs` 在 rsx 中渲染上传状态。
9
10/// 当前编辑器内进行中的上传计数(来自 onUploadEvent 的 counts 快照)。
11#[derive(Clone, Copy, Default)]
12pub struct UploadsInFlight {
13    pub uploading: u32,
14    pub error: u32,
15}
16
17/// 顶部堆叠的上传失败提示条目。
18#[derive(Clone, PartialEq)]
19pub struct UploadErrorEntry {
20    pub id: String,
21    pub file_name: String,
22    pub message: String,
23}
24
25// ============================================================================
26// 以下全部仅在 WASM 前端编译:wasm-bindgen extern + EditorHandle + 上传 closure。
27// 放在 #[cfg] 子模块内,避免 server 构建尝试编译引用 JS 对象的 extern。
28// ============================================================================
29#[cfg(target_arch = "wasm32")]
30pub mod wasm {
31    use super::{UploadErrorEntry, UploadsInFlight};
32    // WritableExt 提供 .write()(Signal 在 Copy 语义下不需要 mut 绑定)。
33    use dioxus::prelude::WritableExt;
34    use wasm_bindgen::prelude::*;
35    use wasm_bindgen::JsCast;
36
37    // —— window.TiptapEditor 模块对象 ——
38    //
39    // TiptapEditor 是 IIFE 产物挂在 window 上的模块对象(含 create 等方法),
40    // 不是函数。wasm-bindgen 对 `fn get_module() -> T` 形式的 extern 会生成
41    // `window.TiptapEditor()`(函数调用),会因"not a function"失败。
42    // 因此用 js_sys::Reflect::get 做属性访问拿到模块对象,再 dyn_into。
43    #[wasm_bindgen]
44    extern "C" {
45        /// `window.TiptapEditor` 模块对象的 Rust 映射(IIFE 产物挂在 window 上的对象字面量)。
46        /// 不是函数——通过 [`get_module`] 用 Reflect::get 取属性而非 extern fn 调用拿到。
47        pub type TiptapEditorModule;
48
49        /// 调用 `TiptapEditor.create(containerId, options)`。
50        /// 找不到容器返回 null(被 Option 捕获);构造失败抛异常(被 catch 捕获)。
51        #[wasm_bindgen(method, catch)]
52        pub fn create(
53            this: &TiptapEditorModule,
54            container_id: &str,
55            opts: &EditorOptions,
56        ) -> Result<Option<EditorInstance>, JsValue>;
57    }
58
59    /// 读取 `window.TiptapEditor`(IIFE 默认导出,顶层 var 即 window 属性)。
60    /// 用 Reflect::get 做属性访问——extern fn 形式会被 wasm-bindgen 编成函数调用。
61    ///
62    /// 用 unchecked_into 而非 dyn_into:TiptapEditor 是 JS 对象字面量,
63    /// 不是 wasm-bindgen 注册的构造函数实例,dyn_into 的 instanceof 检查必然失败。
64    /// unchecked_into 只做编译期类型标注,不做运行时校验(Reflect.get 已保证拿到的是目标对象)。
65    pub fn get_module() -> TiptapEditorModule {
66        // 缺 window:本函数只应在浏览器环境的 wasm32 前端调用,不应在其它上下文触发。
67        let window = web_sys::window().expect("no window: get_module 只能在浏览器 wasm32 前端调用");
68        // 调用方先等待 use_browser_library 就绪,再构造 Options 并调用 create。
69        let val = js_sys::Reflect::get(&window, &"TiptapEditor".into()).expect(
70            "window.TiptapEditor missing: /tiptap/editor.js 未加载,检查该静态资源是否随构建产物部署",
71        );
72        val.unchecked_into::<TiptapEditorModule>()
73    }
74
75    // —— 编辑器实例(TiptapEditorInstance)——
76    #[wasm_bindgen]
77    extern "C" {
78        /// `TiptapEditor.create` 返回的编辑器实例对象,承载 ProseMirror 编辑器与上传协调器。
79        pub type EditorInstance;
80
81        /// 富文本模式下返回 ProseMirror 的 Markdown;源码模式下返回 textarea 内容。
82        #[wasm_bindgen(method, js_name = getMarkdown)]
83        pub fn get_markdown(this: &EditorInstance) -> String;
84
85        /// 用 Markdown 内容回填编辑器(emitUpdate: false,不触发 onUpdate)。
86        #[wasm_bindgen(method, js_name = setMarkdown)]
87        pub fn set_markdown(this: &EditorInstance, content: &str);
88
89        /// 按 uploadId 删除上传节点(revoke blob + 删 pending)。供宿主"×关闭"调用。
90        #[wasm_bindgen(method, js_name = removeUploadByUploadId)]
91        pub fn remove_upload_by_upload_id(this: &EditorInstance, upload_id: &str) -> bool;
92
93        /// 工具栏图片按钮入口:把文件交给 coordinator 走占位符上传
94        /// (与粘贴/拖放同一条路径)。
95        #[wasm_bindgen(method, js_name = insertUploading)]
96        pub fn insert_uploading(this: &EditorInstance, file: web_sys::File);
97
98        /// 从素材库批量插入图片:`json` 为 `[{ "src", "alt"? }]` 串
99        /// (`AssetSelection` 的 serde 序列化形状)。JS 侧在 slash 命令删除
100        /// `/素材` 文本后停留的光标位置一次事务插入全部图片块;非法载荷 no-op。
101        #[wasm_bindgen(method, js_name = insertImagesFromLibrary)]
102        pub fn insert_images_from_library(this: &EditorInstance, json: &str);
103
104        /// 销毁编辑器,释放 JS 侧资源。
105        #[wasm_bindgen(method)]
106        pub fn destroy(this: &EditorInstance);
107    }
108
109    // —— EditorOptions:用 builder 模式(setter)构造 JS 对象 ——
110    #[wasm_bindgen]
111    extern "C" {
112        /// 传给 `TiptapEditor.create` 的配置对象,对应 JS 侧的 EditorOptions。
113        /// 用 `new()` 创建空对象后通过 setter 链式设置字段。
114        pub type EditorOptions;
115
116        /// 构造一个空的 EditorOptions,随后用各 setter 填充回调与占位文案。
117        #[wasm_bindgen(constructor)]
118        pub fn new() -> EditorOptions;
119
120        /// 编辑器无内容时的占位文案。
121        #[wasm_bindgen(method, setter, js_name = placeholder)]
122        pub fn set_placeholder(this: &EditorOptions, v: &str);
123
124        /// 编辑器变体:`"full"`(默认,后台文章编辑器)或 `"comment"`
125        /// (评论区精简子集:气泡菜单 + Placeholder,无标题/表格/斜杠命令等)。
126        #[wasm_bindgen(method, setter, js_name = variant)]
127        pub fn set_variant(this: &EditorOptions, v: &str);
128
129        /// 文档变更回调(ProseMirror transaction 提交时触发,参数为最新 Markdown)。
130        #[wasm_bindgen(method, setter, js_name = onUpdate)]
131        pub fn set_on_update(this: &EditorOptions, cb: &Closure<dyn FnMut(String)>);
132
133        /// JS 侧 onImageUpload: (file: File) => Promise<string>。
134        /// Rust closure 返回 js_sys::Promise,由 future_to_promise 包装。
135        #[wasm_bindgen(method, setter, js_name = onImageUpload)]
136        pub fn set_on_image_upload(
137            this: &EditorOptions,
138            cb: &Closure<dyn Fn(web_sys::File) -> js_sys::Promise>,
139        );
140
141        /// 编辑器就绪回调(init 末尾同步触发一次)。
142        #[wasm_bindgen(method, setter, js_name = onReady)]
143        pub fn set_on_ready(this: &EditorOptions, cb: &Closure<dyn FnMut()>);
144
145        /// 上传事件回调(coordinator.emit 时触发,携带 counts)。
146        #[wasm_bindgen(method, setter, js_name = onUploadEvent)]
147        pub fn set_on_upload_event(this: &EditorOptions, cb: &Closure<dyn FnMut(UploadEventJs)>);
148
149        /// JS 侧 onRunCode: (opts: RunCodeOptsJs) => Promise<string>。
150        /// Rust closure 返回 js_sys::Promise,内部调 start_exec + 轮询 get_exec_result。
151        #[wasm_bindgen(method, setter, js_name = onRunCode)]
152        pub fn set_on_run_code(
153            this: &EditorOptions,
154            cb: &Closure<dyn Fn(RunCodeOptsJs) -> js_sys::Promise>,
155        );
156
157        /// JS 侧 onPickFromLibrary: () => void。
158        /// slash 命令「素材库」触发(此时 /命令 文本已被 JS 删除、光标停在删除位置);
159        /// Rust 侧打开 AssetPickerModal,确认后由 insertImagesFromLibrary 回填。
160        #[wasm_bindgen(method, setter, js_name = onPickFromLibrary)]
161        pub fn set_on_pick_from_library(this: &EditorOptions, cb: &Closure<dyn FnMut()>);
162    }
163
164    // —— 上传事件(JS UploadEvent 的 Rust 映射)——
165    #[wasm_bindgen]
166    extern "C" {
167        /// 上传协调器 emit 的事件对象,对应 JS 侧 UploadCoordinator 发出的事件。
168        /// 由 `onUploadEvent` 回调回传 Rust,[`consume_upload_event`] 据其更新 UI 状态。
169        #[derive(Clone)]
170        pub type UploadEventJs;
171
172        /// 事件种类:`"uploading"` / `"success"` / `"error"` / `"removed"`。
173        #[wasm_bindgen(method, getter)]
174        pub fn kind(this: &UploadEventJs) -> String;
175
176        /// 本次上传的唯一标识(前端生成,用于关联上传节点与失败提示条目)。
177        #[wasm_bindgen(method, getter, js_name = uploadId)]
178        pub fn upload_id(this: &UploadEventJs) -> String;
179
180        /// 上传文件名(用于失败提示展示)。
181        #[wasm_bindgen(method, getter, js_name = fileName)]
182        pub fn file_name(this: &UploadEventJs) -> String;
183
184        /// 失败原因(仅 `error` 事件有值,成功/移除事件为 None)。
185        #[wasm_bindgen(method, getter, js_name = errorMsg)]
186        pub fn error_msg(this: &UploadEventJs) -> Option<String>;
187
188        /// 当前文档内全部上传节点的实时计数快照。
189        #[wasm_bindgen(method, getter)]
190        pub fn counts(this: &UploadEventJs) -> UploadCountsJs;
191    }
192
193    #[wasm_bindgen]
194    extern "C" {
195        /// 上传计数快照(JS 侧遍历文档节点统计后随事件下发)。
196        #[derive(Clone)]
197        pub type UploadCountsJs;
198
199        /// 进行中的上传数量。
200        #[wasm_bindgen(method, getter)]
201        pub fn uploading(this: &UploadCountsJs) -> u32;
202
203        /// 失败的上传数量。
204        #[wasm_bindgen(method, getter)]
205        pub fn error(this: &UploadCountsJs) -> u32;
206    }
207
208    // —— RunCodeOptsJs:onRunCode 回调参数(JS 侧传给 Rust 的纯数据对象)——
209    /// JS 侧传给 onRunCode 的参数对象,Rust 侧读取 getter。
210    /// language 是纯语言名(如 "python",前端已 extractLang 提取);overridesJson 是 overrides 的 JSON 字符串(可能为空)。
211    #[wasm_bindgen]
212    extern "C" {
213        pub type RunCodeOptsJs;
214
215        /// 纯语言名(前端 extractLang 从完整 info string 提取,如 "python")。
216        #[wasm_bindgen(method, getter)]
217        pub fn language(this: &RunCodeOptsJs) -> String;
218
219        /// 代码块文本内容。
220        #[wasm_bindgen(method, getter)]
221        pub fn source(this: &RunCodeOptsJs) -> String;
222
223        /// overrides 的 JSON 字符串(如 `{"timeout_secs":10}`),空串表示无 overrides。
224        /// 前端从 info string 提取(大括号部分),Rust 用 serde_json 反序列化。
225        #[wasm_bindgen(method, getter, js_name = overridesJson)]
226        pub fn overrides_json(this: &RunCodeOptsJs) -> String;
227    }
228
229    // —— EditorHandle:实例 + closure 统一生命周期 ——
230
231    /// 持有编辑器实例 + 其全部 closure,统一生命周期。
232    ///
233    /// drop 时先 destroy JS 实例(释放 ProseMirror 资源),随后 closure 字段
234    /// 按声明逆序自动 drop(释放 wasm-bindgen 回调表)。比 `Closure::forget`
235    /// (永久泄漏)干净——closure 严格随编辑器实例同生共死。
236    pub struct EditorHandle {
237        instance: EditorInstance,
238        _on_update: Closure<dyn FnMut(String)>,
239        _on_image_upload: Closure<dyn Fn(web_sys::File) -> js_sys::Promise>,
240        _on_ready: Closure<dyn FnMut()>,
241        _on_upload_event: Closure<dyn FnMut(UploadEventJs)>,
242        _on_run_code: Closure<dyn Fn(RunCodeOptsJs) -> js_sys::Promise>,
243        _on_pick_from_library: Closure<dyn FnMut()>,
244    }
245
246    impl EditorHandle {
247        /// 聚合编辑器实例与回调 closure,使其共用同一生命周期。
248        ///
249        /// 调用方负责先用各 setter 把 closure 装进 [`EditorOptions`]、`create` 出实例后,
250        /// 再把实例与这些 closure 一并交由本函数持有。返回的 [`EditorHandle`] 一旦
251        /// drop,会先 `destroy` 实例、再按字段逆序 drop closure。
252        pub fn new(
253            instance: EditorInstance,
254            on_update: Closure<dyn FnMut(String)>,
255            on_image_upload: Closure<dyn Fn(web_sys::File) -> js_sys::Promise>,
256            on_ready: Closure<dyn FnMut()>,
257            on_upload_event: Closure<dyn FnMut(UploadEventJs)>,
258            on_run_code: Closure<dyn Fn(RunCodeOptsJs) -> js_sys::Promise>,
259            on_pick_from_library: Closure<dyn FnMut()>,
260        ) -> Self {
261            Self {
262                instance,
263                _on_update: on_update,
264                _on_image_upload: on_image_upload,
265                _on_ready: on_ready,
266                _on_upload_event: on_upload_event,
267                _on_run_code: on_run_code,
268                _on_pick_from_library: on_pick_from_library,
269            }
270        }
271
272        /// 访问底层编辑器实例(调 getMarkdown/setMarkdown/destroy 等)。
273        pub fn instance(&self) -> &EditorInstance {
274            &self.instance
275        }
276
277        /// 评论编辑器用构造:只传入 comment variant 实际使用的 4 个 closure。
278        ///
279        /// run_code / pick_from_library 在 comment variant 下没有触发方
280        /// (无斜杠命令、无代码块 NodeView),以 no-op closure 补齐生命周期结构。
281        pub fn new_comment(
282            instance: EditorInstance,
283            on_update: Closure<dyn FnMut(String)>,
284            on_image_upload: Closure<dyn Fn(web_sys::File) -> js_sys::Promise>,
285            on_ready: Closure<dyn FnMut()>,
286            on_upload_event: Closure<dyn FnMut(UploadEventJs)>,
287        ) -> Self {
288            Self::new(
289                instance,
290                on_update,
291                on_image_upload,
292                on_ready,
293                on_upload_event,
294                Closure::new(|_opts: RunCodeOptsJs| js_sys::Promise::resolve(&JsValue::null())),
295                Closure::new(|| {}),
296            )
297        }
298    }
299
300    impl Drop for EditorHandle {
301        fn drop(&mut self) {
302            self.instance.destroy();
303        }
304    }
305
306    // —— consume_upload_event:纯逻辑 helper ——
307
308    /// 消费单个上传事件,更新 signal(即时驱动,替代旧版 500ms 轮询)。
309    ///
310    /// 逻辑与旧轮询 body 一致:
311    /// - error:新 id 追加提示,已存在 id 原地更新消息(重试后再失败)
312    /// - success/removed:移除对应提示
313    /// - counts:直接从事件读(JS 已遍历文档算好)
314    ///
315    /// 去重以 `upload_errors` Vec 自身为唯一数据源(用 iter().any 判重),
316    /// 不再额外维护 seen_error_ids,避免两份状态需手动同步。
317    pub fn consume_upload_event(
318        ev: &UploadEventJs,
319        mut uploads_in_flight: dioxus::prelude::Signal<UploadsInFlight>,
320        mut upload_errors: dioxus::prelude::Signal<Vec<UploadErrorEntry>>,
321    ) {
322        let id = ev.upload_id();
323        match ev.kind().as_str() {
324            "error" => {
325                let msg = ev.error_msg().unwrap_or_else(|| "上传失败".to_string());
326                // 已存在同 id(重试后再失败):原地更新消息;否则追加。
327                let mut errors = upload_errors.write();
328                if let Some(entry) = errors.iter_mut().find(|e| e.id == id) {
329                    entry.message = msg;
330                } else {
331                    errors.push(UploadErrorEntry {
332                        id: id.clone(),
333                        file_name: ev.file_name(),
334                        message: msg,
335                    });
336                }
337            }
338            "success" | "removed" => {
339                upload_errors.write().retain(|e| e.id != id);
340            }
341            _ => {}
342        }
343        // counts 直接从事件读(JS 已算好)
344        let c = ev.counts();
345        uploads_in_flight.set(UploadsInFlight {
346            uploading: c.uploading(),
347            error: c.error(),
348        });
349    }
350
351    // —— make_upload_closure:Rust fetch 上传 ——
352
353    /// 通用图片上传:multipart POST 指定端点,解析 {success, url, error}。
354    ///
355    /// 由封面图上传(spawn 直接 await)与 Tiptap 编辑器上传(make_upload_closure 包 Promise)共用;
356    /// fetch + 契约解析样板收敛在 [`crate::utils::web_upload::post_multipart_file`]
357    /// (与备份导入共享),此处只取 `url` 字段。
358    ///
359    /// 行为:
360    /// - credentials: same-origin(携带 session cookie)
361    /// - 字段名 'image'(与服务端 upload.rs 对齐)
362    /// - 成功 → Ok(url);失败 → Err(服务端中文 error,或状态码兜底)
363    ///
364    /// 构造阶段的错误以 Err 返回,而非 panic,
365    /// 单张坏文件不应导致整个上传流程崩溃。
366    async fn upload_file_to(url: &str, file: web_sys::File) -> Result<String, String> {
367        let data = crate::utils::web_upload::post_multipart_file(url, "image", &file).await?;
368        let url = data["url"].as_str().unwrap_or("");
369        if url.is_empty() {
370            // success=true 但 url 为空:服务端契约异常,按失败处理
371            return Err("上传成功但未返回图片地址".to_string());
372        }
373        Ok(url.to_string())
374    }
375
376    /// admin 图片上传(POST /api/upload,需 admin 会话)。
377    pub async fn upload_image_file(file: web_sys::File) -> Result<String, String> {
378        upload_file_to("/api/upload", file).await
379    }
380
381    /// 评论图片上传(POST /api/comments/upload,允许匿名,IP 双层限流)。
382    pub async fn upload_comment_image_file(file: web_sys::File) -> Result<String, String> {
383        upload_file_to("/api/comments/upload", file).await
384    }
385
386    /// 创建 Tiptap 图片上传 closure:内部复用 [`upload_image_file`],包装成 JS Promise。
387    ///
388    /// 返回的 closure 签名 `(File) -> Promise` 对应 JS `onImageUpload`。
389    pub fn make_upload_closure() -> Closure<dyn Fn(web_sys::File) -> js_sys::Promise> {
390        Closure::new(move |file: web_sys::File| -> js_sys::Promise {
391            wasm_bindgen_futures::future_to_promise(async move {
392                upload_image_file(file)
393                    .await
394                    .map(|url| js_sys::JsString::from(url).into())
395                    .map_err(|msg| js_sys::Error::new(&msg).into())
396            })
397        })
398    }
399
400    /// 创建评论区 Tiptap 图片上传 closure:走匿名可用的评论端点
401    /// ([`upload_comment_image_file`]),其余语义与 [`make_upload_closure`] 一致。
402    pub fn make_comment_upload_closure() -> Closure<dyn Fn(web_sys::File) -> js_sys::Promise> {
403        Closure::new(move |file: web_sys::File| -> js_sys::Promise {
404            wasm_bindgen_futures::future_to_promise(async move {
405                upload_comment_image_file(file)
406                    .await
407                    .map(|url| js_sys::JsString::from(url).into())
408                    .map_err(|msg| js_sys::Error::new(&msg).into())
409            })
410        })
411    }
412
413    // —— make_run_code_closure:编辑器内运行代码 ——
414
415    /// 把 ExecTask 格式化为结果字符串(供编辑器结果区展示)。
416    fn format_run_result(task: &crate::api::code_runner::ExecTask) -> String {
417        use crate::api::code_runner::ExecStatus;
418        let status_label = match task.status {
419            ExecStatus::Success => "Success",
420            ExecStatus::Error => "Error",
421            ExecStatus::Timeout => "Timeout",
422            ExecStatus::OomKilled => "OOM",
423            ExecStatus::Failed => "Failed",
424            _ => "Unknown",
425        };
426        match &task.result {
427            Some(res) => {
428                let mut out = format!("状态: {} · 耗时: {}ms", status_label, res.duration_ms);
429                if !res.stdout.is_empty() {
430                    out.push_str("\nStdout:\n");
431                    out.push_str(&res.stdout);
432                }
433                if !res.stderr.is_empty() {
434                    out.push_str("\nStderr:\n");
435                    out.push_str(&res.stderr);
436                }
437                out
438            }
439            None => format!("状态: {} · {}", status_label, task.stage),
440        }
441    }
442
443    /// 创建「编辑器内运行代码」closure:内部调 start_exec + 轮询 get_exec_result,
444    /// 把格式化结果字符串回传 JS。
445    ///
446    /// 返回的 closure 签名 `(RunCodeOptsJs) -> Promise` 对应 JS `onRunCode`。
447    /// JS 侧 NodeView await Promise,拿到字符串直接填进结果区 DOM。
448    ///
449    /// 注意:info string 的解析(提取语言名 + overrides JSON)在前端 extractLang 完成,
450    /// Rust 收到的 language 已是纯语言名(如 "python"),不依赖 server-only 的 languages 模块。
451    pub fn make_run_code_closure() -> Closure<dyn Fn(RunCodeOptsJs) -> js_sys::Promise> {
452        Closure::new(move |opts: RunCodeOptsJs| -> js_sys::Promise {
453            wasm_bindgen_futures::future_to_promise(async move {
454                use crate::api::code_runner::{execute, ExecRequest, ExecStatus};
455                use crate::infra::runner_config::ResourceLimits;
456
457                let language = opts.language();
458                let source = opts.source();
459                let overrides_json = opts.overrides_json();
460
461                // 反序列化 overrides JSON(前端已提取大括号部分;空串视为 None;
462                // 畸形 JSON 静默降级为无 overrides)。
463                let overrides = if overrides_json.trim().is_empty() {
464                    None
465                } else {
466                    serde_json::from_str::<ResourceLimits>(&overrides_json).ok()
467                };
468
469                let req = ExecRequest {
470                    language,
471                    source,
472                    overrides,
473                };
474
475                match execute::start_exec(req).await {
476                    Ok(task_id) => {
477                        let poll_interval = 500;
478                        // 500ms * 60 = 30s 上限(编辑器内运行是写作辅助,比 reader 的 120s 短)
479                        for _ in 0..60 {
480                            crate::utils::time::sleep_ms(poll_interval).await;
481                            match execute::get_exec_result(task_id.clone()).await {
482                                Ok(task) => {
483                                    let terminal = task.status != ExecStatus::Queued
484                                        && task.status != ExecStatus::Running;
485                                    if terminal {
486                                        let s = format_run_result(&task);
487                                        return Ok(js_sys::JsString::from(s).into());
488                                    }
489                                }
490                                Err(_) => {
491                                    return Err(js_sys::Error::new("结果获取异常").into());
492                                }
493                            }
494                        }
495                        Err(js_sys::Error::new("轮询超时,请重试").into())
496                    }
497                    Err(e) => Err(js_sys::Error::new(&e.to_string()).into()),
498                }
499            })
500        })
501    }
502}
503
504/// 将 WASM 子模块中的桥接类型与函数重导出到 crate 根,供 `write.rs` 直接引用。
505/// server 构建剥离该子模块,故此重导出仅对 WASM 前端生效。
506///
507/// 故意手工列名单,而非像 `codemirror`/`xterm` 那样 `pub use wasm::*;`:只导出调用方
508/// 需要按名字写出来的项(如 `on_upload_event` 闭包签名里的 `UploadEventJs`)。
509/// `TiptapEditorModule`/`EditorInstance`/`UploadCountsJs`/`RunCodeOptsJs`/
510/// `upload_comment_image_file` 故意不导出——它们全部只经方法链(`get_module().create(...)`、
511/// `handle.instance().xxx()`)或模块内部(`make_comment_upload_closure`)使用,调用方从不
512/// 需要在 crate 根按名字引用。新增 `pub fn`/`pub type` 供外部按名调用时记得同步加进这里。
513#[cfg(target_arch = "wasm32")]
514pub use wasm::{
515    consume_upload_event, get_module, make_comment_upload_closure, make_run_code_closure,
516    make_upload_closure, upload_image_file, EditorHandle, EditorOptions, UploadEventJs,
517};