Skip to main content

yggdrasil/
tiptap_bridge.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        let window = web_sys::window().expect("no window");
67        let val = js_sys::Reflect::get(&window, &"TiptapEditor".into())
68            .expect("window.TiptapEditor missing");
69        val.unchecked_into::<TiptapEditorModule>()
70    }
71
72    // —— 编辑器实例(TiptapEditorInstance)——
73    #[wasm_bindgen]
74    extern "C" {
75        /// `TiptapEditor.create` 返回的编辑器实例对象,承载 ProseMirror 编辑器与上传协调器。
76        pub type EditorInstance;
77
78        /// 富文本模式下返回 ProseMirror 的 Markdown;源码模式下返回 textarea 内容。
79        #[wasm_bindgen(method, js_name = getMarkdown)]
80        pub fn get_markdown(this: &EditorInstance) -> String;
81
82        /// 用 Markdown 内容回填编辑器(emitUpdate: false,不触发 onUpdate)。
83        #[wasm_bindgen(method, js_name = setMarkdown)]
84        pub fn set_markdown(this: &EditorInstance, content: &str);
85
86        /// 按 uploadId 删除上传节点(revoke blob + 删 pending)。供宿主"×关闭"调用。
87        #[wasm_bindgen(method, js_name = removeUploadByUploadId)]
88        pub fn remove_upload_by_upload_id(this: &EditorInstance, upload_id: &str) -> bool;
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        /// 用 `new()` 创建空对象后通过 setter 链式设置字段。
100        pub type EditorOptions;
101
102        /// 构造一个空的 EditorOptions,随后用各 setter 填充回调与占位文案。
103        #[wasm_bindgen(constructor)]
104        pub fn new() -> EditorOptions;
105
106        /// 编辑器无内容时的占位文案。
107        #[wasm_bindgen(method, setter, js_name = placeholder)]
108        pub fn set_placeholder(this: &EditorOptions, v: &str);
109
110        /// 文档变更回调(ProseMirror transaction 提交时触发,参数为最新 Markdown)。
111        #[wasm_bindgen(method, setter, js_name = onUpdate)]
112        pub fn set_on_update(this: &EditorOptions, cb: &Closure<dyn FnMut(String)>);
113
114        /// JS 侧 onImageUpload: (file: File) => Promise<string>。
115        /// Rust closure 返回 js_sys::Promise,由 future_to_promise 包装。
116        #[wasm_bindgen(method, setter, js_name = onImageUpload)]
117        pub fn set_on_image_upload(
118            this: &EditorOptions,
119            cb: &Closure<dyn Fn(web_sys::File) -> js_sys::Promise>,
120        );
121
122        /// 编辑器就绪回调(init 末尾同步触发一次)。
123        #[wasm_bindgen(method, setter, js_name = onReady)]
124        pub fn set_on_ready(this: &EditorOptions, cb: &Closure<dyn FnMut()>);
125
126        /// 上传事件回调(coordinator.emit 时触发,携带 counts)。
127        #[wasm_bindgen(method, setter, js_name = onUploadEvent)]
128        pub fn set_on_upload_event(this: &EditorOptions, cb: &Closure<dyn FnMut(UploadEventJs)>);
129
130        /// JS 侧 onRunCode: (opts: RunCodeOptsJs) => Promise<string>。
131        /// Rust closure 返回 js_sys::Promise,内部调 start_exec + 轮询 get_exec_result。
132        #[wasm_bindgen(method, setter, js_name = onRunCode)]
133        pub fn set_on_run_code(
134            this: &EditorOptions,
135            cb: &Closure<dyn Fn(RunCodeOptsJs) -> js_sys::Promise>,
136        );
137    }
138
139    // —— 上传事件(JS UploadEvent 的 Rust 映射)——
140    #[wasm_bindgen]
141    extern "C" {
142        /// 上传协调器 emit 的事件对象,对应 JS 侧 UploadCoordinator 发出的事件。
143        /// 由 `onUploadEvent` 回调回传 Rust,[`consume_upload_event`] 据其更新 UI 状态。
144        #[derive(Clone)]
145        pub type UploadEventJs;
146
147        /// 事件种类:`"uploading"` / `"success"` / `"error"` / `"removed"`。
148        #[wasm_bindgen(method, getter)]
149        pub fn kind(this: &UploadEventJs) -> String;
150
151        /// 本次上传的唯一标识(前端生成,用于关联上传节点与失败提示条目)。
152        #[wasm_bindgen(method, getter, js_name = uploadId)]
153        pub fn upload_id(this: &UploadEventJs) -> String;
154
155        /// 上传文件名(用于失败提示展示)。
156        #[wasm_bindgen(method, getter, js_name = fileName)]
157        pub fn file_name(this: &UploadEventJs) -> String;
158
159        /// 失败原因(仅 `error` 事件有值,成功/移除事件为 None)。
160        #[wasm_bindgen(method, getter, js_name = errorMsg)]
161        pub fn error_msg(this: &UploadEventJs) -> Option<String>;
162
163        /// 当前文档内全部上传节点的实时计数快照。
164        #[wasm_bindgen(method, getter)]
165        pub fn counts(this: &UploadEventJs) -> UploadCountsJs;
166    }
167
168    #[wasm_bindgen]
169    extern "C" {
170        /// 上传计数快照(JS 侧遍历文档节点统计后随事件下发)。
171        #[derive(Clone)]
172        pub type UploadCountsJs;
173
174        /// 进行中的上传数量。
175        #[wasm_bindgen(method, getter)]
176        pub fn uploading(this: &UploadCountsJs) -> u32;
177
178        /// 失败的上传数量。
179        #[wasm_bindgen(method, getter)]
180        pub fn error(this: &UploadCountsJs) -> u32;
181    }
182
183    // —— RunCodeOptsJs:onRunCode 回调参数(JS 侧传给 Rust 的纯数据对象)——
184    /// JS 侧传给 onRunCode 的参数对象,Rust 侧读取 getter。
185    /// language 是纯语言名(如 "python",前端已 extractLang 提取);overridesJson 是 overrides 的 JSON 字符串(可能为空)。
186    #[wasm_bindgen]
187    extern "C" {
188        pub type RunCodeOptsJs;
189
190        /// 纯语言名(前端 extractLang 从完整 info string 提取,如 "python")。
191        #[wasm_bindgen(method, getter)]
192        pub fn language(this: &RunCodeOptsJs) -> String;
193
194        /// 代码块文本内容。
195        #[wasm_bindgen(method, getter)]
196        pub fn source(this: &RunCodeOptsJs) -> String;
197
198        /// overrides 的 JSON 字符串(如 `{"timeout_secs":10}`),空串表示无 overrides。
199        /// 前端从 info string 提取(大括号部分),Rust 用 serde_json 反序列化。
200        #[wasm_bindgen(method, getter, js_name = overridesJson)]
201        pub fn overrides_json(this: &RunCodeOptsJs) -> String;
202    }
203
204    // —— EditorHandle:实例 + closure 统一生命周期 ——
205
206    /// 持有编辑器实例 + 其全部 closure,统一生命周期。
207    ///
208    /// drop 时先 destroy JS 实例(释放 ProseMirror 资源),随后 closure 字段
209    /// 按声明逆序自动 drop(释放 wasm-bindgen 回调表)。比 `Closure::forget`
210    /// (永久泄漏)干净——closure 严格随编辑器实例同生共死。
211    pub struct EditorHandle {
212        instance: EditorInstance,
213        _on_update: Closure<dyn FnMut(String)>,
214        _on_image_upload: Closure<dyn Fn(web_sys::File) -> js_sys::Promise>,
215        _on_ready: Closure<dyn FnMut()>,
216        _on_upload_event: Closure<dyn FnMut(UploadEventJs)>,
217        _on_run_code: Closure<dyn Fn(RunCodeOptsJs) -> js_sys::Promise>,
218    }
219
220    impl EditorHandle {
221        /// 聚合编辑器实例与回调 closure,使其共用同一生命周期。
222        ///
223        /// 调用方负责先用各 setter 把 closure 装进 [`EditorOptions`]、`create` 出实例后,
224        /// 再把实例与这些 closure 一并交由本函数持有。返回的 [`EditorHandle`] 一旦
225        /// drop,会先 `destroy` 实例、再按字段逆序 drop closure。
226        pub fn new(
227            instance: EditorInstance,
228            on_update: Closure<dyn FnMut(String)>,
229            on_image_upload: Closure<dyn Fn(web_sys::File) -> js_sys::Promise>,
230            on_ready: Closure<dyn FnMut()>,
231            on_upload_event: Closure<dyn FnMut(UploadEventJs)>,
232            on_run_code: Closure<dyn Fn(RunCodeOptsJs) -> js_sys::Promise>,
233        ) -> Self {
234            Self {
235                instance,
236                _on_update: on_update,
237                _on_image_upload: on_image_upload,
238                _on_ready: on_ready,
239                _on_upload_event: on_upload_event,
240                _on_run_code: on_run_code,
241            }
242        }
243
244        /// 访问底层编辑器实例(调 getMarkdown/setMarkdown/destroy 等)。
245        pub fn instance(&self) -> &EditorInstance {
246            &self.instance
247        }
248    }
249
250    impl Drop for EditorHandle {
251        fn drop(&mut self) {
252            self.instance.destroy();
253        }
254    }
255
256    // —— consume_upload_event:纯逻辑 helper ——
257
258    /// 消费单个上传事件,更新 signal(即时驱动,替代旧版 500ms 轮询)。
259    ///
260    /// 逻辑与旧轮询 body 一致:
261    /// - error:新 id 追加提示,已存在 id 原地更新消息(重试后再失败)
262    /// - success/removed:移除对应提示
263    /// - counts:直接从事件读(JS 已遍历文档算好)
264    ///
265    /// 去重以 `upload_errors` Vec 自身为唯一数据源(用 iter().any 判重),
266    /// 不再额外维护 seen_error_ids,避免两份状态需手动同步。
267    pub fn consume_upload_event(
268        ev: &UploadEventJs,
269        mut uploads_in_flight: dioxus::prelude::Signal<UploadsInFlight>,
270        mut upload_errors: dioxus::prelude::Signal<Vec<UploadErrorEntry>>,
271    ) {
272        let id = ev.upload_id();
273        match ev.kind().as_str() {
274            "error" => {
275                let msg = ev.error_msg().unwrap_or_else(|| "上传失败".to_string());
276                // 已存在同 id(重试后再失败):原地更新消息;否则追加。
277                let mut errors = upload_errors.write();
278                if let Some(entry) = errors.iter_mut().find(|e| e.id == id) {
279                    entry.message = msg;
280                } else {
281                    errors.push(UploadErrorEntry {
282                        id: id.clone(),
283                        file_name: ev.file_name(),
284                        message: msg,
285                    });
286                }
287            }
288            "success" | "removed" => {
289                upload_errors.write().retain(|e| e.id != id);
290            }
291            _ => {}
292        }
293        // counts 直接从事件读(JS 已算好)
294        let c = ev.counts();
295        uploads_in_flight.set(UploadsInFlight {
296            uploading: c.uploading(),
297            error: c.error(),
298        });
299    }
300
301    // —— make_upload_closure:Rust fetch 上传 ——
302
303    /// 通用图片上传:FormData POST /api/upload,解析 {success, url, error}。
304    ///
305    /// 由封面图上传(spawn 直接 await)与 Tiptap 编辑器上传(make_upload_closure 包 Promise)共用,
306    /// 避免两处重复同一份 fetch + 解析逻辑。
307    ///
308    /// 行为:
309    /// - credentials: same-origin(携带 session cookie)
310    /// - 字段名 'image'(与服务端 upload.rs 对齐)
311    /// - 成功 → Ok(url);失败 → Err(服务端中文 error,或状态码兜底)
312    ///
313    /// 构造阶段的错误(FormData/Request 等)以 Err 返回,而非 panic,
314    /// 单张坏文件不应导致整个上传流程崩溃。
315    pub async fn upload_image_file(file: web_sys::File) -> Result<String, String> {
316        // 构造 FormData:字段名 'image' 与服务端 upload.rs 对齐
317        let form = web_sys::FormData::new().map_err(|_| "无法构造上传表单".to_string())?;
318        form.append_with_blob("image", &file)
319            .map_err(|_| "无法附加文件".to_string())?;
320
321        // 构造 POST 请求,credentials same-origin 携带 session cookie
322        let init = web_sys::RequestInit::new();
323        init.set_method("POST");
324        // set_body 接收 &JsValue(非 Option);FormData: AsRef<JsValue>。
325        init.set_body(form.as_ref());
326        init.set_credentials(web_sys::RequestCredentials::SameOrigin);
327
328        let request = web_sys::Request::new_with_str_and_init("/api/upload", &init)
329            .map_err(|_| "无法构造上传请求".to_string())?;
330
331        let window = web_sys::window().expect("no window");
332        let promise = window.fetch_with_request(&request);
333
334        // fetch Promise → Future → 解析响应体
335        let resp_val = wasm_bindgen_futures::JsFuture::from(promise)
336            .await
337            .map_err(|e| format!("上传请求失败: {:?}", e))?;
338        let resp: web_sys::Response = resp_val
339            .dyn_into()
340            .map_err(|_| "上传响应类型异常".to_string())?;
341
342        // 读响应体文本(无论 2xx 与否,服务端都返回 JSON)
343        let text_promise = resp.text().map_err(|e| format!("读取响应失败: {:?}", e))?;
344        let text_val = wasm_bindgen_futures::JsFuture::from(text_promise)
345            .await
346            .map_err(|e| format!("读取响应失败: {:?}", e))?;
347        let text = text_val.as_string().unwrap_or_default();
348
349        // 解析 {success, url, error}
350        let data: serde_json::Value =
351            serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);
352
353        if data["success"].as_bool() == Some(true) {
354            let url = data["url"].as_str().unwrap_or("");
355            if !url.is_empty() {
356                Ok(url.to_string())
357            } else {
358                // success=true 但 url 为空:服务端契约异常,按失败处理
359                Err("上传成功但未返回图片地址".to_string())
360            }
361        } else {
362            // 失败:优先用服务端中文 error,兜底用状态码
363            Err(data["error"]
364                .as_str()
365                .map(|s| s.to_string())
366                .unwrap_or_else(|| format!("上传失败: {}", resp.status())))
367        }
368    }
369
370    /// 创建 Tiptap 图片上传 closure:内部复用 [`upload_image_file`],包装成 JS Promise。
371    ///
372    /// 返回的 closure 签名 `(File) -> Promise` 对应 JS `onImageUpload`。
373    pub fn make_upload_closure() -> Closure<dyn Fn(web_sys::File) -> js_sys::Promise> {
374        Closure::new(move |file: web_sys::File| -> js_sys::Promise {
375            wasm_bindgen_futures::future_to_promise(async move {
376                upload_image_file(file)
377                    .await
378                    .map(|url| js_sys::JsString::from(url).into())
379                    .map_err(|msg| js_sys::Error::new(&msg).into())
380            })
381        })
382    }
383
384    // —— make_run_code_closure:编辑器内运行代码 ——
385
386    /// 把 ExecTask 格式化为结果字符串(供编辑器结果区展示)。
387    fn format_run_result(task: &crate::api::code_runner::ExecTask) -> String {
388        use crate::api::code_runner::ExecStatus;
389        let status_label = match task.status {
390            ExecStatus::Success => "Success",
391            ExecStatus::Error => "Error",
392            ExecStatus::Timeout => "Timeout",
393            ExecStatus::OomKilled => "OOM",
394            ExecStatus::Failed => "Failed",
395            _ => "Unknown",
396        };
397        match &task.result {
398            Some(res) => {
399                let mut out = format!("状态: {} · 耗时: {}ms", status_label, res.duration_ms);
400                if !res.stdout.is_empty() {
401                    out.push_str("\nStdout:\n");
402                    out.push_str(&res.stdout);
403                }
404                if !res.stderr.is_empty() {
405                    out.push_str("\nStderr:\n");
406                    out.push_str(&res.stderr);
407                }
408                out
409            }
410            None => format!("状态: {} · {}", status_label, task.stage),
411        }
412    }
413
414    /// 创建「编辑器内运行代码」closure:内部调 start_exec + 轮询 get_exec_result,
415    /// 把格式化结果字符串回传 JS。
416    ///
417    /// 返回的 closure 签名 `(RunCodeOptsJs) -> Promise` 对应 JS `onRunCode`。
418    /// JS 侧 NodeView await Promise,拿到字符串直接填进结果区 DOM。
419    ///
420    /// 注意:info string 的解析(提取语言名 + overrides JSON)在前端 extractLang 完成,
421    /// Rust 收到的 language 已是纯语言名(如 "python"),不依赖 server-only 的 languages 模块。
422    pub fn make_run_code_closure() -> Closure<dyn Fn(RunCodeOptsJs) -> js_sys::Promise> {
423        Closure::new(move |opts: RunCodeOptsJs| -> js_sys::Promise {
424            wasm_bindgen_futures::future_to_promise(async move {
425                use crate::api::code_runner::{execute, ExecRequest, ExecStatus};
426                use crate::infra::runner_config::ResourceLimits;
427
428                let language = opts.language();
429                let source = opts.source();
430                let overrides_json = opts.overrides_json();
431
432                // 反序列化 overrides JSON(前端已提取大括号部分;空串视为 None)
433                let overrides = if overrides_json.trim().is_empty() {
434                    None
435                } else {
436                    match serde_json::from_str::<ResourceLimits>(&overrides_json) {
437                        Ok(o) => Some(o),
438                        Err(_) => None, // 畸形 JSON 静默降级为无 overrides
439                    }
440                };
441
442                let req = ExecRequest {
443                    language,
444                    source,
445                    overrides,
446                };
447
448                match execute::start_exec(req).await {
449                    Ok(task_id) => {
450                        let poll_interval = 500;
451                        // 500ms * 60 = 30s 上限(编辑器内运行是写作辅助,比 reader 的 120s 短)
452                        for _ in 0..60 {
453                            crate::utils::time::sleep_ms(poll_interval).await;
454                            match execute::get_exec_result(task_id.clone()).await {
455                                Ok(task) => {
456                                    let terminal = task.status != ExecStatus::Queued
457                                        && task.status != ExecStatus::Running;
458                                    if terminal {
459                                        let s = format_run_result(&task);
460                                        return Ok(js_sys::JsString::from(s).into());
461                                    }
462                                }
463                                Err(_) => {
464                                    return Err(js_sys::Error::new("结果获取异常").into());
465                                }
466                            }
467                        }
468                        Err(js_sys::Error::new("轮询超时,请重试").into())
469                    }
470                    Err(e) => Err(js_sys::Error::new(&e.to_string()).into()),
471                }
472            })
473        })
474    }
475}
476
477/// 将 WASM 子模块中的桥接类型与函数重导出到 crate 根,供 `write.rs` 直接引用。
478/// server 构建剥离该子模块,故此重导出仅对 WASM 前端生效。
479#[cfg(target_arch = "wasm32")]
480pub use wasm::{
481    consume_upload_event, get_module, make_run_code_closure, make_upload_closure,
482    upload_image_file, EditorHandle, EditorOptions, UploadEventJs,
483};