diff --git a/AGENTS.md b/AGENTS.md index 0091512..c4d2019 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,7 +122,7 @@ Rich-text editor in `libs/tiptap-editor/`, built as an IIFE library exposing `wi - Output: `public/tiptap/` - `make build` runs `npm install && npx vite build` inside `libs/tiptap-editor` -- `src/pages/admin/write.rs` initializes via `js_sys::eval`, polls `window.__tiptap_ready` +- `src/pages/admin/write.rs` initializes via `src/tiptap_bridge.rs` (wasm-bindgen bindings): injects `Closure` callbacks (`onUpdate`/`onReady`/`onUploadEvent`/`onImageUpload`) into `TiptapEditor.create`, holds the instance + closures in `EditorHandle` (Drop calls `destroy()`). No `js_sys::eval`, no `window` globals, no polling. Do not edit `public/tiptap/` — they are build artifacts. diff --git a/Cargo.toml b/Cargo.toml index f628ecd..6ff18e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,7 @@ futures = { version = "0.3", optional = true } bytes = { version = "1", optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] -web-sys = { version = "0.3", features = ["Document", "Window", "Storage", "Element", "HtmlElement", "DomTokenList", "MediaQueryList", "HtmlImageElement", "MouseEvent", "KeyboardEvent", "Node", "EventTarget", "Navigator"] } +web-sys = { version = "0.3", features = ["Document", "Window", "Storage", "Element", "HtmlElement", "DomTokenList", "MediaQueryList", "HtmlImageElement", "MouseEvent", "KeyboardEvent", "Node", "EventTarget", "Navigator", "File", "FormData", "Request", "RequestInit", "Response", "Headers"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" diff --git a/libs/tiptap-editor/src/index.ts b/libs/tiptap-editor/src/index.ts index 5d67c19..ed7d11c 100644 --- a/libs/tiptap-editor/src/index.ts +++ b/libs/tiptap-editor/src/index.ts @@ -5,7 +5,7 @@ import { TableKit } from '@tiptap/extension-table' import { TaskList, TaskItem } from '@tiptap/extension-list' import { FileHandler } from '@tiptap/extension-file-handler' import { SlashCommand } from './slash-command' -import { UploadCoordinator } from './upload-coordinator' +import { UploadCoordinator, type UploadEvent } from './upload-coordinator' import { UploadImage, setUploadCoordinator } from './upload-image' import './style.css' @@ -16,8 +16,12 @@ export interface EditorOptions { onFocus?: () => void onBlur?: () => void editable?: boolean - // 新增:图片上传回调 + // 图片上传回调 onImageUpload?: (file: File) => Promise + // 编辑器实例创建完成(含 coordinator)后同步触发一次,替代 window.__tiptap_ready 轮询 + onReady?: () => void + // 上传状态事件(error/success/removed + counts),替代 window.__tiptap_uploads 轮询 + onUploadEvent?: (event: UploadEvent) => void } class TiptapEditorInstance { @@ -57,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) } @@ -129,10 +132,18 @@ class TiptapEditorInstance { }) // 创建上传协调器,注入给 NodeView 的 onRetry/onRemove + // onUploadEvent 透传给 coordinator.emit,未提供时空操作兜底 if (this.options.onImageUpload) { - this.coordinator = new UploadCoordinator(this.editor, this.options.onImageUpload) + this.coordinator = new UploadCoordinator( + this.editor, + this.options.onImageUpload, + this.options.onUploadEvent ?? (() => {}), + ) setUploadCoordinator(this.coordinator) } + + // 通知宿主编辑器已就绪(替代 window.__tiptap_ready 轮询) + this.options.onReady?.() } getMarkdown(): string { diff --git a/libs/tiptap-editor/src/upload-coordinator.ts b/libs/tiptap-editor/src/upload-coordinator.ts index 2d033ee..571544f 100644 --- a/libs/tiptap-editor/src/upload-coordinator.ts +++ b/libs/tiptap-editor/src/upload-coordinator.ts @@ -7,13 +7,21 @@ interface UploadEntry { fileName: string } -/** coordinator 推给 Rust 的事件(通过 window.__tiptap_uploads.events)。 */ +/** counts 快照:随事件一起传给宿主,替代 window 全局。 */ +export interface UploadCounts { + uploading: number + error: number +} + +/** coordinator 推给宿主的事件(通过 emit 回调,替代 window 全局轮询)。 */ export interface UploadEvent { kind: 'error' | 'success' | 'removed' uploadId: string fileName: string errorMsg?: string ts: number + /** 当前编辑器内 uploading/error 占位符计数(emit 时由 JS 遍历文档算出)。 */ + counts: UploadCounts } /** 生成 uploadId,兼容非安全上下文(无 crypto.randomUUID 时)。 */ @@ -28,7 +36,7 @@ function genUploadId(): string { * - 生成 uploadId、创建 blob URL、插入占位符节点 * - 发起上传,成功更新节点 src、失败转 error 态 * - 按 upload-id 定位节点更新/删除(上传完成时光标早已移走) - * - 维护 window.__tiptap_uploads 供 Rust 轮询 + * - 通过 emit 回调把事件推给宿主(替代旧版 window 全局轮询) * * pending Map 保留 File 对象直到上传成功或显式移除,支持无限重试。 */ @@ -38,6 +46,7 @@ export class UploadCoordinator { constructor( private editor: Editor, private onImageUpload: (file: File) => Promise, + private emit: (event: UploadEvent) => void, ) {} /** @@ -157,17 +166,10 @@ export class UploadCoordinator { } /** - * 追加事件到 window.__tiptap_uploads.events,并重算 counts。 - * Rust 侧 500ms 轮询消费 events 并读取 counts。 + * 通过注入的 emit 回调把事件(含 counts 快照)输出给宿主, + * 替代原先写 window.__tiptap_uploads 全局供 Rust 轮询。 */ - private notifyRust(event: Omit): void { - const w = window as unknown as { __tiptap_uploads?: { events: UploadEvent[]; counts: { uploading: number; error: number } } } - if (!w.__tiptap_uploads) { - w.__tiptap_uploads = { events: [], counts: { uploading: 0, error: 0 } } - } - w.__tiptap_uploads.events.push({ ...event, ts: Date.now() }) - - // 重算 counts:遍历文档统计当前 uploading/error 占位符 + private notifyRust(event: Omit): void { let uploading = 0 let error = 0 this.editor.state.doc.descendants((node) => { @@ -176,7 +178,7 @@ export class UploadCoordinator { else if (state === 'error') error++ return true }) - w.__tiptap_uploads.counts = { uploading, error } + this.emit({ ...event, ts: Date.now(), counts: { uploading, error } }) } /** pending Map 查询(供内部/测试)。 */ diff --git a/src/main.rs b/src/main.rs index 188d690..206f42f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,6 +28,9 @@ mod router; mod ssr_cache; mod tasks; mod theme; +// 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..2837fb4 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 内容回填、图片上传与组件卸载时的清理。 +// prelude 在 WASM 构建里直接使用(use_signal/Signal/Element 等); +// server 构建里 #[component] 宏会重新导出这些符号导致 native 报 unused,故 allow。 +#[allow(unused_imports)] 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, -} - -#[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, -} +use wasm_bindgen::closure::Closure; /// 新建文章页面组件。 /// @@ -82,10 +46,10 @@ pub fn WriteEdit(id: i32) -> Element { /// /// 负责: /// - 编辑模式下通过 server function 拉取文章数据; -/// - 在 WASM 前端初始化 Tiptap 富文本编辑器并轮询就绪状态; +/// - 在 WASM 前端通过 tiptap_bridge 的 closure 回调初始化 Tiptap 富文本编辑器; /// - 编辑模式下将 Markdown 内容回填到编辑器; /// - 提交时读取编辑器 Markdown、校验并调用 create_post / update_post; -/// - 组件卸载时销毁 Tiptap 实例并清理全局状态。 +/// - 组件卸载时销毁 Tiptap 实例(EditorHandle::drop 自动 destroy + 释放 closure)。 #[allow(unused_mut, unused_variables)] fn write_editor(post_id: Option) -> Element { let is_edit = post_id.is_some(); @@ -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 new file mode 100644 index 0000000..ea8c07c --- /dev/null +++ b/src/tiptap_bridge.rs @@ -0,0 +1,308 @@ +//! 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();Writable 提供 .set()。Signal 方法需 trait 在作用域内。 + use dioxus::prelude::{Readable, Writable, WritableExt}; + 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)] + 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, + } + + 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 + } + } + + 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 —— + + /// 消费单个上传事件,更新 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(""); + if !url.is_empty() { + Ok(js_sys::JsString::from(url).into()) + } else { + // success=true 但 url 为空:服务端契约异常,按失败处理 + Err(js_sys::Error::new("上传成功但未返回图片地址").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, +};