Skip to main content

fc_dioxus/components/
editor.rs

1//! A rich text editor from editor.css, with `Prose` for rendered rich text.
2//!
3//! The markup is Rust; the behaviour is upstream's own `editor.ts`, shipped unchanged as
4//! JavaScript and bound through [`DomBridge::bind_editor`]. Editing is selection work,
5//! which upstream keeps out of its Rust models on purpose: where the caret is, what the
6//! IME is composing, and how to wrap a selection without collapsing it are questions
7//! only the browser can answer. Without a script host the editor stays plain editable
8//! text with an inert toolbar.
9use crate::{
10    core::{
11        assets,
12        attrs::{
13            component_attributes, editor_block_attribute, editor_mark_attribute,
14            named_state_attribute,
15        },
16        platform::{DomBridge, EditorEvent},
17    },
18    models::rich_text::Mark,
19    prelude::*,
20    theme::use_stylesheets,
21};
22const BEM: Bem = Bem::new("fc-editor");
23const PROSE: Bem = Bem::new("fc-prose");
24const TOOLBAR: Bem = Bem::new("fc-toolbar");
25const HIDDEN: Bem = Bem::new("fc-u-visually-hidden");
26
27/// HTML the application vouches for, the only kind an [`Editor`] will render.
28///
29/// The document becomes `innerHTML`, so an event handler or script in it runs for the
30/// reader. Constructing one is an explicit claim that the markup came from the
31/// application's own state or went through its sanitizer, never straight from storage
32/// or another user.
33#[derive(Debug, Clone, Default, PartialEq, Eq)]
34pub struct TrustedHtml(String);
35impl TrustedHtml {
36    /// Wrap markup the caller has already made safe.
37    #[must_use]
38    pub fn assume_trusted(html: impl Into<String>) -> Self {
39        Self(html.into())
40    }
41    /// The markup.
42    #[must_use]
43    pub fn as_str(&self) -> &str {
44        &self.0
45    }
46}
47
48/// Whether the document is being saved.
49#[non_exhaustive]
50#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
51pub enum EditorState {
52    /// Editing.
53    #[default]
54    Idle,
55    /// A save is in flight: the content dims but stays editable.
56    Saving,
57    /// The last save failed.
58    Error,
59}
60
61/// A block a toolbar button toggles on the paragraph holding the selection.
62#[non_exhaustive]
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64pub enum BlockTag {
65    /// A top-level heading.
66    H1,
67    /// A section heading.
68    H2,
69    /// A subsection heading.
70    H3,
71    /// Preformatted code.
72    Pre,
73    /// A quotation.
74    Blockquote,
75}
76impl BlockTag {
77    const fn command(self) -> &'static str {
78        match self {
79            Self::H1 => "h1",
80            Self::H2 => "h2",
81            Self::H3 => "h3",
82            Self::Pre => "pre",
83            Self::Blockquote => "blockquote",
84        }
85    }
86    const fn glyph(self) -> (&'static str, &'static str) {
87        match self {
88            Self::H1 => ("H1", "Heading 1"),
89            Self::H2 => ("H2", "Heading 2"),
90            Self::H3 => ("H3", "Heading 3"),
91            Self::Pre => ("{ }", "Code block"),
92            Self::Blockquote => ("\u{201c}", "Quote"),
93        }
94    }
95}
96
97const fn mark_command(mark: Mark) -> (&'static str, &'static str, &'static str) {
98    match mark {
99        Mark::Bold => ("bold", "B", "Bold"),
100        Mark::Italic => ("italic", "I", "Italic"),
101        Mark::Underline => ("underline", "U", "Underline"),
102        Mark::Strike => ("strike", "S", "Strikethrough"),
103        Mark::Code => ("code", "</>", "Code"),
104        Mark::Link => ("link", "\u{1f517}", "Link"),
105    }
106}
107
108/// The marks a default toolbar offers, in upstream's order.
109pub const DEFAULT_MARKS: [Mark; 5] = [
110    Mark::Bold,
111    Mark::Italic,
112    Mark::Underline,
113    Mark::Strike,
114    Mark::Code,
115];
116
117/// A rich text editor: a formatting toolbar over an editable document. Toolbar buttons
118/// report `aria-pressed` across the whole selection, so a half-bold selection reads as
119/// not bold and a click bolds the rest. The document is reported as HTML through
120/// `onchange`.
121#[component]
122pub fn Editor(
123    /// Names the document.
124    label: AccessibleName,
125    /// The starting document, read once.
126    #[props(default)]
127    initial: TrustedHtml,
128    /// Shown while the document is empty.
129    #[props(default)]
130    placeholder: Option<String>,
131    #[props(default)] state: EditorState,
132    #[props(default)] readonly: bool,
133    /// The mark buttons, in order.
134    #[props(default = DEFAULT_MARKS.to_vec())]
135    marks: Vec<Mark>,
136    /// The block buttons, after a divider.
137    #[props(default)]
138    blocks: Vec<BlockTag>,
139    #[props(default)] onchange: Option<EventHandler<String>>,
140    /// The marks covering the whole selection, as the toolbar shows them.
141    #[props(default)]
142    onmarks: Option<EventHandler<Vec<Mark>>>,
143    #[props(default)] class: Option<String>,
144    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
145) -> Element {
146    let id = use_id();
147    // Read once: after mount the document belongs to the browser and the binding, and
148    // re-rendering `innerHTML` would throw away the caret and the user's typing.
149    let initial = use_hook(|| initial.as_str().to_owned());
150    let mut attributes = component_attributes(
151        attributes,
152        ClassList::new(BEM.block()).extend(class.as_deref()),
153        &["id"],
154    );
155    attributes.push(Attribute::new("id", id.to_string(), None, false));
156    match state {
157        EditorState::Idle => {}
158        EditorState::Saving => attributes.push(named_state_attribute("saving")),
159        EditorState::Error => attributes.push(named_state_attribute("error")),
160    }
161    let content_class = ClassList::new(&BEM.element("content"))
162        .with(PROSE.block())
163        .to_string();
164    let icon_class = ClassList::new(&TOOLBAR.element("item"))
165        .with(TOOLBAR.element("item--icon"))
166        .to_string();
167
168    rsx! {
169        if use_stylesheets() {
170            document::Stylesheet { href: assets::COMPONENTS_TOOLBAR_CSS }
171            document::Stylesheet { href: assets::COMPONENTS_EDITOR_CSS }
172        }
173        div {
174            onmounted: move |_| {
175                let id = id.clone();
176                async move {
177                    let Ok(mut session) = DomBridge.bind_editor(&id).await else { return };
178                    while let Some(event) = session.next().await {
179                        match event {
180                            EditorEvent::Change(html) => if let Some(onchange) = onchange { onchange.call(html) },
181                            EditorEvent::Marks(active) => if let Some(onmarks) = onmarks { onmarks.call(active) },
182                        }
183                    }
184                }
185            },
186            ..attributes,
187            // The binding owns `aria-pressed` and the roving tabindex from here on;
188            // neither is rendered dynamically, so the two never contend.
189            div { class: TOOLBAR.block(), role: "toolbar", aria_label: "Formatting",
190                for mark in marks.iter().copied() {
191                    {
192                        let (command, glyph, name) = mark_command(mark);
193                        rsx! {
194                            button { key: "{command}", r#type: "button", class: TOOLBAR.element("item"), aria_pressed: "false",
195                                disabled: readonly, ..crate::spread![editor_mark_attribute(command)],
196                                span { aria_hidden: "true", "{glyph}" }
197                                span { class: HIDDEN.block(), "{name}" }
198                            }
199                        }
200                    }
201                }
202                if !blocks.is_empty() {
203                    span { class: TOOLBAR.element("divider"), role: "separator" }
204                }
205                for block in blocks.iter().copied() {
206                    {
207                        let (glyph, name) = block.glyph();
208                        rsx! {
209                            // The name is spoken from the hidden span, so "H2" on screen and
210                            // "Heading 2" in speech do not count as a label mismatch.
211                            button { key: "{block.command()}", r#type: "button", class: "{icon_class}",
212                                disabled: readonly, ..crate::spread![editor_block_attribute(block.command())],
213                                span { aria_hidden: "true", "{glyph}" }
214                                span { class: HIDDEN.block(), "{name}" }
215                            }
216                        }
217                    }
218                }
219            }
220            div { class: "{content_class}", role: "textbox", aria_multiline: "true", aria_label: "{label}",
221                contenteditable: if readonly { "false" } else { "true" },
222                aria_readonly: if readonly { Some("true") } else { None },
223                "data-placeholder": placeholder,
224                dangerous_inner_html: "{initial}",
225            }
226        }
227    }
228}
229
230/// Rendered rich text: headings, lists, quotes, code, and links in upstream's prose
231/// styles.
232#[component]
233pub fn Prose(
234    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
235    children: Element,
236) -> Element {
237    let attributes = component_attributes(attributes, ClassList::new(PROSE.block()), &[]);
238    rsx! {
239        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_EDITOR_CSS } }
240        div { ..attributes, {children} }
241    }
242}