//! Tiptap 编辑器的 wasm-bindgen 绑定层。 //! //! 封装与 `window.TiptapEditor`(IIFE 暴露的全局对象)的全部交互, //! 替代旧版 `js_sys::eval` 字符串拼贴 + window 全局变量通信。 //! //! wasm-bindgen extern 与 `EditorHandle`、上传 closure 等**仅在 WASM 前端**编译 //! (server 构建无 `window`);共享的纯数据类型 `UploadsInFlight`/`UploadErrorEntry` //! 在两端都编译,供 `write.rs` 在 rsx 中渲染上传状态。 /// 当前编辑器内进行中的上传计数(来自 onUploadEvent 的 counts 快照)。 #[derive(Clone, Copy, Default)] pub struct UploadsInFlight { pub uploading: u32, pub error: u32, } /// 顶部堆叠的上传失败提示条目。 #[derive(Clone, PartialEq)] pub struct UploadErrorEntry { pub id: String, pub file_name: String, pub message: String, } // ============================================================================ // 以下全部仅在 WASM 前端编译:wasm-bindgen extern + EditorHandle + 上传 closure。 // 放在 #[cfg] 子模块内,避免 server 构建尝试编译引用 JS 对象的 extern。 // ============================================================================ #[cfg(target_arch = "wasm32")] pub mod wasm { use super::{UploadErrorEntry, UploadsInFlight}; // WritableExt 提供 .write()(Signal 在 Copy 语义下不需要 mut 绑定)。 use dioxus::prelude::WritableExt; use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; // —— window.TiptapEditor 模块对象 —— // // TiptapEditor 是 IIFE 产物挂在 window 上的模块对象(含 create 等方法), // 不是函数。wasm-bindgen 对 `fn get_module() -> T` 形式的 extern 会生成 // `window.TiptapEditor()`(函数调用),会因"not a function"失败。 // 因此用 js_sys::Reflect::get 做属性访问拿到模块对象,再 dyn_into。 #[wasm_bindgen] extern "C" { /// `window.TiptapEditor` 模块对象的 Rust 映射(IIFE 产物挂在 window 上的对象字面量)。 /// 不是函数——通过 [`get_module`] 用 Reflect::get 取属性而非 extern fn 调用拿到。 pub type TiptapEditorModule; /// 调用 `TiptapEditor.create(containerId, options)`。 /// 找不到容器返回 null(被 Option 捕获);构造失败抛异常(被 catch 捕获)。 #[wasm_bindgen(method, catch)] pub fn create( this: &TiptapEditorModule, container_id: &str, opts: &EditorOptions, ) -> Result, JsValue>; } /// 读取 `window.TiptapEditor`(IIFE 默认导出,顶层 var 即 window 属性)。 /// 用 Reflect::get 做属性访问——extern fn 形式会被 wasm-bindgen 编成函数调用。 /// /// 用 unchecked_into 而非 dyn_into:TiptapEditor 是 JS 对象字面量, /// 不是 wasm-bindgen 注册的构造函数实例,dyn_into 的 instanceof 检查必然失败。 /// unchecked_into 只做编译期类型标注,不做运行时校验(Reflect.get 已保证拿到的是目标对象)。 pub fn get_module() -> TiptapEditorModule { let window = web_sys::window().expect("no window"); let val = js_sys::Reflect::get(&window, &"TiptapEditor".into()).expect("window.TiptapEditor missing"); val.unchecked_into::() } // —— 编辑器实例(TiptapEditorInstance)—— #[wasm_bindgen] extern "C" { /// `TiptapEditor.create` 返回的编辑器实例对象,承载 ProseMirror 编辑器与上传协调器。 pub type EditorInstance; /// 富文本模式下返回 ProseMirror 的 Markdown;源码模式下返回 textarea 内容。 #[wasm_bindgen(method, js_name = getMarkdown)] pub fn get_markdown(this: &EditorInstance) -> String; /// 用 Markdown 内容回填编辑器(emitUpdate: false,不触发 onUpdate)。 #[wasm_bindgen(method, js_name = setMarkdown)] pub fn set_markdown(this: &EditorInstance, content: &str); /// 按 uploadId 删除上传节点(revoke blob + 删 pending)。供宿主"×关闭"调用。 #[wasm_bindgen(method, js_name = removeUploadByUploadId)] pub fn remove_upload_by_upload_id(this: &EditorInstance, upload_id: &str) -> bool; /// 销毁编辑器,释放 JS 侧资源。 #[wasm_bindgen(method)] pub fn destroy(this: &EditorInstance); } // —— EditorOptions:用 builder 模式(setter)构造 JS 对象 —— #[wasm_bindgen] extern "C" { /// 传给 `TiptapEditor.create` 的配置对象,对应 JS 侧的 EditorOptions。 /// 用 `new()` 创建空对象后通过 setter 链式设置字段。 pub type EditorOptions; /// 构造一个空的 EditorOptions,随后用各 setter 填充回调与占位文案。 #[wasm_bindgen(constructor)] pub fn new() -> EditorOptions; /// 编辑器无内容时的占位文案。 #[wasm_bindgen(method, setter, js_name = placeholder)] pub fn set_placeholder(this: &EditorOptions, v: &str); /// 文档变更回调(ProseMirror transaction 提交时触发,参数为最新 Markdown)。 #[wasm_bindgen(method, setter, js_name = onUpdate)] pub fn set_on_update(this: &EditorOptions, cb: &Closure); /// JS 侧 onImageUpload: (file: File) => Promise。 /// Rust closure 返回 js_sys::Promise,由 future_to_promise 包装。 #[wasm_bindgen(method, setter, js_name = onImageUpload)] pub fn set_on_image_upload( this: &EditorOptions, cb: &Closure js_sys::Promise>, ); /// 编辑器就绪回调(init 末尾同步触发一次)。 #[wasm_bindgen(method, setter, js_name = onReady)] pub fn set_on_ready(this: &EditorOptions, cb: &Closure); /// 上传事件回调(coordinator.emit 时触发,携带 counts)。 #[wasm_bindgen(method, setter, js_name = onUploadEvent)] pub fn set_on_upload_event(this: &EditorOptions, cb: &Closure); } // —— 上传事件(JS UploadEvent 的 Rust 映射)—— #[wasm_bindgen] extern "C" { /// 上传协调器 emit 的事件对象,对应 JS 侧 UploadCoordinator 发出的事件。 /// 由 `onUploadEvent` 回调回传 Rust,[`consume_upload_event`] 据其更新 UI 状态。 #[derive(Clone)] pub type UploadEventJs; /// 事件种类:`"uploading"` / `"success"` / `"error"` / `"removed"`。 #[wasm_bindgen(method, getter)] pub fn kind(this: &UploadEventJs) -> String; /// 本次上传的唯一标识(前端生成,用于关联上传节点与失败提示条目)。 #[wasm_bindgen(method, getter, js_name = uploadId)] pub fn upload_id(this: &UploadEventJs) -> String; /// 上传文件名(用于失败提示展示)。 #[wasm_bindgen(method, getter, js_name = fileName)] pub fn file_name(this: &UploadEventJs) -> String; /// 失败原因(仅 `error` 事件有值,成功/移除事件为 None)。 #[wasm_bindgen(method, getter, js_name = errorMsg)] pub fn error_msg(this: &UploadEventJs) -> Option; /// 当前文档内全部上传节点的实时计数快照。 #[wasm_bindgen(method, getter)] pub fn counts(this: &UploadEventJs) -> UploadCountsJs; } #[wasm_bindgen] extern "C" { /// 上传计数快照(JS 侧遍历文档节点统计后随事件下发)。 #[derive(Clone)] pub type UploadCountsJs; /// 进行中的上传数量。 #[wasm_bindgen(method, getter)] pub fn uploading(this: &UploadCountsJs) -> u32; /// 失败的上传数量。 #[wasm_bindgen(method, getter)] pub fn error(this: &UploadCountsJs) -> u32; } // —— EditorHandle:实例 + closure 统一生命周期 —— /// 持有编辑器实例 + 其全部 closure,统一生命周期。 /// /// drop 时先 destroy JS 实例(释放 ProseMirror 资源),随后 closure 字段 /// 按声明逆序自动 drop(释放 wasm-bindgen 回调表)。比 `Closure::forget` /// (永久泄漏)干净——closure 严格随编辑器实例同生共死。 pub struct EditorHandle { instance: EditorInstance, _on_update: Closure, _on_image_upload: Closure js_sys::Promise>, _on_ready: Closure, _on_upload_event: Closure, } impl EditorHandle { /// 聚合编辑器实例与四个回调 closure,使其共用同一生命周期。 /// /// 调用方负责先用各 setter 把 closure 装进 [`EditorOptions`]、`create` 出实例后, /// 再把实例与这四个 closure 一并交由本函数持有。返回的 [`EditorHandle`] 一旦 /// drop,会先 `destroy` 实例、再按字段逆序 drop closure。 pub fn new( instance: EditorInstance, on_update: Closure, on_image_upload: Closure js_sys::Promise>, on_ready: Closure, on_upload_event: Closure, ) -> Self { Self { instance, _on_update: on_update, _on_image_upload: on_image_upload, _on_ready: on_ready, _on_upload_event: on_upload_event, } } /// 访问底层编辑器实例(调 getMarkdown/setMarkdown/destroy 等)。 pub fn instance(&self) -> &EditorInstance { &self.instance } } impl Drop for EditorHandle { fn drop(&mut self) { self.instance.destroy(); } } // —— consume_upload_event:纯逻辑 helper —— /// 消费单个上传事件,更新 signal(即时驱动,替代旧版 500ms 轮询)。 /// /// 逻辑与旧轮询 body 一致: /// - error:新 id 追加提示,已存在 id 原地更新消息(重试后再失败) /// - success/removed:移除对应提示 /// - counts:直接从事件读(JS 已遍历文档算好) /// /// 去重以 `upload_errors` Vec 自身为唯一数据源(用 iter().any 判重), /// 不再额外维护 seen_error_ids,避免两份状态需手动同步。 pub fn consume_upload_event( ev: &UploadEventJs, mut uploads_in_flight: dioxus::prelude::Signal, mut upload_errors: dioxus::prelude::Signal>, ) { let id = ev.upload_id(); match ev.kind().as_str() { "error" => { let msg = ev.error_msg().unwrap_or_else(|| "上传失败".to_string()); // 已存在同 id(重试后再失败):原地更新消息;否则追加。 let mut errors = upload_errors.write(); if let Some(entry) = errors.iter_mut().find(|e| e.id == id) { entry.message = msg; } else { errors.push(UploadErrorEntry { id: id.clone(), file_name: ev.file_name(), message: msg, }); } } "success" | "removed" => { upload_errors.write().retain(|e| e.id != id); } _ => {} } // counts 直接从事件读(JS 已算好) let c = ev.counts(); uploads_in_flight.set(UploadsInFlight { uploading: c.uploading(), error: c.error(), }); } // —— make_upload_closure:Rust fetch 上传 —— /// 通用图片上传:FormData POST /api/upload,解析 {success, url, error}。 /// /// 由封面图上传(spawn 直接 await)与 Tiptap 编辑器上传(make_upload_closure 包 Promise)共用, /// 避免两处重复同一份 fetch + 解析逻辑。 /// /// 行为: /// - credentials: same-origin(携带 session cookie) /// - 字段名 'image'(与服务端 upload.rs 对齐) /// - 成功 → Ok(url);失败 → Err(服务端中文 error,或状态码兜底) /// /// 构造阶段的错误(FormData/Request 等)以 Err 返回,而非 panic, /// 单张坏文件不应导致整个上传流程崩溃。 pub async fn upload_image_file(file: web_sys::File) -> Result { // 构造 FormData:字段名 'image' 与服务端 upload.rs 对齐 let form = web_sys::FormData::new().map_err(|_| "无法构造上传表单".to_string())?; form.append_with_blob("image", &file) .map_err(|_| "无法附加文件".to_string())?; // 构造 POST 请求,credentials same-origin 携带 session cookie let init = web_sys::RequestInit::new(); init.set_method("POST"); // set_body 接收 &JsValue(非 Option);FormData: AsRef。 init.set_body(form.as_ref()); init.set_credentials(web_sys::RequestCredentials::SameOrigin); let request = web_sys::Request::new_with_str_and_init("/api/upload", &init) .map_err(|_| "无法构造上传请求".to_string())?; let window = web_sys::window().expect("no window"); let promise = window.fetch_with_request(&request); // fetch Promise → Future → 解析响应体 let resp_val = wasm_bindgen_futures::JsFuture::from(promise) .await .map_err(|e| format!("上传请求失败: {:?}", e))?; let resp: web_sys::Response = resp_val .dyn_into() .map_err(|_| "上传响应类型异常".to_string())?; // 读响应体文本(无论 2xx 与否,服务端都返回 JSON) let text_promise = resp .text() .map_err(|e| format!("读取响应失败: {:?}", e))?; let text_val = wasm_bindgen_futures::JsFuture::from(text_promise) .await .map_err(|e| format!("读取响应失败: {:?}", e))?; let text = text_val.as_string().unwrap_or_default(); // 解析 {success, url, error} let data: serde_json::Value = serde_json::from_str(&text).unwrap_or(serde_json::Value::Null); if data["success"].as_bool() == Some(true) { let url = data["url"].as_str().unwrap_or(""); if !url.is_empty() { Ok(url.to_string()) } else { // success=true 但 url 为空:服务端契约异常,按失败处理 Err("上传成功但未返回图片地址".to_string()) } } else { // 失败:优先用服务端中文 error,兜底用状态码 Err(data["error"] .as_str() .map(|s| s.to_string()) .unwrap_or_else(|| format!("上传失败: {}", resp.status()))) } } /// 创建 Tiptap 图片上传 closure:内部复用 [`upload_image_file`],包装成 JS Promise。 /// /// 返回的 closure 签名 `(File) -> Promise` 对应 JS `onImageUpload`。 pub fn make_upload_closure() -> Closure js_sys::Promise> { Closure::new(move |file: web_sys::File| -> js_sys::Promise { wasm_bindgen_futures::future_to_promise(async move { upload_image_file(file) .await .map(|url| js_sys::JsString::from(url).into()) .map_err(|msg| js_sys::Error::new(&msg).into()) }) }) } } /// 将 WASM 子模块中的桥接类型与函数重导出到 crate 根,供 `write.rs` 直接引用。 /// server 构建剥离该子模块,故此重导出仅对 WASM 前端生效。 #[cfg(target_arch = "wasm32")] pub use wasm::{ consume_upload_event, make_upload_closure, upload_image_file, EditorHandle, EditorOptions, UploadEventJs, get_module, };