1use 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
34pub struct TrustedHtml(String);
35impl TrustedHtml {
36 #[must_use]
38 pub fn assume_trusted(html: impl Into<String>) -> Self {
39 Self(html.into())
40 }
41 #[must_use]
43 pub fn as_str(&self) -> &str {
44 &self.0
45 }
46}
47
48#[non_exhaustive]
50#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
51pub enum EditorState {
52 #[default]
54 Idle,
55 Saving,
57 Error,
59}
60
61#[non_exhaustive]
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64pub enum BlockTag {
65 H1,
67 H2,
69 H3,
71 Pre,
73 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
108pub const DEFAULT_MARKS: [Mark; 5] = [
110 Mark::Bold,
111 Mark::Italic,
112 Mark::Underline,
113 Mark::Strike,
114 Mark::Code,
115];
116
117#[component]
122pub fn Editor(
123 label: AccessibleName,
125 #[props(default)]
127 initial: TrustedHtml,
128 #[props(default)]
130 placeholder: Option<String>,
131 #[props(default)] state: EditorState,
132 #[props(default)] readonly: bool,
133 #[props(default = DEFAULT_MARKS.to_vec())]
135 marks: Vec<Mark>,
136 #[props(default)]
138 blocks: Vec<BlockTag>,
139 #[props(default)] onchange: Option<EventHandler<String>>,
140 #[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 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 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 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#[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}