1use crate::styles::Style;
7use crate::theme::{use_style, use_theme};
8use dioxus::prelude::*;
9
10#[derive(Clone, PartialEq, Debug)]
12pub struct RichTextFeatures {
13 pub bold: bool,
15 pub italic: bool,
17 pub underline: bool,
19 pub strikethrough: bool,
21 pub heading: bool,
23 pub bullet_list: bool,
25 pub numbered_list: bool,
27 pub link: bool,
29 pub code_block: bool,
31 pub quote: bool,
33 pub align_left: bool,
35 pub align_center: bool,
37 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#[derive(Props, Clone, PartialEq)]
63pub struct RichTextEditorProps {
64 #[props(default)]
66 pub value: String,
67 #[props(default)]
69 pub on_change: Option<EventHandler<String>>,
70 #[props(default)]
72 pub placeholder: Option<String>,
73 #[props(default = false)]
75 pub disabled: bool,
76 #[props(default = "200px")]
78 pub min_height: &'static str,
79 #[props(default)]
81 pub max_height: Option<String>,
82 #[props(default)]
84 pub features: RichTextFeatures,
85 #[props(default)]
87 pub class: Option<String>,
88}
89
90#[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 handler.call(content());
119 }
120 }
121 };
122
123 rsx! {
124 div {
125 class: "rich-text-editor{class_css}",
126 style: "{container_style}",
127
128 if !props.disabled {
130 RichTextToolbar {
131 features: props.features.clone(),
132 disabled: props.disabled,
133 }
134 }
135
136 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#[derive(Props, Clone, PartialEq)]
152pub struct RichTextToolbarProps {
153 pub features: RichTextFeatures,
155 #[props(default = false)]
157 pub disabled: bool,
158}
159
160#[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 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 if props.features.bold || props.features.italic || props.features.underline || props.features.strikethrough {
247 ToolbarSeparator {}
248 }
249
250 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 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 if props.features.bullet_list || props.features.numbered_list {
301 ToolbarSeparator {}
302 }
303
304 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 if props.features.align_left || props.features.align_center || props.features.align_right {
335 ToolbarSeparator {}
336 }
337
338 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#[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#[derive(Props, Clone, PartialEq)]
388pub struct ToolbarButtonProps {
389 pub title: &'static str,
391 pub icon: &'static str,
393 pub command: &'static str,
395 #[props(default)]
397 pub value: Option<&'static str>,
398 pub style: String,
400 pub hover_style: String,
402}
403
404#[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_command(props.command, props.value);
416 },
417 "{props.icon}"
418 }
419 }
420}
421
422fn execute_command(command: &str, value: Option<&str>) {
424 #[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 #[cfg(not(target_arch = "wasm32"))]
442 {
443 let _ = command;
444 let _ = value;
445 }
446}
447
448#[derive(Props, Clone, PartialEq)]
450pub struct RichTextContentProps {
451 pub value: String,
453 #[props(default)]
455 pub placeholder: Option<String>,
456 #[props(default = false)]
458 pub disabled: bool,
459 pub min_height: &'static str,
461 #[props(default)]
463 pub max_height: Option<String>,
464 pub is_focused: bool,
466 #[props(default)]
468 pub on_input: Option<EventHandler<String>>,
469}
470
471#[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 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 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 div {
535 contenteditable: !props.disabled,
536 style: "min-height: 100%; outline: none;",
537 oninput: on_input,
538
539 dangerous_inner_html: "{inner_html}",
541 }
542 }
543 }
544}
545
546#[derive(Props, Clone, PartialEq)]
548pub struct SimpleRichTextProps {
549 #[props(default)]
551 pub value: String,
552 #[props(default)]
554 pub on_change: Option<EventHandler<String>>,
555 #[props(default)]
557 pub placeholder: Option<String>,
558 #[props(default = false)]
560 pub disabled: bool,
561}
562
563#[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#[derive(Props, Clone, PartialEq)]
595pub struct MinimalRichTextProps {
596 #[props(default)]
598 pub value: String,
599 #[props(default)]
601 pub on_change: Option<EventHandler<String>>,
602 #[props(default)]
604 pub placeholder: Option<String>,
605 #[props(default = false)]
607 pub disabled: bool,
608}
609
610#[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#[derive(Props, Clone, PartialEq)]
643pub struct FullRichTextProps {
644 #[props(default)]
646 pub value: String,
647 #[props(default)]
649 pub on_change: Option<EventHandler<String>>,
650 #[props(default)]
652 pub placeholder: Option<String>,
653 #[props(default = false)]
655 pub disabled: bool,
656 #[props(default = "300px")]
658 pub min_height: &'static str,
659 #[props(default)]
661 pub max_height: Option<String>,
662}
663
664#[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}