refactor(write): replace all js_sys::eval with tiptap_bridge bindings

write.rs changes (Tasks 7-11):
- use_drop: EditorHandle::drop replaces eval destroy + global reset (#1)
- init use_effect: closure-driven bridge.create replaces eval initEditor
  script + 100ms ready polling (#2/#3/#4/#5)
- getMarkdown: read via EditorHandle instead of eval (#8)
- delete upload polling use_future: driven by onUploadEvent closure (#6/#7)
- removeUploadByUploadId: call via EditorHandle instead of eval (#9)

Bridge fixes during integration:
- UploadCountsJs: drop Copy derive (wasm_bindgen types can't be Copy)
- split module: shared types compile on both targets, wasm externs gated
- add WritableExt/Readable/Writable trait imports for .write()/.set()
- use FnMut closures (Signal write/set take &mut self)
- consume_upload_event takes &mut Signal params

Also: remove stale window.__tiptap_content in index.ts source-mode handler.
This commit is contained in:
xfy 2026-06-23 11:25:06 +08:00
parent 84eea3272a
commit a3896c24a5
4 changed files with 373 additions and 515 deletions

View File

@ -61,8 +61,7 @@ class TiptapEditorInstance {
this.sourceTextarea.placeholder = '在此输入 Markdown 源码...'
this.sourceTextarea.spellcheck = false
this.sourceTextarea.addEventListener('input', () => {
// 保持全局缓存与 onUpdate 回调一致
window.__tiptap_content = this.sourceTextarea!.value
// 源码模式下通过 onUpdate 回调同步内容(替代旧版 window.__tiptap_content 缓存)
if (this.options.onUpdate) {
this.options.onUpdate(this.sourceTextarea!.value)
}

View File

@ -28,8 +28,8 @@ mod router;
mod ssr_cache;
mod tasks;
mod theme;
// tiptap_bridge 仅在 WASM 前端编译;封装与 TiptapEditor 的 wasm-bindgen 绑定。
#[cfg(target_arch = "wasm32")]
// tiptap_bridge共享类型UploadsInFlight/UploadErrorEntry两端都编译
// wasm-bindgen extern 与 EditorHandle 在内部的 #[cfg(wasm32)] 子模块里。
mod tiptap_bridge;
mod utils;
mod webp;

View File

@ -1,64 +1,28 @@
//! 文章编辑器页面。
//!
//! 提供新建文章与编辑文章两种模式,使用基于 Tiptap 的富文本编辑器。
//! 编辑器通过 `js_sys::eval` 在 WASM 前端初始化,并与 `window.TiptapEditor` 实例交互
//! 实现 Markdown 内容回填、图片上传与组件卸载时的清理。
//! 编辑器通过 [`crate::tiptap_bridge`] 的 wasm-bindgen 绑定在 WASM 前端初始化
//! 并与 `window.TiptapEditor` 实例交互,实现 Markdown 内容回填、图片上传与组件卸载时的清理。
use dioxus::prelude::*;
// 仅在 WASM 前端使用的类型转换与文章 API。
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::JsCast;
#[cfg(target_arch = "wasm32")]
use crate::api::posts::{
create_post, get_post_by_id, update_post, CreatePostResponse, SinglePostResponse,
};
#[cfg(target_arch = "wasm32")]
use crate::tiptap_bridge::{consume_upload_event, EditorHandle};
// 共享上传状态类型两端都编译rsx 在 server SSR 时也要渲染这些结构)。
use crate::tiptap_bridge::{UploadErrorEntry, UploadsInFlight};
use crate::components::write_skeleton::WriteSkeleton;
use crate::models::post::Post;
use crate::router::Route;
/// 当前编辑器内进行中的上传计数(来自轮询 window.__tiptap_uploads.counts
#[derive(Clone, Copy, Default)]
struct UploadsInFlight {
uploading: u32,
error: u32,
}
/// 顶部堆叠的上传失败提示条目。
#[derive(Clone, PartialEq)]
struct UploadErrorEntry {
id: String,
file_name: String,
message: String,
}
/// window.__tiptap_uploads 快照(轮询消费用)。
#[cfg(target_arch = "wasm32")]
#[derive(serde::Deserialize)]
struct UploadSnapshot {
events: Vec<UploadSnapshotEvent>,
counts: UploadSnapshotCounts,
}
use wasm_bindgen::closure::Closure;
#[cfg(target_arch = "wasm32")]
#[derive(serde::Deserialize)]
struct UploadSnapshotEvent {
kind: String,
#[serde(rename = "uploadId")]
upload_id: String,
#[serde(rename = "fileName")]
file_name: String,
#[serde(rename = "errorMsg")]
error_msg: Option<String>,
}
#[cfg(target_arch = "wasm32")]
#[derive(serde::Deserialize, Default, Clone, Copy)]
struct UploadSnapshotCounts {
uploading: u32,
error: u32,
}
#[allow(unused_imports)]
use dioxus::prelude::*;
/// 新建文章页面组件。
///
@ -110,12 +74,19 @@ fn write_editor(post_id: Option<i32>) -> Element {
// 编辑模式:用于暂存从服务端加载的文章数据。
let mut edit_post = use_signal(|| None::<Post>);
// WASM 前端:编辑器实例句柄(持有实例 + closure统一生命周期
#[cfg(target_arch = "wasm32")]
let mut editor: Signal<Option<EditorHandle>> = use_signal(|| None);
// WASM 前端编辑器就绪标志onReady 回调驱动,替代 __tiptap_ready 轮询)。
#[cfg(target_arch = "wasm32")]
let mut ready = use_signal(|| false);
// 上传状态:当前进行中计数(保存拦截)+ 顶部失败提示堆叠(用户手动关闭)
let mut uploads_in_flight = use_signal(UploadsInFlight::default);
let mut upload_errors: Signal<Vec<UploadErrorEntry>> = use_signal(Vec::new);
// 已展示过错误的上传 id去重 + 重试后再失败时原地更新)。
// 用 signal 而非局部变量use_future 的 FnMut 闭包要求可重复调用,
// signal 是 Copy 可被多次 move 进 async。
// 用 signal 而非局部变量:closure 的 FnMut 闭包要求可重复调用,
// signal 是 Copy 可被多次 move
let mut seen_error_ids: Signal<std::collections::HashSet<String>> =
use_signal(std::collections::HashSet::new);
@ -158,218 +129,99 @@ fn write_editor(post_id: Option<i32>) -> Element {
}
});
// 组件卸载时清理 Tiptap 实例:销毁编辑器、删除实例映射、重置全局就绪标志与内容缓存
// 组件卸载时清理 Tiptap 实例:EditorHandle::drop 会 destroy 编辑器并释放全部 closure
#[cfg(target_arch = "wasm32")]
use_drop(move || {
let _ = js_sys::eval(
r#"
(function() {
var editor = window.TiptapEditor && window.TiptapEditor._instances && window.TiptapEditor._instances.get('tiptap-editor');
if (editor && typeof editor.destroy === 'function') {
editor.destroy();
}
if (window.TiptapEditor && window.TiptapEditor._instances) {
window.TiptapEditor._instances.delete('tiptap-editor');
}
window.__tiptap_ready = false;
window.__tiptap_content = '';
})();
"#,
);
editor.set(None);
});
// Tiptap 编辑器初始化:在 WASM 前端通过 js_sys::eval 调用 public/tiptap/ 构建产物暴露的 window.TiptapEditor。
// 编辑模式需等待文章数据加载完成,避免空内容覆盖后续回填。
// Tiptap 编辑器初始化:构造 closure + EditorOptions调用 bridge.create。
// 替代旧版 eval initEditor 脚本 + 100ms 就绪轮询。
// use_effect 在首次渲染后跑,此时 #tiptap-editor 容器已挂载。
#[cfg(target_arch = "wasm32")]
use_effect(move || {
#[cfg(target_arch = "wasm32")]
{
// 编辑模式:等数据加载完再初始化。
if is_edit && edit_post().is_none() {
return;
}
// 执行 JS 初始化脚本:查找 DOM 容器,调用 TiptapEditor.create并注册 onUpdate / onImageUpload 回调。
let _ = js_sys::eval(
r#"
(function initEditor() {
if (window.__tiptap_ready) return;
var container = document.getElementById('tiptap-editor');
if (!container) {
setTimeout(initEditor, 50);
return;
}
if (typeof window.TiptapEditor !== 'undefined' && window.TiptapEditor) {
try {
window.TiptapEditor.create('tiptap-editor', {
content: '',
placeholder: '...',
onUpdate: function(markdown) {
window.__tiptap_content = markdown;
},
onImageUpload: function(file) {
return new Promise(function(resolve, reject) {
var formData = new FormData();
formData.append('image', file);
fetch('/api/upload', {
method: 'POST',
body: formData,
credentials: 'same-origin'
})
.then(function(response) {
if (!response.ok) {
// 读取服务端返回的中文错误({"success":false,"error":"文件超过大小限制"}
return response.json().catch(function() { return null; }).then(function(data) {
if (data && data.error) {
throw new Error(data.error);
}
throw new Error(': ' + response.status);
});
}
return response.json();
})
.then(function(data) {
if (data.success && data.url) {
resolve(data.url);
} else {
reject(new Error(data.error || 'Upload failed'));
}
})
.catch(function(err) {
reject(err);
});
});
}
});
window.__tiptap_ready = true;
} catch(e) {
console.error('[tiptap] create error: ' + e.message);
}
return;
}
setTimeout(initEditor, 50);
})();
"#,
);
// 编辑模式:等数据加载完再初始化(避免空内容覆盖回填)
if is_edit && edit_post().is_none() {
return;
}
// 防重复 initeffect 可能多次触发)
if editor.read().is_some() {
return;
}
});
// 轮询 Tiptap 编辑器就绪状态:最多等待约 10 秒,就绪后编辑模式回填 Markdown最后结束加载。
use_effect(move || {
#[cfg(target_arch = "wasm32")]
{
// 编辑模式:等数据加载完再开始轮询。
if is_edit && edit_post().is_none() {
return;
// —— 构造 closure ——
// 用 FnMutDioxus Signal 的 write/set 接收 &mut self回调需可变借用捕获的 signal。
let on_update = Closure::new({
let mut content = content;
move |md: String| content.set(md)
});
let on_ready = Closure::new({
let mut ready = ready;
move || ready.set(true)
});
let on_image_upload = crate::tiptap_bridge::make_upload_closure();
let on_upload_event = Closure::new({
let mut uploads_in_flight = uploads_in_flight;
let mut upload_errors = upload_errors;
let mut seen_error_ids = seen_error_ids;
move |ev: crate::tiptap_bridge::UploadEventJs| {
consume_upload_event(&ev, &mut uploads_in_flight, &mut upload_errors, &mut seen_error_ids);
}
});
wasm_bindgen_futures::spawn_local(async move {
for i in 0..100 {
if let Ok(promise_val) = js_sys::eval("new Promise(r => setTimeout(r, 100))") {
if let Ok(promise) = promise_val.dyn_into::<js_sys::Promise>() {
let _ = wasm_bindgen_futures::JsFuture::from(promise).await;
}
}
if let Ok(ready) = js_sys::eval("window.__tiptap_ready") {
if ready.as_bool().unwrap_or(false) {
// 编辑模式:通过 window.TiptapEditor 实例的 setMarkdown 回填已有内容。
if is_edit && !editor_content_set() {
let md = content();
if !md.is_empty() {
let md_json = serde_json::to_string(&md).unwrap_or_default();
let script = format!(
"(function() {{ var editor = window.TiptapEditor && window.TiptapEditor._instances && window.TiptapEditor._instances.get('tiptap-editor'); if (editor) {{ editor.setMarkdown({}); }} }})()",
md_json
);
let _ = js_sys::eval(&script);
}
editor_content_set.set(true);
}
loading.set(false);
return;
}
// —— 构造 options ——
let opts = crate::tiptap_bridge::EditorOptions::new();
opts.set_placeholder("在此输入内容...");
opts.set_on_update(&on_update);
opts.set_on_ready(&on_ready);
opts.set_on_image_upload(&on_image_upload);
opts.set_on_upload_event(&on_upload_event);
// —— create同步返回找不到容器返回 None构造失败抛异常——
match crate::tiptap_bridge::get_module().create("tiptap-editor", &opts) {
Ok(Some(inst)) => {
// 编辑模式回填create 成功立即回填(实例已创建,时机确定)
if is_edit && !editor_content_set() {
let md = content();
if !md.is_empty() {
inst.set_markdown(&md);
}
editor_content_set.set(true);
}
let handle = EditorHandle::new(
inst,
on_update,
on_image_upload,
on_ready,
on_upload_event,
);
editor.set(Some(handle));
}
Ok(None) => {
load_error.set(Some("编辑器容器未就绪".to_string()));
loading.set(false);
});
}
Err(e) => {
load_error.set(Some(format!("编辑器初始化错误: {:?}", e)));
loading.set(false);
}
}
#[cfg(not(target_arch = "wasm32"))]
{
});
// 编辑器就绪后解除 loadingonReady 回调设 ready=true
// 独立 effect 避免 on_ready 闭包与 loading 写权限在同一作用域冲突。
#[cfg(target_arch = "wasm32")]
use_effect(move || {
if ready() {
loading.set(false);
}
});
// 轮询 window.__tiptap_uploads消费上传事件并更新 signal仅 WASM
#[cfg(target_arch = "wasm32")]
{
use_future(move || {
let mut uploads_in_flight = uploads_in_flight;
let mut upload_errors = upload_errors;
let mut seen_error_ids = seen_error_ids;
async move {
loop {
// 500ms 间隔
if let Ok(promise_val) = js_sys::eval("new Promise(r => setTimeout(r, 500))") {
if let Ok(promise) = promise_val.dyn_into::<js_sys::Promise>() {
let _ = wasm_bindgen_futures::JsFuture::from(promise).await;
}
}
// 读取并清空 events读取 counts
let snapshot = js_sys::eval(
r#"
(function() {
var u = window.__tiptap_uploads;
if (!u) return null;
var events = u.events || [];
u.events = [];
return JSON.stringify({ events: events, counts: u.counts || {uploading:0,error:0} });
})()
"#,
)
.ok()
.and_then(|v| v.as_string());
if let Some(json) = snapshot {
if let Ok(parsed) = serde_json::from_str::<UploadSnapshot>(&json) {
for ev in parsed.events {
match ev.kind.as_str() {
"error" => {
let msg = ev.error_msg.unwrap_or_else(|| "上传失败".to_string());
if seen_error_ids.write().insert(ev.upload_id.clone()) {
// 新失败:追加提示
upload_errors.write().push(UploadErrorEntry {
id: ev.upload_id,
file_name: ev.file_name,
message: msg,
});
} else {
// 已存在的 id重试后再失败原地更新消息
//
let mut errors = upload_errors.write();
if let Some(entry) = errors.iter_mut().find(|e| e.id == ev.upload_id) {
entry.message = msg;
}
}
}
"success" | "removed" => {
seen_error_ids.write().remove(&ev.upload_id);
upload_errors.write().retain(|e| e.id != ev.upload_id);
}
_ => {}
}
}
uploads_in_flight.set(UploadsInFlight {
uploading: parsed.counts.uploading,
error: parsed.counts.error,
});
}
}
}
}
});
}
// 非 WASMserver SSR无编辑器直接解除 loading。
#[cfg(not(target_arch = "wasm32"))]
use_effect(move || {
loading.set(false);
});
// 提交表单:校验标题与内容,读取 Tiptap 编辑器 Markdown调用 create_post 或 update_post。
let on_submit = move |_| {
@ -395,13 +247,12 @@ fn write_editor(post_id: Option<i32>) -> Element {
// 仅在 WASM 前端读取编辑器内容并发起保存请求。
#[cfg(target_arch = "wasm32")]
{
// 优先通过 window.TiptapEditor 实例读取 Markdown否则退回到全局缓存。
let md = js_sys::eval(r#"
(function() {
var editor = window.TiptapEditor && window.TiptapEditor._instances && window.TiptapEditor._instances.get('tiptap-editor');
return editor ? editor.getMarkdown() : (window.__tiptap_content || '');
})()
"#).ok().and_then(|v| v.as_string()).unwrap_or_default();
// 通过 EditorHandle 实例读取 Markdown句柄未就绪时退回 content signal。
let md = if let Some(handle) = &*editor.read() {
handle.instance().get_markdown()
} else {
content()
};
// 兜底:扫描残留的上传占位符标记(轮询窗口期漏判防护)
// 检测 ![](blob:...) 形式的泄漏图片 src而非裸 "blob:" 字符串,
@ -631,11 +482,8 @@ fn write_editor(post_id: Option<i32>) -> Element {
move |_| {
// 关闭提示同时删除编辑器内失败占位符(避免孤儿)
#[cfg(target_arch = "wasm32")]
{
let _ = js_sys::eval(&format!(
"(function(){{var e=window.TiptapEditor&&window.TiptapEditor._instances&&window.TiptapEditor._instances.get('tiptap-editor');if(e&&e.removeUploadByUploadId){{e.removeUploadByUploadId({:?});}}}})()",
id
));
if let Some(handle) = &*editor.read() {
handle.instance().remove_upload_by_upload_id(&id);
}
upload_errors.write().retain(|e| e.id != id);
}

View File

@ -3,121 +3,9 @@
//! 封装与 `window.TiptapEditor`IIFE 暴露的全局对象)的全部交互,
//! 替代旧版 `js_sys::eval` 字符串拼贴 + window 全局变量通信。
//!
//! 全模块仅在 WASM 前端使用(`#[cfg(target_arch = "wasm32")]`
//! 因为 server 构建里无 `window` 对象。
#![cfg(target_arch = "wasm32")]
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
// —— window.TiptapEditor 模块对象 ——
#[wasm_bindgen]
extern "C" {
pub type TiptapEditorModule;
/// 读取 `window.TiptapEditor`IIFE 默认导出,顶层 var 即 window 属性)。
#[wasm_bindgen(js_namespace = window, js_name = TiptapEditor)]
pub fn get_module() -> 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>;
}
// —— 编辑器实例TiptapEditorInstance——
#[wasm_bindgen]
extern "C" {
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" {
pub type EditorOptions;
#[wasm_bindgen(constructor)]
pub fn new() -> EditorOptions;
#[wasm_bindgen(method, setter, js_name = placeholder)]
pub fn set_placeholder(this: &EditorOptions, v: &str);
#[wasm_bindgen(method, setter, js_name = onUpdate)]
pub fn set_on_update(this: &EditorOptions, cb: &Closure<dyn Fn(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 Fn()>);
/// 上传事件回调coordinator.emit 时触发,携带 counts
#[wasm_bindgen(method, setter, js_name = onUploadEvent)]
pub fn set_on_upload_event(this: &EditorOptions, cb: &Closure<dyn Fn(UploadEventJs)>);
}
// —— 上传事件JS UploadEvent 的 Rust 映射)——
#[wasm_bindgen]
extern "C" {
#[derive(Clone)]
pub type UploadEventJs;
#[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;
#[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" {
#[derive(Clone, Copy)]
pub type UploadCountsJs;
#[wasm_bindgen(method, getter)]
pub fn uploading(this: &UploadCountsJs) -> u32;
#[wasm_bindgen(method, getter)]
pub fn error(this: &UploadCountsJs) -> u32;
}
// —— 共享类型(供 write.rs 与本模块内部使用)——
//! wasm-bindgen extern 与 `EditorHandle`、上传 closure 等**仅在 WASM 前端**编译
//! server 构建无 `window`);共享的纯数据类型 `UploadsInFlight`/`UploadErrorEntry`
//! 在两端都编译,供 `write.rs` 在 rsx 中渲染上传状态。
/// 当前编辑器内进行中的上传计数(来自 onUploadEvent 的 counts 快照)。
#[derive(Clone, Copy, Default)]
@ -134,159 +22,282 @@ pub struct UploadErrorEntry {
pub message: String,
}
// —— EditorHandle实例 + closure 统一生命周期 ——
// ============================================================================
// 以下全部仅在 WASM 前端编译wasm-bindgen extern + EditorHandle + 上传 closure。
// 放在 #[cfg] 子模块内,避免 server 构建尝试编译引用 JS 对象的 extern。
// ============================================================================
#[cfg(target_arch = "wasm32")]
pub mod wasm {
use super::{UploadErrorEntry, UploadsInFlight};
// WritableExt 提供 .write()Writable 提供 .set()。Signal 方法需 trait 在作用域内。
use dioxus::prelude::{Readable, Writable, WritableExt};
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
/// 持有编辑器实例 + 其全部 closure统一生命周期。
///
/// drop 时先 destroy JS 实例(释放 ProseMirror 资源),再 drop closure
/// (释放 wasm-bindgen 回调表)。比 `Closure::forget`(永久泄漏)干净——
/// closure 严格随编辑器实例同生共死。
///
/// 字段顺序:`instance` 在前仅表示逻辑主从Drop::drop 里显式调 destroy
/// 之后 closure 字段按声明逆序自动 drop无顺序敏感问题。
pub struct EditorHandle {
instance: EditorInstance,
_on_update: Closure<dyn Fn(String)>,
_on_image_upload: Closure<dyn Fn(web_sys::File) -> js_sys::Promise>,
_on_ready: Closure<dyn Fn()>,
_on_upload_event: Closure<dyn Fn(UploadEventJs)>,
}
// —— window.TiptapEditor 模块对象 ——
#[wasm_bindgen]
extern "C" {
pub type TiptapEditorModule;
impl EditorHandle {
pub fn new(
/// 读取 `window.TiptapEditor`IIFE 默认导出,顶层 var 即 window 属性)。
#[wasm_bindgen(js_namespace = window, js_name = TiptapEditor)]
pub fn get_module() -> 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>;
}
// —— 编辑器实例TiptapEditorInstance——
#[wasm_bindgen]
extern "C" {
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" {
pub type EditorOptions;
#[wasm_bindgen(constructor)]
pub fn new() -> EditorOptions;
#[wasm_bindgen(method, setter, js_name = placeholder)]
pub fn set_placeholder(this: &EditorOptions, v: &str);
#[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" {
#[derive(Clone)]
pub type UploadEventJs;
#[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;
#[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" {
#[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 资源),再 drop closure
/// (释放 wasm-bindgen 回调表)。比 `Closure::forget`(永久泄漏)干净——
/// closure 严格随编辑器实例同生共死。
///
/// 字段顺序:`instance` 在前仅表示逻辑主从Drop::drop 里显式调 destroy
/// 之后 closure 字段按声明逆序自动 drop无顺序敏感问题。
pub struct EditorHandle {
instance: EditorInstance,
on_update: Closure<dyn Fn(String)>,
on_image_upload: Closure<dyn Fn(web_sys::File) -> js_sys::Promise>,
on_ready: Closure<dyn Fn()>,
on_upload_event: Closure<dyn Fn(UploadEventJs)>,
) -> Self {
Self {
instance,
_on_update: on_update,
_on_image_upload: on_image_upload,
_on_ready: on_ready,
_on_upload_event: on_upload_event,
_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 {
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
}
}
/// 访问底层编辑器实例(调 getMarkdown/setMarkdown/destroy 等)。
pub fn instance(&self) -> &EditorInstance {
&self.instance
impl Drop for EditorHandle {
fn drop(&mut self) {
self.instance.destroy();
// closure 字段随后自动 drop逆序on_upload_event → on_ready → on_image_upload → on_update
}
}
}
impl Drop for EditorHandle {
fn drop(&mut self) {
self.instance.destroy();
// closure 字段随后自动 drop逆序on_upload_event → on_ready → on_image_upload → on_update
}
}
// —— consume_upload_event纯逻辑 helper ——
// —— consume_upload_event纯逻辑 helper ——
/// 消费单个上传事件,更新 signal即时驱动替代旧版 500ms 轮询)。
///
/// 逻辑与旧轮询 body 一致:
/// - error新 id 追加提示,已存在 id 原地更新消息(重试后再失败)
/// - success/removed移除对应提示 + 清 seen
/// - counts直接从事件读JS 已遍历文档算好)
#[allow(clippy::too_many_arguments)]
pub fn consume_upload_event(
ev: &UploadEventJs,
uploads_in_flight: &mut dioxus::prelude::Signal<UploadsInFlight>,
upload_errors: &mut dioxus::prelude::Signal<Vec<UploadErrorEntry>>,
seen_error_ids: &mut dioxus::prelude::Signal<std::collections::HashSet<String>>,
) {
let id = ev.upload_id();
match ev.kind().as_str() {
"error" => {
let msg = ev.error_msg().unwrap_or_else(|| "上传失败".to_string());
if seen_error_ids.write().insert(id.clone()) {
// 新失败:追加提示
upload_errors.write().push(UploadErrorEntry {
id: id.clone(),
file_name: ev.file_name(),
message: msg,
});
} else {
// 已存在的 id重试后再失败原地更新消息
let mut errors = upload_errors.write();
if let Some(entry) = errors.iter_mut().find(|e| e.id == id) {
entry.message = msg;
/// 消费单个上传事件,更新 signal即时驱动替代旧版 500ms 轮询)。
///
/// 逻辑与旧轮询 body 一致:
/// - error新 id 追加提示,已存在 id 原地更新消息(重试后再失败)
/// - success/removed移除对应提示 + 清 seen
/// - counts直接从事件读JS 已遍历文档算好)
pub fn consume_upload_event(
ev: &UploadEventJs,
uploads_in_flight: &mut dioxus::prelude::Signal<UploadsInFlight>,
upload_errors: &mut dioxus::prelude::Signal<Vec<UploadErrorEntry>>,
seen_error_ids: &mut dioxus::prelude::Signal<std::collections::HashSet<String>>,
) {
let id = ev.upload_id();
match ev.kind().as_str() {
"error" => {
let msg = ev.error_msg().unwrap_or_else(|| "上传失败".to_string());
if seen_error_ids.write().insert(id.clone()) {
// 新失败:追加提示
upload_errors.write().push(UploadErrorEntry {
id: id.clone(),
file_name: ev.file_name(),
message: msg,
});
} else {
// 已存在的 id重试后再失败原地更新消息
let mut errors = upload_errors.write();
if let Some(entry) = errors.iter_mut().find(|e| e.id == id) {
entry.message = msg;
}
}
}
}
"success" | "removed" => {
seen_error_ids.write().remove(&id);
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 上传 ——
/// 创建图片上传 closureFormData POST /api/upload解析 {success, url, error}。
///
/// 行为与旧版 write.rs eval 注入的 JS fetch 等价:
/// - credentials: same-origin携带 session cookie
/// - 字段名 'image'(与服务端 upload.rs 对齐)
/// - 成功 → resolve(url);失败 → reject(Error(服务端中文 error))
///
/// 返回的 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 {
// 构造 FormData字段名 'image' 与服务端 upload.rs 对齐
let form =
web_sys::FormData::new().expect("FormData::new failed");
form.append_with_blob("image", &file)
.expect("FormData.append failed");
// 构造 POST 请求credentials same-origin 携带 session cookie
let mut init = web_sys::RequestInit::new();
init.method("POST");
init.body(Some(&form));
init.credentials(web_sys::RequestCredentials::SameOrigin);
let request = web_sys::Request::new_with_str_and_init("/api/upload", &init)
.expect("Request::new failed");
let window = web_sys::window().expect("no window");
let promise = window.fetch_with_request(&request);
// 把 fetch Promise → Future → 解析响应体 → 再包回 Promise
wasm_bindgen_futures::future_to_promise(async move {
let resp_val = wasm_bindgen_futures::JsFuture::from(promise).await?;
let resp: web_sys::Response = resp_val.dyn_into()?;
// 读响应体文本(无论 2xx 与否,服务端都返回 JSON
let text_promise = resp
.text()
.map_err(|e| js_sys::Error::new(&format!("读取响应失败: {:?}", e)))?;
let text_val = wasm_bindgen_futures::JsFuture::from(text_promise).await?;
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("").to_string();
Ok(js_sys::JsString::from(url).into())
} else {
// 失败:优先用服务端中文 error兜底用状态码
let msg = data["error"]
.as_str()
.map(|s| s.to_string())
.unwrap_or_else(|| format!("上传失败: {}", resp.status()));
Err(js_sys::Error::new(&msg).into())
"success" | "removed" => {
seen_error_ids.write().remove(&id);
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 上传 ——
/// 创建图片上传 closureFormData POST /api/upload解析 {success, url, error}。
///
/// 行为与旧版 write.rs eval 注入的 JS fetch 等价:
/// - credentials: same-origin携带 session cookie
/// - 字段名 'image'(与服务端 upload.rs 对齐)
/// - 成功 → resolve(url);失败 → reject(Error(服务端中文 error))
///
/// 返回的 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 {
// 构造 FormData字段名 'image' 与服务端 upload.rs 对齐
let form = web_sys::FormData::new().expect("FormData::new failed");
form.append_with_blob("image", &file)
.expect("FormData.append failed");
// 构造 POST 请求credentials same-origin 携带 session cookie
let mut init = web_sys::RequestInit::new();
init.method("POST");
init.body(Some(&form));
init.credentials(web_sys::RequestCredentials::SameOrigin);
let request = web_sys::Request::new_with_str_and_init("/api/upload", &init)
.expect("Request::new failed");
let window = web_sys::window().expect("no window");
let promise = window.fetch_with_request(&request);
// 把 fetch Promise → Future → 解析响应体 → 再包回 Promise
wasm_bindgen_futures::future_to_promise(async move {
let resp_val = wasm_bindgen_futures::JsFuture::from(promise).await?;
let resp: web_sys::Response = resp_val.dyn_into()?;
// 读响应体文本(无论 2xx 与否,服务端都返回 JSON
let text_promise = resp
.text()
.map_err(|e| js_sys::Error::new(&format!("读取响应失败: {:?}", e)))?;
let text_val = wasm_bindgen_futures::JsFuture::from(text_promise).await?;
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("").to_string();
Ok(js_sys::JsString::from(url).into())
} else {
// 失败:优先用服务端中文 error兜底用状态码
let msg = data["error"]
.as_str()
.map(|s| s.to_string())
.unwrap_or_else(|| format!("上传失败: {}", resp.status()));
Err(js_sys::Error::new(&msg).into())
}
})
})
})
}
}
#[cfg(target_arch = "wasm32")]
pub use wasm::{
consume_upload_event, make_upload_closure, EditorHandle, EditorInstance, EditorOptions,
TiptapEditorModule, UploadEventJs, get_module,
};