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