refactor(tiptap): replace js_sys::eval with wasm-bindgen bindings
Tiptap 编辑器与 Rust/WASM 宿主的通信层从 eval 字符串拼贴 + window 全局 + 轮询, 重构为类型化 wasm-bindgen 绑定: - 新增 src/tiptap_bridge.rs:extern 绑定 TiptapEditor/EditorInstance/EditorOptions/ UploadEventJs;EditorHandle 统一持有实例 + 4 个 Closure,Drop 调 destroy 杜绝泄漏。 - write.rs 删除全部 9 处 js_sys::eval、两段轮询(100ms 就绪 / 500ms 上传事件)、 三个 window 全局(__tiptap_ready/__tiptap_content/__tiptap_uploads)。 - JS 侧 EditorOptions 加 onReady/onUploadEvent 回调;UploadCoordinator.notifyRust 改为 emit 回调(事件携带 counts),不再写 window 全局。 - make_upload_closure 用 web_sys fetch 实现 /api/upload,行为与原 JS 一致。 净变化:354 insertions(+), 496 deletions(-)。 Design: docs/superpowers/specs/2026-06-23-tiptap-wasm-bindgen-refactor-design.md Plan: docs/superpowers/plans/2026-06-23-tiptap-wasm-bindgen-refactor.md
This commit is contained in:
commit
1e270f0055
@ -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.
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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<string>
|
||||
// 编辑器实例创建完成(含 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 {
|
||||
|
||||
@ -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<string>,
|
||||
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<UploadEvent, 'ts'>): 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<UploadEvent, 'ts' | 'counts'>): 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 查询(供内部/测试)。 */
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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<UploadSnapshotEvent>,
|
||||
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<String>,
|
||||
}
|
||||
|
||||
#[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<i32>) -> Element {
|
||||
let is_edit = post_id.is_some();
|
||||
@ -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;
|
||||
}
|
||||
// 防重复 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::<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"))]
|
||||
{
|
||||
});
|
||||
|
||||
// 编辑器就绪后解除 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::<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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// 非 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<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()
|
||||
};
|
||||
|
||||
// 兜底:扫描残留的上传占位符标记(轮询窗口期漏判防护)
|
||||
// 检测  形式的泄漏图片 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);
|
||||
}
|
||||
|
||||
308
src/tiptap_bridge.rs
Normal file
308
src/tiptap_bridge.rs
Normal file
@ -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<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 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
|
||||
}
|
||||
}
|
||||
|
||||
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<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_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<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("");
|
||||
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,
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user