Skip to main content

dioxus_ui_system/organisms/
rich_text.rs

1//! Rich Text Editor organism component
2//!
3//! A WYSIWYG text editor with formatting toolbar supporting bold, italic,
4//! underline, headings, lists, links, code blocks, and text alignment.
5
6use crate::styles::Style;
7use crate::theme::{use_style, use_theme};
8use dioxus::prelude::*;
9
10/// Rich text editor features configuration
11#[derive(Clone, PartialEq, Debug)]
12pub struct RichTextFeatures {
13    /// Enable bold formatting
14    pub bold: bool,
15    /// Enable italic formatting
16    pub italic: bool,
17    /// Enable underline formatting
18    pub underline: bool,
19    /// Enable strikethrough formatting
20    pub strikethrough: bool,
21    /// Enable heading formatting
22    pub heading: bool,
23    /// Enable bullet list
24    pub bullet_list: bool,
25    /// Enable numbered list
26    pub numbered_list: bool,
27    /// Enable link insertion
28    pub link: bool,
29    /// Enable code blocks
30    pub code_block: bool,
31    /// Enable blockquotes
32    pub quote: bool,
33    /// Enable left alignment
34    pub align_left: bool,
35    /// Enable center alignment
36    pub align_center: bool,
37    /// Enable right alignment
38    pub align_right: bool,
39}
40
41impl Default for RichTextFeatures {
42    fn default() -> Self {
43        Self {
44            bold: true,
45            italic: true,
46            underline: true,
47            strikethrough: true,
48            heading: true,
49            bullet_list: true,
50            numbered_list: true,
51            link: true,
52            code_block: true,
53            quote: true,
54            align_left: true,
55            align_center: true,
56            align_right: true,
57        }
58    }
59}
60
61/// Rich text editor properties
62#[derive(Props, Clone, PartialEq)]
63pub struct RichTextEditorProps {
64    /// Current HTML content
65    #[props(default)]
66    pub value: String,
67    /// Change handler - called when content changes
68    #[props(default)]
69    pub on_change: Option<EventHandler<String>>,
70    /// Placeholder text when editor is empty
71    #[props(default)]
72    pub placeholder: Option<String>,
73    /// Disabled state
74    #[props(default = false)]
75    pub disabled: bool,
76    /// Minimum height of editor (default: "200px")
77    #[props(default = "200px")]
78    pub min_height: &'static str,
79    /// Maximum height of editor
80    #[props(default)]
81    pub max_height: Option<String>,
82    /// Feature flags to enable/disable formatting options
83    #[props(default)]
84    pub features: RichTextFeatures,
85    /// Additional CSS classes
86    #[props(default)]
87    pub class: Option<String>,
88}
89
90/// Rich text editor component
91#[component]
92pub fn RichTextEditor(props: RichTextEditorProps) -> Element {
93    let _theme = use_theme();
94    let content = use_signal(|| props.value.clone());
95    let is_focused = use_signal(|| false);
96
97    let class_css = props
98        .class
99        .as_ref()
100        .map(|c| format!(" {}", c))
101        .unwrap_or_default();
102
103    let container_style = use_style(|t| {
104        Style::new()
105            .w_full()
106            .border(1, &t.colors.border)
107            .rounded(&t.radius, "md")
108            .bg(&t.colors.background)
109            .build()
110    });
111
112    let _on_input = {
113        let on_change = props.on_change.clone();
114        move |_e: Event<dioxus::events::KeyboardData>| {
115            if let Some(handler) = &on_change {
116                // In a real implementation, we would get the innerHTML from the contenteditable
117                // For now, we'll just trigger the handler
118                handler.call(content());
119            }
120        }
121    };
122
123    rsx! {
124        div {
125            class: "rich-text-editor{class_css}",
126            style: "{container_style}",
127
128            // Toolbar
129            if !props.disabled {
130                RichTextToolbar {
131                    features: props.features.clone(),
132                    disabled: props.disabled,
133                }
134            }
135
136            // Content editable area
137            RichTextContent {
138                value: props.value.clone(),
139                placeholder: props.placeholder.clone(),
140                disabled: props.disabled,
141                min_height: props.min_height,
142                max_height: props.max_height.clone(),
143                is_focused: is_focused(),
144                on_input: props.on_change.clone(),
145            }
146        }
147    }
148}
149
150/// Rich text toolbar properties
151#[derive(Props, Clone, PartialEq)]
152pub struct RichTextToolbarProps {
153    /// Feature flags
154    pub features: RichTextFeatures,
155    /// Disabled state
156    #[props(default = false)]
157    pub disabled: bool,
158}
159
160/// Rich text toolbar component with formatting buttons
161#[component]
162pub fn RichTextToolbar(props: RichTextToolbarProps) -> Element {
163    let theme = use_theme();
164
165    let toolbar_style = use_style(|t| {
166        Style::new()
167            .flex()
168            .flex_row()
169            .flex_wrap()
170            .items_center()
171            .gap_px(4)
172            .p(&t.spacing, "sm")
173            .border_bottom(1, &t.colors.border)
174            .build()
175    });
176
177    let button_style = use_style(|t| {
178        Style::new()
179            .inline_flex()
180            .items_center()
181            .justify_center()
182            .w_px(32)
183            .h_px(32)
184            .rounded(&t.radius, "sm")
185            .bg_transparent()
186            .cursor_pointer()
187            .text_color(&t.colors.foreground)
188            .build()
189    });
190
191    let button_hover_style = format!(
192        "background: {};",
193        theme.tokens.read().colors.muted.to_rgba()
194    );
195
196    let disabled_style = if props.disabled {
197        "opacity: 0.5; pointer-events: none;"
198    } else {
199        ""
200    };
201
202    rsx! {
203        div {
204            class: "rich-text-toolbar",
205            style: "{toolbar_style} {disabled_style}",
206
207            // Text formatting group
208            if props.features.bold {
209                ToolbarButton {
210                    title: "Bold",
211                    icon: "B",
212                    command: "bold",
213                    style: "{button_style}",
214                    hover_style: &button_hover_style,
215                }
216            }
217            if props.features.italic {
218                ToolbarButton {
219                    title: "Italic",
220                    icon: "I",
221                    command: "italic",
222                    style: "{button_style}",
223                    hover_style: &button_hover_style,
224                }
225            }
226            if props.features.underline {
227                ToolbarButton {
228                    title: "Underline",
229                    icon: "U",
230                    command: "underline",
231                    style: "{button_style}",
232                    hover_style: &button_hover_style,
233                }
234            }
235            if props.features.strikethrough {
236                ToolbarButton {
237                    title: "Strikethrough",
238                    icon: "S",
239                    command: "strikeThrough",
240                    style: "{button_style}",
241                    hover_style: &button_hover_style,
242                }
243            }
244
245            // Separator
246            if props.features.bold || props.features.italic || props.features.underline || props.features.strikethrough {
247                ToolbarSeparator {}
248            }
249
250            // Headings
251            if props.features.heading {
252                ToolbarButton {
253                    title: "Heading 1",
254                    icon: "H1",
255                    command: "formatBlock",
256                    value: Some("H1"),
257                    style: "{button_style}",
258                    hover_style: &button_hover_style,
259                }
260                ToolbarButton {
261                    title: "Heading 2",
262                    icon: "H2",
263                    command: "formatBlock",
264                    value: Some("H2"),
265                    style: "{button_style}",
266                    hover_style: &button_hover_style,
267                }
268                ToolbarButton {
269                    title: "Heading 3",
270                    icon: "H3",
271                    command: "formatBlock",
272                    value: Some("H3"),
273                    style: "{button_style}",
274                    hover_style: &button_hover_style,
275                }
276                ToolbarSeparator {}
277            }
278
279            // Lists
280            if props.features.bullet_list {
281                ToolbarButton {
282                    title: "Bullet List",
283                    icon: "•",
284                    command: "insertUnorderedList",
285                    style: "{button_style}",
286                    hover_style: &button_hover_style,
287                }
288            }
289            if props.features.numbered_list {
290                ToolbarButton {
291                    title: "Numbered List",
292                    icon: "1.",
293                    command: "insertOrderedList",
294                    style: "{button_style}",
295                    hover_style: &button_hover_style,
296                }
297            }
298
299            // Separator
300            if props.features.bullet_list || props.features.numbered_list {
301                ToolbarSeparator {}
302            }
303
304            // Alignment
305            if props.features.align_left {
306                ToolbarButton {
307                    title: "Align Left",
308                    icon: "←",
309                    command: "justifyLeft",
310                    style: "{button_style}",
311                    hover_style: &button_hover_style,
312                }
313            }
314            if props.features.align_center {
315                ToolbarButton {
316                    title: "Align Center",
317                    icon: "↔",
318                    command: "justifyCenter",
319                    style: "{button_style}",
320                    hover_style: &button_hover_style,
321                }
322            }
323            if props.features.align_right {
324                ToolbarButton {
325                    title: "Align Right",
326                    icon: "→",
327                    command: "justifyRight",
328                    style: "{button_style}",
329                    hover_style: &button_hover_style,
330                }
331            }
332
333            // Separator
334            if props.features.align_left || props.features.align_center || props.features.align_right {
335                ToolbarSeparator {}
336            }
337
338            // Special blocks
339            if props.features.quote {
340                ToolbarButton {
341                    title: "Quote",
342                    icon: "\"",
343                    command: "formatBlock",
344                    value: Some("BLOCKQUOTE"),
345                    style: "{button_style}",
346                    hover_style: &button_hover_style,
347                }
348            }
349            if props.features.code_block {
350                ToolbarButton {
351                    title: "Code Block",
352                    icon: "</>",
353                    command: "formatBlock",
354                    value: Some("PRE"),
355                    style: "{button_style}",
356                    hover_style: &button_hover_style,
357                }
358            }
359            if props.features.link {
360                ToolbarButton {
361                    title: "Insert Link",
362                    icon: "L",
363                    command: "createLink",
364                    value: Some("https://"),
365                    style: "{button_style}",
366                    hover_style: &button_hover_style,
367                }
368            }
369        }
370    }
371}
372
373/// Toolbar separator component
374#[component]
375fn ToolbarSeparator() -> Element {
376    let theme = use_theme();
377
378    rsx! {
379        div {
380            class: "rich-text-separator",
381            style: "width: 1px; height: 24px; background: {theme.tokens.read().colors.border.to_rgba()}; margin: 0 4px;",
382        }
383    }
384}
385
386/// Toolbar button properties
387#[derive(Props, Clone, PartialEq)]
388pub struct ToolbarButtonProps {
389    /// Button title (tooltip)
390    pub title: &'static str,
391    /// Button icon/text
392    pub icon: &'static str,
393    /// execCommand command name
394    pub command: &'static str,
395    /// Optional command value
396    #[props(default)]
397    pub value: Option<&'static str>,
398    /// Base style
399    pub style: String,
400    /// Hover style
401    pub hover_style: String,
402}
403
404/// Toolbar button component
405#[component]
406fn ToolbarButton(props: ToolbarButtonProps) -> Element {
407    rsx! {
408        button {
409            type: "button",
410            class: "rich-text-toolbar-button",
411            title: "{props.title}",
412            style: "{props.style} {props.hover_style}",
413            onclick: move |_| {
414                // Execute the formatting command
415                execute_command(props.command, props.value);
416            },
417            "{props.icon}"
418        }
419    }
420}
421
422/// Execute a document.execCommand
423fn execute_command(command: &str, value: Option<&str>) {
424    // In a browser environment, this would call:
425    // document.execCommand(command, false, value.unwrap_or(""));
426    // For Dioxus, we use wasm-bindgen or inline JS
427    #[cfg(target_arch = "wasm32")]
428    {
429        use wasm_bindgen::prelude::*;
430
431        #[wasm_bindgen]
432        extern "C" {
433            #[wasm_bindgen(js_namespace = document)]
434            fn execCommand(command: &str, show_ui: bool, value: Option<&str>) -> bool;
435        }
436
437        let _ = execCommand(command, false, value);
438    }
439
440    // For non-WASM targets, commands are no-ops
441    #[cfg(not(target_arch = "wasm32"))]
442    {
443        let _ = command;
444        let _ = value;
445    }
446}
447
448/// Rich text content area properties
449#[derive(Props, Clone, PartialEq)]
450pub struct RichTextContentProps {
451    /// Current value
452    pub value: String,
453    /// Placeholder text
454    #[props(default)]
455    pub placeholder: Option<String>,
456    /// Disabled state
457    #[props(default = false)]
458    pub disabled: bool,
459    /// Minimum height
460    pub min_height: &'static str,
461    /// Maximum height
462    #[props(default)]
463    pub max_height: Option<String>,
464    /// Whether the editor is focused
465    pub is_focused: bool,
466    /// Input handler
467    #[props(default)]
468    pub on_input: Option<EventHandler<String>>,
469}
470
471/// Rich text content editable area
472#[component]
473pub fn RichTextContent(props: RichTextContentProps) -> Element {
474    let theme = use_theme();
475    let inner_html = use_signal(|| props.value.clone());
476
477    let max_height_style = props
478        .max_height
479        .as_ref()
480        .map(|h| format!("max-height: {};", h))
481        .unwrap_or_default();
482
483    let disabled_style = if props.disabled {
484        "background: #f5f5f5; cursor: not-allowed; opacity: 0.7;"
485    } else {
486        ""
487    };
488
489    let focus_style = if props.is_focused {
490        format!(
491            "outline: 2px solid {}; outline-offset: -2px;",
492            theme.tokens.read().colors.primary.to_rgba()
493        )
494    } else {
495        String::new()
496    };
497
498    let content_style = use_style(|t| {
499        Style::new()
500            .w_full()
501            .min_h(props.min_height)
502            .p(&t.spacing, "md")
503            .font_size(14)
504            .line_height(1.6)
505            .text_color(&t.colors.foreground)
506            .build()
507    });
508
509    let on_input = {
510        let on_input_handler = props.on_input.clone();
511        move |_e: Event<dioxus::events::FormData>| {
512            // In a real implementation, we would get innerHTML from the event target
513            // For now, we trigger the handler with current content
514            if let Some(handler) = &on_input_handler {
515                handler.call(inner_html());
516            }
517        }
518    };
519
520    rsx! {
521        div {
522            class: "rich-text-content",
523            style: "{content_style} {max_height_style} {disabled_style} {focus_style} overflow: auto;",
524
525            // Placeholder (shown when empty)
526            if inner_html().is_empty() && props.placeholder.is_some() {
527                div {
528                    style: "position: absolute; color: {theme.tokens.read().colors.muted.to_rgba()}; pointer-events: none;",
529                    "{props.placeholder.clone().unwrap()}"
530                }
531            }
532
533            // Content editable div
534            div {
535                contenteditable: !props.disabled,
536                style: "min-height: 100%; outline: none;",
537                oninput: on_input,
538
539                // Render the initial content
540                dangerous_inner_html: "{inner_html}",
541            }
542        }
543    }
544}
545
546/// Simple rich text editor variant with fewer options
547#[derive(Props, Clone, PartialEq)]
548pub struct SimpleRichTextProps {
549    /// Current HTML content
550    #[props(default)]
551    pub value: String,
552    /// Change handler
553    #[props(default)]
554    pub on_change: Option<EventHandler<String>>,
555    /// Placeholder text
556    #[props(default)]
557    pub placeholder: Option<String>,
558    /// Disabled state
559    #[props(default = false)]
560    pub disabled: bool,
561}
562
563/// Simple rich text editor with basic formatting only
564#[component]
565pub fn SimpleRichText(props: SimpleRichTextProps) -> Element {
566    let features = RichTextFeatures {
567        bold: true,
568        italic: true,
569        underline: true,
570        strikethrough: false,
571        heading: false,
572        bullet_list: true,
573        numbered_list: true,
574        link: true,
575        code_block: false,
576        quote: false,
577        align_left: false,
578        align_center: false,
579        align_right: false,
580    };
581
582    rsx! {
583        RichTextEditor {
584            value: props.value,
585            on_change: props.on_change,
586            placeholder: props.placeholder,
587            disabled: props.disabled,
588            features: features,
589        }
590    }
591}
592
593/// Minimal rich text editor with only text formatting
594#[derive(Props, Clone, PartialEq)]
595pub struct MinimalRichTextProps {
596    /// Current HTML content
597    #[props(default)]
598    pub value: String,
599    /// Change handler
600    #[props(default)]
601    pub on_change: Option<EventHandler<String>>,
602    /// Placeholder text
603    #[props(default)]
604    pub placeholder: Option<String>,
605    /// Disabled state
606    #[props(default = false)]
607    pub disabled: bool,
608}
609
610/// Minimal rich text editor with text formatting only
611#[component]
612pub fn MinimalRichText(props: MinimalRichTextProps) -> Element {
613    let features = RichTextFeatures {
614        bold: true,
615        italic: true,
616        underline: true,
617        strikethrough: false,
618        heading: false,
619        bullet_list: false,
620        numbered_list: false,
621        link: false,
622        code_block: false,
623        quote: false,
624        align_left: false,
625        align_center: false,
626        align_right: false,
627    };
628
629    rsx! {
630        RichTextEditor {
631            value: props.value,
632            on_change: props.on_change,
633            placeholder: props.placeholder,
634            disabled: props.disabled,
635            min_height: "100px",
636            features: features,
637        }
638    }
639}
640
641/// Full-featured rich text editor with all formatting options
642#[derive(Props, Clone, PartialEq)]
643pub struct FullRichTextProps {
644    /// Current HTML content
645    #[props(default)]
646    pub value: String,
647    /// Change handler
648    #[props(default)]
649    pub on_change: Option<EventHandler<String>>,
650    /// Placeholder text
651    #[props(default)]
652    pub placeholder: Option<String>,
653    /// Disabled state
654    #[props(default = false)]
655    pub disabled: bool,
656    /// Minimum height (default: "300px")
657    #[props(default = "300px")]
658    pub min_height: &'static str,
659    /// Maximum height
660    #[props(default)]
661    pub max_height: Option<String>,
662}
663
664/// Full-featured rich text editor with all formatting options
665#[component]
666pub fn FullRichText(props: FullRichTextProps) -> Element {
667    rsx! {
668        RichTextEditor {
669            value: props.value,
670            on_change: props.on_change,
671            placeholder: props.placeholder,
672            disabled: props.disabled,
673            min_height: props.min_height,
674            max_height: props.max_height,
675            features: RichTextFeatures::default(),
676        }
677    }
678}