yggdrasil/src/tiptap_bridge.rs
xfy 8f6c864f22 chore(tiptap): 移除未使用的 RunCodeOptsJs 重导出
RunCodeOptsJs 只在 mod wasm 内部用作 Closure 类型参数,无外部消费者,
从 pub use 重导出列表移除以消除 unused_imports warning。
2026-07-07 18:34:17 +08:00

484 lines
22 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 侧 onRunCode: (opts: RunCodeOptsJs) => Promise<string>。
/// Rust closure 返回 js_sys::Promise内部调 start_exec + 轮询 get_exec_result。
#[wasm_bindgen(method, setter, js_name = onRunCode)]
pub fn set_on_run_code(
this: &EditorOptions,
cb: &Closure<dyn Fn(RunCodeOptsJs) -> js_sys::Promise>,
);
}
// —— 上传事件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;
}
// —— RunCodeOptsJsonRunCode 回调参数JS 侧传给 Rust 的纯数据对象)——
/// JS 侧传给 onRunCode 的参数对象Rust 侧读取 getter。
/// language 是纯语言名(如 "python",前端已 extractLang 提取overridesJson 是 overrides 的 JSON 字符串(可能为空)。
#[wasm_bindgen]
extern "C" {
pub type RunCodeOptsJs;
/// 纯语言名(前端 extractLang 从完整 info string 提取,如 "python")。
#[wasm_bindgen(method, getter)]
pub fn language(this: &RunCodeOptsJs) -> String;
/// 代码块文本内容。
#[wasm_bindgen(method, getter)]
pub fn source(this: &RunCodeOptsJs) -> String;
/// overrides 的 JSON 字符串(如 `{"timeout_secs":10}`),空串表示无 overrides。
/// 前端从 info string 提取大括号部分Rust 用 serde_json 反序列化。
#[wasm_bindgen(method, getter, js_name = overridesJson)]
pub fn overrides_json(this: &RunCodeOptsJs) -> String;
}
// —— 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)>,
_on_run_code: Closure<dyn Fn(RunCodeOptsJs) -> js_sys::Promise>,
}
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)>,
on_run_code: Closure<dyn Fn(RunCodeOptsJs) -> js_sys::Promise>,
) -> Self {
Self {
instance,
_on_update: on_update,
_on_image_upload: on_image_upload,
_on_ready: on_ready,
_on_upload_event: on_upload_event,
_on_run_code: on_run_code,
}
}
/// 访问底层编辑器实例(调 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())
})
})
}
// —— make_run_code_closure编辑器内运行代码 ——
/// 把 ExecTask 格式化为结果字符串(供编辑器结果区展示)。
fn format_run_result(task: &crate::api::code_runner::ExecTask) -> String {
use crate::api::code_runner::ExecStatus;
let status_label = match task.status {
ExecStatus::Success => "Success",
ExecStatus::Error => "Error",
ExecStatus::Timeout => "Timeout",
ExecStatus::OomKilled => "OOM",
ExecStatus::Failed => "Failed",
_ => "Unknown",
};
match &task.result {
Some(res) => {
let mut out = format!("状态: {} · 耗时: {}ms", status_label, res.duration_ms);
if !res.stdout.is_empty() {
out.push_str("\nStdout:\n");
out.push_str(&res.stdout);
}
if !res.stderr.is_empty() {
out.push_str("\nStderr:\n");
out.push_str(&res.stderr);
}
out
}
None => format!("状态: {} · {}", status_label, task.stage),
}
}
/// 创建「编辑器内运行代码」closure内部调 start_exec + 轮询 get_exec_result
/// 把格式化结果字符串回传 JS。
///
/// 返回的 closure 签名 `(RunCodeOptsJs) -> Promise` 对应 JS `onRunCode`。
/// JS 侧 NodeView await Promise拿到字符串直接填进结果区 DOM。
///
/// 注意info string 的解析(提取语言名 + overrides JSON在前端 extractLang 完成,
/// Rust 收到的 language 已是纯语言名(如 "python"),不依赖 server-only 的 languages 模块。
pub fn make_run_code_closure() -> Closure<dyn Fn(RunCodeOptsJs) -> js_sys::Promise> {
Closure::new(move |opts: RunCodeOptsJs| -> js_sys::Promise {
wasm_bindgen_futures::future_to_promise(async move {
use crate::api::code_runner::{execute, ExecRequest, ExecStatus};
use crate::infra::runner_config::ResourceLimits;
let language = opts.language();
let source = opts.source();
let overrides_json = opts.overrides_json();
// 反序列化 overrides JSON前端已提取大括号部分空串视为 None
let overrides = if overrides_json.trim().is_empty() {
None
} else {
match serde_json::from_str::<ResourceLimits>(&overrides_json) {
Ok(o) => Some(o),
Err(_) => None, // 畸形 JSON 静默降级为无 overrides
}
};
let req = ExecRequest {
language,
source,
overrides,
};
match execute::start_exec(req).await {
Ok(task_id) => {
let poll_interval = 500;
// 500ms * 60 = 30s 上限(编辑器内运行是写作辅助,比 reader 的 120s 短)
for _ in 0..60 {
crate::utils::time::sleep_ms(poll_interval).await;
match execute::get_exec_result(task_id.clone()).await {
Ok(task) => {
let terminal = task.status != ExecStatus::Queued
&& task.status != ExecStatus::Running;
if terminal {
let s = format_run_result(&task);
return Ok(js_sys::JsString::from(s).into());
}
}
Err(_) => {
return Err(js_sys::Error::new("结果获取异常").into());
}
}
}
Err(js_sys::Error::new("轮询超时,请重试").into())
}
Err(e) => Err(js_sys::Error::new(&e.to_string()).into()),
}
})
})
}
}
/// 将 WASM 子模块中的桥接类型与函数重导出到 crate 根,供 `write.rs` 直接引用。
/// server 构建剥离该子模块,故此重导出仅对 WASM 前端生效。
#[cfg(target_arch = "wasm32")]
pub use wasm::{
consume_upload_event, get_module, make_run_code_closure, make_upload_closure,
upload_image_file, EditorHandle, EditorOptions, UploadEventJs,
};