Skip to main content

tablo_core/schema/fields/
rich_text.rs

1//! The rich text control (ADR-0032): a `<textarea>` holding the HTML, which
2//! `assets/rich_text.js` replaces with an editor whose toolbar the field declares.
3
4use std::marker::PhantomData;
5
6use tablo_ui::{icons, textarea as ui_textarea};
7use toasty::stmt::Path;
8use topcoat::{
9    Result,
10    context::Cx,
11    icon::{IconData, icon},
12    view::*,
13};
14
15use super::{ControlKind, Field, Posting, render_field};
16use crate::{
17    FieldRuleFault,
18    rich_text::{PROSE, Tool, clean_with, text_length},
19};
20
21/// A rich text field: [`Field::rich_text`], or a record form's text control made one with
22/// [`TextField::rich_text`](crate::TextField::rich_text) (`F` is the form).
23///
24/// - The toolbar is the schema. `.toolbar(..)` takes groups of `Tool`s, `Tool::DEFAULT` unless
25///   declared. Each tool names the elements it produces: `Tool::Bold` a `<strong>`, `Tool::Link` an
26///   `<a href>`, `Tool::BulletList` a `<ul>` of `<li>`. The editor registers only those, so a tool
27///   left off the toolbar cannot be typed and drops from a paste. The editor takes a link only to a
28///   URL the server keeps, and a code block's language, which the editor records as a class, is not
29///   stored.
30/// - The server cleans what it reads. A submission keeps only the elements and attributes its
31///   field's tools produce, before the record form parses it: a `<script>`, a `style`, an `on…`
32///   handler and a link to a scheme other than `http`, `https` or `mailto` never reach the column,
33///   and every link gets `rel="noopener noreferrer nofollow"`. A document with no text reads as an
34///   empty submission, so the field's blank answer applies, and the empty paragraphs the editor
35///   leaves at a document's end are dropped.
36/// - Length rules count the text. `.min_length(n)` and `.max_length(n)` count the characters a
37///   reader sees, not the markup.
38/// - Without JavaScript the control is a `<textarea>` of the HTML, cleaned the same way on submit.
39///   The editor is TipTap, which the page loads only when it holds a rich text control.
40/// - A text column moving to rich text needs its values moved too. The field reads what the column
41///   stores as HTML, so `a<b` loses its `<b`, and each edit stores what it read. Rewrite each
42///   stored value through `tablo::rich_text::from_text`, which escapes it and wraps its paragraphs,
43///   before the field edits it.
44pub struct RichTextField<F = ()>(pub(super) Field, pub(super) PhantomData<fn() -> F>);
45
46/// The rich text control's toolbar, placeholder and length rules.
47pub(crate) struct RichTextControl {
48    pub(super) toolbar: Vec<Vec<Tool>>,
49    pub(super) placeholder: Option<crate::Text>,
50    pub(super) min_length: Option<usize>,
51    pub(super) max_length: Option<usize>,
52    /// The input type of the text control a field of a type other than text gave up, a mistake.
53    pub(super) non_text: Option<&'static str>,
54}
55
56impl Default for RichTextControl {
57    fn default() -> Self {
58        Self {
59            toolbar: Tool::DEFAULT.iter().map(|group| group.to_vec()).collect(),
60            placeholder: None,
61            min_length: None,
62            max_length: None,
63            non_text: None,
64        }
65    }
66}
67
68impl RichTextControl {
69    fn tools(&self) -> impl Iterator<Item = Tool> + '_ {
70        self.toolbar.iter().flatten().copied()
71    }
72
73    /// `posted` keeping only what the toolbar's tools produce, `""` when it holds no text.
74    pub(crate) fn clean(&self, posted: &str) -> String {
75        clean_with(posted, self.tools())
76    }
77
78    /// The sentence refusing `value`, a cleaned, non-empty submission, for the length rule it
79    /// breaks. A length counts the characters a reader sees, not the markup.
80    pub(crate) fn refusal(&self, label: &crate::Text, value: &str) -> Option<crate::Text> {
81        let length = text_length(value);
82        if let Some(min) = self.min_length.filter(|min| length < *min) {
83            return Some(crate::text!(
84                "tablo-field-min-length",
85                label = label,
86                min = min
87            ));
88        }
89        let max = self.max_length.filter(|max| length > *max)?;
90        Some(crate::text!(
91            "tablo-field-max-length",
92            label = label,
93            max = max
94        ))
95    }
96
97    /// What is wrong with the declared rules.
98    pub(crate) fn rule_faults(&self) -> Vec<FieldRuleFault> {
99        let mut faults: Vec<_> = self
100            .non_text
101            .map(FieldRuleFault::NotText)
102            .into_iter()
103            .collect();
104        if let (Some(min), Some(max)) = (self.min_length, self.max_length)
105            && min > max
106        {
107            faults.push(FieldRuleFault::EmptyRange);
108        }
109        faults
110    }
111}
112
113impl<F> RichTextField<F> {
114    fn rich(&mut self) -> &mut RichTextControl {
115        match &mut self.0.control {
116            ControlKind::RichText(rich) => rich,
117            _ => unreachable!("a RichTextField holds a rich text control"),
118        }
119    }
120
121    /// The toolbar's groups of [`Tool`]s, in order, which also decide what the field keeps: a
122    /// submission loses every element no tool here produces. [`Tool::DEFAULT`] by default.
123    ///
124    /// ```rust
125    /// # #[derive(Debug, Clone, toasty::Model)]
126    /// # struct Post { #[key] #[auto] id: uuid::Uuid, summary: String }
127    /// use tablo_core::{Field, Tool};
128    ///
129    /// Field::rich_text(Post::fields().summary()).toolbar(&[&[Tool::Bold, Tool::Italic, Tool::Link]]);
130    /// ```
131    pub fn toolbar(mut self, groups: &[&[Tool]]) -> Self {
132        self.rich().toolbar = groups.iter().map(|group| group.to_vec()).collect();
133        self
134    }
135
136    /// The text the empty editor shows.
137    pub fn placeholder(mut self, placeholder: impl Into<crate::Text>) -> Self {
138        self.rich().placeholder = Some(placeholder.into());
139        self
140    }
141
142    /// Refuse a value whose text holds fewer than `chars` characters. The markup does not count,
143    /// and a value with no text is the field's blank answer, which no rule refuses.
144    pub fn min_length(mut self, chars: usize) -> Self {
145        self.rich().min_length = Some(chars);
146        self
147    }
148
149    /// Refuse a value whose text holds more than `chars` characters. The markup does not count.
150    pub fn max_length(mut self, chars: usize) -> Self {
151        self.rich().max_length = Some(chars);
152        self
153    }
154}
155
156impl Field {
157    /// A rich text field over a `String` or `Option<String>` column, storing HTML its toolbar's
158    /// [`Tool`]s produce: the server removes anything else from a submission before it parses it.
159    /// A record form's text control becomes one with
160    /// [`TextField::rich_text`](super::TextField::rich_text).
161    ///
162    /// ```rust
163    /// # #[derive(Debug, Clone, toasty::Model)]
164    /// # struct Post { #[key] #[auto] id: uuid::Uuid, body: String }
165    /// use tablo_core::Field;
166    ///
167    /// Field::rich_text(Post::fields().body()).placeholder("The full story…");
168    /// ```
169    pub fn rich_text<M, T>(lens: impl Into<Path<M, T>>) -> RichTextField
170    where
171        M: toasty::schema::Model,
172        T: toasty::schema::Field<Inner = String>,
173    {
174        RichTextField::new(Self::bound(
175            super::Binding::of::<M, T>(&lens.into()),
176            ControlKind::RichText(RichTextControl::default()),
177        ))
178    }
179
180    /// A rich text field posting `name`, a key no column binds: a
181    /// [`FormInput`](crate::FormInput) field.
182    pub fn rich_text_input(name: impl Into<String>) -> RichTextField {
183        RichTextField::new(Self::bound(
184            Self::named(name.into()),
185            ControlKind::RichText(RichTextControl::default()),
186        ))
187    }
188
189    /// Renders a rich text field's control: the toolbar and the editor the script mounts, over
190    /// the `<textarea>` that holds the HTML and posts it.
191    pub(super) fn render_rich_text<'a>(
192        &self,
193        rich: &RichTextControl,
194        cx: &'a Cx,
195        value: Option<&str>,
196        error: Option<&crate::values::FieldError>,
197        posting: Posting,
198    ) -> Result<BoxView<'a>> {
199        let Posting { id, name, disabled } = posting;
200        let required = self.is_required() && !disabled;
201        let chrome = self.chrome(cx, id.clone(), error, None);
202        let aria_invalid = chrome.aria_invalid();
203        let described_by = chrome.described_by();
204        let placeholder = rich.placeholder.clone();
205        let value = value.unwrap_or("").to_string();
206        let tools = rich.tools().map(Tool::key).collect::<Vec<_>>().join(" ");
207        let groups: Vec<Vec<Tool>> = rich.toolbar.clone();
208        let content_class = format!("{PROSE} {EDITOR}");
209        let control = view! {
210            <div
211                class="overflow-hidden rounded-lg border border-border focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background has-[[aria-invalid=true]]:border-destructive"
212                data-rich-text=""
213                data-rich-text-tools=(tools)
214            >
215                <div
216                    class="flex flex-wrap gap-1 border-b border-border bg-muted/40 p-1"
217                    role="toolbar"
218                    aria-label=(crate::t!(cx, "tablo-rich-toolbar"))
219                    aria-controls=(id.clone())
220                    data-rich-text-toolbar=""
221                    hidden=(true)
222                >
223                    for group in groups {
224                        <div class="flex gap-0.5" role="group">
225                            for tool in group {
226                                <button
227                                    type="button"
228                                    class="inline-flex size-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 aria-pressed:bg-accent aria-pressed:text-foreground [&>svg]:size-4"
229                                    tabindex="-1"
230                                    title=(crate::t!(cx, tool.label()))
231                                    aria-label=(crate::t!(cx, tool.label()))
232                                    aria-pressed=(toggles(tool).then_some("false"))
233                                    disabled=(disabled)
234                                    data-rich-text-tool=(tool.key())
235                                    data-rich-text-prompt=((tool == Tool::Link).then(
236                                        || crate::t!(cx, "tablo-rich-link-url"),
237                                    ))
238                                >
239                                    icon(data: tool_icon(tool))
240                                </button>
241                            }
242                        </div>
243                    }
244                </div>
245                <div class=(content_class) data-rich-text-content="" hidden=(true)></div>
246                ui_textarea(
247                    attrs: attributes! {
248                        id=(id)
249                        class="rounded-none border-0 focus-visible:ring-0 focus-visible:ring-offset-0"
250                        name=(name)
251                        placeholder=(placeholder)
252                        rows=(8)
253                        required=(required)
254                        disabled=(disabled)
255                        aria-required=(required.then_some("true"))
256                        aria-invalid=(aria_invalid)
257                        aria-describedby=(described_by)
258                        data-rich-text-source=""
259                    },
260                    (value)
261                )
262            </div>
263        }
264        .boxed();
265        render_field(
266            cx,
267            &chrome,
268            &self.label(),
269            required,
270            attributes! {},
271            control,
272        )
273    }
274}
275
276/// The editing surface's own classes, beside [`PROSE`]: its padding and height, and the
277/// placeholder TipTap marks on an empty document.
278const EDITOR: &str = "px-3 py-2 [&_.ProseMirror]:min-h-32 [&_.ProseMirror]:outline-none \
279    [&_.is-editor-empty:first-child]:before:pointer-events-none \
280    [&_.is-editor-empty:first-child]:before:float-left [&_.is-editor-empty:first-child]:before:h-0 \
281    [&_.is-editor-empty:first-child]:before:text-muted-foreground \
282    [&_.is-editor-empty:first-child]:before:content-[attr(data-placeholder)]";
283
284/// Whether the tool's button shows a state, pressed while the selection is in what it produces.
285fn toggles(tool: Tool) -> bool {
286    !matches!(tool, Tool::Undo | Tool::Redo | Tool::HorizontalRule)
287}
288
289fn tool_icon(tool: Tool) -> IconData {
290    match tool {
291        Tool::Bold => icons::BOLD,
292        Tool::Italic => icons::ITALIC,
293        Tool::Underline => icons::UNDERLINE,
294        Tool::Strike => icons::STRIKETHROUGH,
295        Tool::Code => icons::CODE,
296        Tool::Link => icons::LINK,
297        Tool::Heading1 => icons::HEADING_1,
298        Tool::Heading2 => icons::HEADING_2,
299        Tool::Heading3 => icons::HEADING_3,
300        Tool::BulletList => icons::LIST,
301        Tool::OrderedList => icons::LIST_ORDERED,
302        Tool::Blockquote => icons::TEXT_QUOTE,
303        Tool::CodeBlock => icons::SQUARE_CODE,
304        Tool::HorizontalRule => icons::MINUS,
305        Tool::Undo => icons::UNDO_2,
306        Tool::Redo => icons::REDO_2,
307    }
308}
309
310#[cfg(test)]
311mod tests;