第二十四章:富文本编辑器集成

博客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" => "![图片描述](url)",
                    _ => "",
                };
                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![图片]({url})\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}"), &current);
            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