第二十四章:富文本编辑器集成
博客v1.0系列教程(Dioxus)博客 v1.0 系列教程 (Dioxus)
第二十四章:富文本编辑器集成
1. 方案对比
| 方案 | 实现方式 | 优点 | 缺点 |
|------|---------|------|------|
| contenteditable | HTML 原生 | 无依赖,轻量 | 跨浏览器不一致,功能有限 |
| Markdown 编辑器 | <textarea> + 预览 | 简单可靠,纯文本存储 | 不是所见即所得 |
| ProseMirror/TipTap | 通过 web-sys 集成 | 功能完整,可扩展 | WASM 绑定复杂 |
| CodeMirror | WASM 编译 | 代码编辑功能强 | 体积大 |
选择建议
写文章(长文本) → Markdown 编辑器
评论/回复(短文本) → contenteditable 轻量编辑器
代码展示 → 语法高亮组件(非编辑器)
后台管理代码编辑 → CodeMirror/Ace 集成
2. Markdown 编辑器
2.1 分栏编辑器
#[component]
fn MarkdownEditor() -> Element {
let content = use_signal(|| String::new());
let preview = use_signal(|| false);
let rendered = use_memo(move || {
render_markdown(&content())
});
rsx! {
div { class: "border rounded-lg overflow-hidden",
style: "background: var(--card); border-color: var(--border);",
// 工具栏
div { class: "flex items-center gap-1 px-3 py-2 border-b",
style: "background: var(--hover); border-color: var(--border);",
ToolbarButton { icon: "B", action: "bold", editor: content }
ToolbarButton { icon: "I", action: "italic", editor: content }
ToolbarButton { icon: "H", action: "heading", editor: content }
span { class: "text-gray-300", "|" }
ToolbarButton { icon: "🔗", action: "link", editor: content }
ToolbarButton { icon: "📷", action: "image", editor: content }
span { class: "flex-1" }
button {
class: "px-3 py-1 text-xs rounded transition-colors",
style: "background: {if preview() { \"var(--primary)\" } else { \"transparent\" }}; color: {if preview() { \"white\" } else { \"var(--text)\" }};",
onclick: move |_| preview.toggle(),
if preview() { "编辑" } else { "预览" }
}
}
// 编辑/预览区
div { class: "flex",
if !preview() {
textarea {
class: "w-full min-h-[400px] p-4 font-mono text-sm resize-none outline-none",
style: "background: var(--card); color: var(--text);",
value: "{content}",
oninput: move |e| content.set(e.value()),
placeholder: "使用 Markdown 编写文章...",
}
}
if preview() {
div {
class: "w-full min-h-[400px] p-4 prose-content overflow-y-auto",
style: "color: var(--text);",
dangerous_inner_html: "{rendered}",
}
}
}
}
}
}
#[component]
fn ToolbarButton(icon: &'static str, action: &'static str, mut editor: Signal<String>) -> Element {
rsx! {
button {
class: "w-7 h-7 flex items-center justify-center rounded text-xs font-bold
transition-colors hover:bg-gray-200 dark:hover:bg-gray-600",
onclick: move |_| {
let insert = match action {
"bold" => "**文本**",
"italic" => "*文本*",
"heading" => "\n## 标题\n",
"link" => "[链接文字](url)",
"image" => "",
_ => "",
};
editor.write().push_str(insert);
},
"{icon}"
}
}
}
2.2 快捷键支持
#[component]
fn ShortcutEditor() -> Element {
let mut content = use_signal(String::new);
let handle_keydown = move |e: KeyboardEvent| {
let selection = get_textarea_selection();
let start = selection.0;
let end = selection.1;
let selected = &content()[start..end];
let replacement = match (e.key(), e.ctrl_key() || e.meta_key()) {
(Key::Character("b"), true) => {
e.prevent_default();
Some(format!("**{selected}**"))
}
(Key::Character("i"), true) => {
e.prevent_default();
Some(format!("*{selected}*"))
}
(Key::Character("k"), true) => {
e.prevent_default();
Some(format!("[{selected}](url)"))
}
(Key::Character("z"), true) if e.shift_key() => {
// Ctrl+Shift+Z = redo
e.prevent_default();
None // 通过浏览器默认行为
}
(Key::Character("z"), true) => {
// Ctrl+Z = undo
e.prevent_default();
None // 浏览器默认处理
}
_ => None,
};
if let Some(repl) = replacement {
let new_content = format!(
"{}{}{}",
&content()[..start],
repl,
&content()[end..]
);
content.set(new_content);
}
};
rsx! {
textarea {
value: "{content}",
oninput: move |e| content.set(e.value()),
onkeydown: handle_keydown,
}
}
}
3. 集成 CodeMirror
3.1 通过 web-sys 集成
#[wasm_bindgen(module = "codemirror")]
extern "C" {
type Editor;
type EditorConfig;
#[wasm_bindgen(js_name = "fromTextArea")]
fn from_text_area(textarea: &web_sys::HtmlTextAreaElement, config: &JsValue) -> Editor;
#[wasm_bindgen(method)]
fn getValue(this: &Editor) -> String;
#[wasm_bindgen(method)]
fn setValue(this: &Editor, value: &str);
}
#[component]
fn CodeEditor(code: String, lang: String) -> Element {
let editor_ref = use_node_ref();
use_effect(move || {
if let Some(el) = editor_ref.get() {
if let Some(textarea) = el.dyn_ref::<web_sys::HtmlTextAreaElement>() {
let config = serde_wasm_bindgen::to_value(&serde_json::json!({
"mode": lang,
"theme": "monokai",
"lineNumbers": true,
"tabSize": 2,
})).unwrap();
let editor = from_text_area(textarea, &config);
// 监听变化
// editor.on("change", ...)
}
}
});
rsx! {
textarea {
class: "hidden", // CodeMirror 接管后隐藏原始 textarea
onmounted: move |_| {},
}
}
}
4. contenteditable 轻量编辑器
4.1 基础实现
#[component]
fn InlineEditor() -> Element {
let content = use_signal(|| "输入评论...".to_string());
let editing = use_signal(|| false);
rsx! {
div {
class: "border rounded-lg p-3 transition-all",
style: "
background: var(--card);
border-color: {if editing() { \"var(--primary)\" } else { \"var(--border)\" }};
min-height: 80px;
",
div {
class: "outline-none text-sm",
contenteditable: "true",
onfocus: move |_| editing.set(true),
onblur: move |_| editing.set(false),
// 通过 inner_html 同步内容
dangerous_inner_html: "{content}",
// 实际需要通过 JS 监听 input 事件
}
// 工具栏(获得焦点时显示)
if editing() {
div { class: "flex gap-1 mt-2 pt-2 border-t",
style: "border-color: var(--border);",
button { onclick: move |_| {}, "B" }
button { onclick: move |_| {}, "I" }
button { onclick: move |_| {}, "U" }
}
}
}
}
}
4.2 执行文档命令
/// 使用 document.execCommand 操作 contenteditable
fn exec_command(command: &str, value: Option<&str>) {
let doc = web_sys::window().unwrap().document().unwrap();
let _ = doc.exec_command(command, false, value);
}
// 工具栏命令
fn format_bold() { exec_command("bold", None); }
fn format_italic() { exec_command("italic", None); }
fn format_heading() { exec_command("formatBlock", Some("h3")); }
fn insert_link(url: &str) { exec_command("createLink", Some(url)); }
fn insert_image(url: &str) { exec_command("insertImage", Some(url)); }
fn insert_list() { exec_command("insertUnorderedList", None); }
fn insert_code() { exec_command("insertHTML", Some("<code>代码</code>")); }
5. 代码编辑器组件
5.1 语法高亮显示
#[component]
fn CodeBlock(code: String, lang: String) -> Element {
let highlighted = use_memo(move || {
// 使用 syntect 或类似的 Rust 语法高亮库
highlight_code(&code, &lang)
});
rsx! {
div { class: "code-block-wrap",
div { class: "code-toolbar",
div { class: "code-dots",
span { class: "code-dot red" }
span { class: "code-dot green" }
}
span { class: "text-xs ml-2",
style: "color: var(--tertiary);",
"{lang}"
}
button { class: "code-copy-btn", "复制" }
}
pre {
dangerous_inner_html: "{highlighted}",
}
}
}
}
6. 图片上传集成
#[component]
fn EditorWithUpload() -> Element {
let content = use_signal(String::new);
let uploading = use_signal(|| false);
let insert_image = move |url: String| {
let markdown = format!("\n\n");
content.write().push_str(&markdown);
};
let handle_paste = move |e: ClipboardEvent| {
if let Some(items) = e.clipboard_data() {
for item in items.items() {
if item.kind() == "file" {
e.prevent_default();
let file = item.get_as_file().unwrap();
uploading.set(true);
spawn(async move {
// 上传文件到服务器
if let Ok(url) = upload_file(file).await {
insert_image(url);
}
uploading.set(false);
});
}
}
}
};
rsx! {
div { class: "relative",
if uploading() {
div { class: "absolute inset-0 flex items-center justify-center bg-white/50",
"图片上传中..."
}
}
textarea {
value: "{content}",
oninput: move |e| content.set(e.value()),
onpaste: handle_paste,
}
}
}
}
7. 自动保存
fn use_autosave(key: &str, content: Signal<String>, interval_ms: u64) {
let key = key.to_string();
let mut last_saved = use_signal(|| String::new());
use_interval(interval_ms, move || {
let current = content();
if current != last_saved() && !current.is_empty() {
// 保存到 localStorage 作为草稿
set_local_storage(&format!("draft_{key}"), ¤t);
last_saved.set(current);
console_log!("[Autosave] 已保存草稿 {key}");
}
});
}
#[component]
fn ArticleEditor(slug: String) -> Element {
let content = use_signal(|| {
// 加载本地草稿
get_local_storage(&format!("draft_{slug}"))
.unwrap_or_default()
});
use_autosave(&slug, content, 5000);
// 成功发布后清除草稿
let publish = move || {
// ... 发布逻辑
remove_local_storage(&format!("draft_{slug}"));
};
rsx! {
div { class: "text-xs", style: "color: var(--tertiary);",
"自动保存中..."
}
MarkdownEditor {}
}
}
8. 字数统计
#[component]
fn EditorStats(content: Signal<String>) -> Element {
let stats = use_memo(move || {
let text = content();
let chars = text.chars().count();
let words = text.split_whitespace().count();
let lines = text.lines().count();
let reading_time = (words as f64 / 200.0).ceil() as u64; // 200字/分钟
EditorStats {
chars,
words,
lines,
reading_time,
}
});
rsx! {
div { class: "flex gap-4 text-xs", style: "color: var(--tertiary);",
span { "{stats.read().chars} 字" }
span { "{stats.read().words} 词" }
span { "{stats.read().lines} 行" }
span { "阅读约 {stats.read().reading_time} 分钟" }
}
}
}
9. 编辑体验优化
// 全屏编辑模式
#[component]
fn FullscreenEditor() -> Element {
let fullscreen = use_signal(|| false);
rsx! {
div {
class: "transition-all duration-300",
style: "
position: {if fullscreen() { \"fixed\" } else { \"relative\" }};
inset: 0;
z-index: {if fullscreen() { \"50\" } else { \"auto\" }};
background: var(--bg);
",
button {
onclick: move |_| fullscreen.toggle(),
if fullscreen() { "退出全屏" } else { "全屏编辑" }
}
MarkdownEditor {}
}
}
}
10. 小结
- Markdown 编辑器(
<textarea>+ 预览)是最实用可靠的长文编辑方案 contenteditable+execCommand适合轻量的评论/回复编辑器- CodeMirror 等专业编辑器通过 web-sys 绑定集成
- 工具栏按钮通过插入 Markdown 语法或执行文档命令实现
- 粘贴图片自动上传提升编辑体验
- 自动保存草稿到 localStorage,防止内容丢失
- 字数和阅读时间统计对作者有价值
dioxuseditorrich-textmarkdowncontent-editable