synpad 0.1.0

A full-featured Matrix chat client built with Dioxus
use dioxus::prelude::*;

/// Rich text editor with formatting toolbar.
///
/// Toolbar buttons insert markdown syntax at cursor position or around
/// the entire text when selection tracking is not available.
#[component]
pub fn RichTextEditor(
    value: String,
    on_input: EventHandler<String>,
    placeholder: Option<String>,
    disabled: Option<bool>,
) -> Element {
    let is_disabled = disabled.unwrap_or(false);
    let placeholder_text = placeholder.unwrap_or_else(|| "Send a message...".to_string());
    let mut is_preview = use_signal(|| false);

    // Formatting handlers: insert markdown markers around current text.
    // When the user has selected text, a proper implementation would wrap
    // only the selection.  Dioxus doesn't expose the selection range on a
    // textarea, so we append the formatting prefix/suffix to the end of
    // the current value as an insertion template the user can fill in.
    let value_for_bold = value.clone();
    let on_bold = {
        let on_input = on_input.clone();
        move |_| {
            let v = value_for_bold.clone();
            if v.is_empty() {
                on_input.call("****".to_string());
            } else {
                on_input.call(format!("**{v}**"));
            }
        }
    };
    let value_for_italic = value.clone();
    let on_italic = {
        let on_input = on_input.clone();
        move |_| {
            let v = value_for_italic.clone();
            if v.is_empty() {
                on_input.call("__".to_string());
            } else {
                on_input.call(format!("_{v}_"));
            }
        }
    };
    let value_for_strike = value.clone();
    let on_strikethrough = {
        let on_input = on_input.clone();
        move |_| {
            let v = value_for_strike.clone();
            if v.is_empty() {
                on_input.call("~~~~".to_string());
            } else {
                on_input.call(format!("~~{v}~~"));
            }
        }
    };
    let value_for_code = value.clone();
    let on_code = {
        let on_input = on_input.clone();
        move |_| {
            let v = value_for_code.clone();
            if v.is_empty() {
                on_input.call("``".to_string());
            } else {
                on_input.call(format!("`{v}`"));
            }
        }
    };
    let value_for_codeblock = value.clone();
    let on_codeblock = {
        let on_input = on_input.clone();
        move |_| {
            let v = value_for_codeblock.clone();
            if v.is_empty() {
                on_input.call("```\n\n```".to_string());
            } else {
                on_input.call(format!("```\n{v}\n```"));
            }
        }
    };
    let value_for_quote = value.clone();
    let on_quote = {
        let on_input = on_input.clone();
        move |_| {
            let v = value_for_quote.clone();
            if v.is_empty() {
                on_input.call("> ".to_string());
            } else {
                let quoted = v.lines()
                    .map(|l| format!("> {l}"))
                    .collect::<Vec<_>>()
                    .join("\n");
                on_input.call(quoted);
            }
        }
    };
    let value_for_list = value.clone();
    let on_list = {
        let on_input = on_input.clone();
        move |_| {
            let v = value_for_list.clone();
            if v.is_empty() {
                on_input.call("- ".to_string());
            } else {
                let listed = v.lines()
                    .map(|l| format!("- {l}"))
                    .collect::<Vec<_>>()
                    .join("\n");
                on_input.call(listed);
            }
        }
    };
    let value_for_link = value.clone();
    let on_link = {
        let on_input = on_input.clone();
        move |_| {
            let v = value_for_link.clone();
            if v.is_empty() {
                on_input.call("[text](url)".to_string());
            } else {
                on_input.call(format!("[{v}](url)"));
            }
        }
    };

    // Markdown preview
    let preview_html = if *is_preview.read() {
        crate::utils::markdown::markdown_to_html_string(&value)
    } else {
        String::new()
    };

    rsx! {
        div {
            class: "rich-text-editor",

            // Formatting toolbar
            div {
                class: "rich-text-editor__toolbar",
                button {
                    class: "rich-text-editor__tool-btn",
                    title: "Bold (Ctrl+B)",
                    disabled: is_disabled,
                    onclick: on_bold,
                    span { class: "rich-text-editor__tool-icon", "B" }
                }
                button {
                    class: "rich-text-editor__tool-btn",
                    title: "Italic (Ctrl+I)",
                    disabled: is_disabled,
                    onclick: on_italic,
                    span { class: "rich-text-editor__tool-icon rich-text-editor__tool-icon--italic", "I" }
                }
                button {
                    class: "rich-text-editor__tool-btn",
                    title: "Strikethrough",
                    disabled: is_disabled,
                    onclick: on_strikethrough,
                    span { class: "rich-text-editor__tool-icon rich-text-editor__tool-icon--strike", "S" }
                }
                div { class: "rich-text-editor__toolbar-divider" }
                button {
                    class: "rich-text-editor__tool-btn",
                    title: "Inline code",
                    disabled: is_disabled,
                    onclick: on_code,
                    "<>"
                }
                button {
                    class: "rich-text-editor__tool-btn",
                    title: "Code block",
                    disabled: is_disabled,
                    onclick: on_codeblock,
                    "[]"
                }
                div { class: "rich-text-editor__toolbar-divider" }
                button {
                    class: "rich-text-editor__tool-btn",
                    title: "Quote",
                    disabled: is_disabled,
                    onclick: on_quote,
                    "\""
                }
                button {
                    class: "rich-text-editor__tool-btn",
                    title: "Bullet list",
                    disabled: is_disabled,
                    onclick: on_list,
                    ""
                }
                button {
                    class: "rich-text-editor__tool-btn",
                    title: "Link",
                    disabled: is_disabled,
                    onclick: on_link,
                    "🔗"
                }
                div { class: "rich-text-editor__toolbar-spacer" }
                button {
                    class: if *is_preview.read() { "rich-text-editor__tool-btn rich-text-editor__tool-btn--active" } else { "rich-text-editor__tool-btn" },
                    title: "Toggle preview",
                    onclick: move |_| {
                        let current = *is_preview.read();
                        is_preview.set(!current);
                    },
                    "👁"
                }
            }

            // Text area or preview
            if *is_preview.read() {
                div {
                    class: "rich-text-editor__preview",
                    dangerous_inner_html: "{preview_html}",
                }
            } else {
                textarea {
                    class: "rich-text-editor__input",
                    placeholder: "{placeholder_text}",
                    value: "{value}",
                    oninput: move |evt| on_input.call(evt.value()),
                    disabled: is_disabled,
                }
            }
        }
    }
}