diff --git a/libs/tiptap-editor/src/index.ts b/libs/tiptap-editor/src/index.ts index 997668b..ed7d11c 100644 --- a/libs/tiptap-editor/src/index.ts +++ b/libs/tiptap-editor/src/index.ts @@ -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) } diff --git a/src/main.rs b/src/main.rs index 51a812d..206f42f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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; diff --git a/src/pages/admin/write.rs b/src/pages/admin/write.rs index dde1cc7..be78dd1 100644 --- a/src/pages/admin/write.rs +++ b/src/pages/admin/write.rs @@ -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, - 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, -} - -#[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) -> Element { // 编辑模式:用于暂存从服务端加载的文章数据。 let mut edit_post = use_signal(|| None::); + // WASM 前端:编辑器实例句柄(持有实例 + closure,统一生命周期)。 + #[cfg(target_arch = "wasm32")] + let mut editor: Signal> = 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> = 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> = use_signal(std::collections::HashSet::new); @@ -158,218 +129,99 @@ fn write_editor(post_id: Option) -> 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; + } + // 防重复 init(effect 可能多次触发) + 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 —— + // 用 FnMut:Dioxus 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::() { - 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"))] - { + }); + + // 编辑器就绪后解除 loading(onReady 回调设 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::() { - 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::(&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, - }); - } - } - } - } - }); - } + // 非 WASM(server 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) -> 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) -> 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); } diff --git a/src/tiptap_bridge.rs b/src/tiptap_bridge.rs index fe034d9..5b3b674 100644 --- a/src/tiptap_bridge.rs +++ b/src/tiptap_bridge.rs @@ -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, 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); - - /// 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" { - #[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; - - #[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, - _on_image_upload: Closure js_sys::Promise>, - _on_ready: Closure, - _on_upload_event: Closure, -} + // —— 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, 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); + + /// 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" { + #[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; + + #[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, - 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, + _on_update: Closure, + _on_image_upload: Closure js_sys::Promise>, + _on_ready: Closure, + _on_upload_event: Closure, + } + + impl EditorHandle { + 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 } } - /// 访问底层编辑器实例(调 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, - upload_errors: &mut dioxus::prelude::Signal>, - seen_error_ids: &mut dioxus::prelude::Signal>, -) { - 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, + upload_errors: &mut dioxus::prelude::Signal>, + seen_error_ids: &mut dioxus::prelude::Signal>, + ) { + 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_closure:Rust fetch 上传 —— - -/// 创建图片上传 closure:FormData 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 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_closure:Rust fetch 上传 —— + + /// 创建图片上传 closure:FormData 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 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, +};