yggdrasil/src/tiptap_bridge.rs
xfy 96da2f27d5 docs: 补齐 tiptap_bridge 文档注释并新增 make doc 目标
- tiptap_bridge.rs: 为 wasm-bindgen 桥接层全部 19 处 pub 项补文档
  (JS 类型映射、getter/setter、EditorHandle::new、pub use 重导出)
- markdown.rs: 用反引号包裹注释内字面量 <span>,消除 rustdoc 警告
- Makefile: 新增 doc / doc-open,纯 binary crate 需 --document-private-items
  才能让内部模块进文档
2026-06-25 18:26:56 +08:00

361 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<Option<EditorInstance>, JsValue>;
}
/// 读取 `window.TiptapEditor`IIFE 默认导出,顶层 var 即 window 属性)。
/// 用 Reflect::get 做属性访问——extern fn 形式会被 wasm-bindgen 编成函数调用。
///
/// 用 unchecked_into 而非 dyn_intoTiptapEditor 是 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::<TiptapEditorModule>()
}
// —— 编辑器实例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<dyn FnMut(String)>);
/// JS 侧 onImageUpload: (file: File) => Promise<string>。
/// 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<dyn Fn(web_sys::File) -> js_sys::Promise>,
);
/// 编辑器就绪回调init 末尾同步触发一次)。
#[wasm_bindgen(method, setter, js_name = onReady)]
pub fn set_on_ready(this: &EditorOptions, cb: &Closure<dyn FnMut()>);
/// 上传事件回调coordinator.emit 时触发,携带 counts
#[wasm_bindgen(method, setter, js_name = onUploadEvent)]
pub fn set_on_upload_event(this: &EditorOptions, cb: &Closure<dyn FnMut(UploadEventJs)>);
}
// —— 上传事件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<String>;
/// 当前文档内全部上传节点的实时计数快照。
#[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<dyn FnMut(String)>,
_on_image_upload: Closure<dyn Fn(web_sys::File) -> js_sys::Promise>,
_on_ready: Closure<dyn FnMut()>,
_on_upload_event: Closure<dyn FnMut(UploadEventJs)>,
}
impl EditorHandle {
/// 聚合编辑器实例与四个回调 closure使其共用同一生命周期。
///
/// 调用方负责先用各 setter 把 closure 装进 [`EditorOptions`]、`create` 出实例后,
/// 再把实例与这四个 closure 一并交由本函数持有。返回的 [`EditorHandle`] 一旦
/// drop会先 `destroy` 实例、再按字段逆序 drop closure。
pub fn new(
instance: EditorInstance,
on_update: Closure<dyn FnMut(String)>,
on_image_upload: Closure<dyn Fn(web_sys::File) -> js_sys::Promise>,
on_ready: Closure<dyn FnMut()>,
on_upload_event: Closure<dyn FnMut(UploadEventJs)>,
) -> 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<UploadsInFlight>,
mut upload_errors: dioxus::prelude::Signal<Vec<UploadErrorEntry>>,
) {
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_closureRust 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<String, String> {
// 构造 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非 OptionFormData: AsRef<JsValue>。
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<dyn Fn(web_sys::File) -> 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,
};