tablo_core/schema/fields/
rich_text.rs1use 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, render_field};
16use crate::{
17 FieldRuleFault,
18 rich_text::{PROSE, Tool, clean_with, text_length},
19};
20
21pub struct RichTextField<F = ()>(pub(super) Field, pub(super) PhantomData<fn() -> F>);
45
46pub(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 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 pub(crate) fn clean(&self, posted: &str) -> String {
75 clean_with(posted, self.tools())
76 }
77
78 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 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 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 pub fn placeholder(mut self, placeholder: impl Into<crate::Text>) -> Self {
138 self.rich().placeholder = Some(placeholder.into());
139 self
140 }
141
142 pub fn min_length(mut self, chars: usize) -> Self {
145 self.rich().min_length = Some(chars);
146 self
147 }
148
149 pub fn max_length(mut self, chars: usize) -> Self {
151 self.rich().max_length = Some(chars);
152 self
153 }
154}
155
156impl Field {
157 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 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 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 id: String,
198 disabled: bool,
199 ) -> Result<BoxView<'a>> {
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 name = self.name().to_string();
205 let placeholder = rich.placeholder.clone();
206 let value = value.unwrap_or("").to_string();
207 let tools = rich.tools().map(Tool::key).collect::<Vec<_>>().join(" ");
208 let groups: Vec<Vec<Tool>> = rich.toolbar.clone();
209 let content_class = format!("{PROSE} {EDITOR}");
210 let control = view! {
211 cx =>
212 <div
213 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"
214 data-rich-text=""
215 data-rich-text-tools=(tools)
216 >
217 <div
218 class="flex flex-wrap gap-1 border-b border-border bg-muted/40 p-1"
219 role="toolbar"
220 aria-label=(crate::t!(cx, "tablo-rich-toolbar"))
221 aria-controls=(id.clone())
222 data-rich-text-toolbar=""
223 hidden=(true)
224 >
225 for group in groups {
226 <div class="flex gap-0.5" role="group">
227 for tool in group {
228 <button
229 type="button"
230 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"
231 tabindex="-1"
232 title=(crate::t!(cx, tool.label()))
233 aria-label=(crate::t!(cx, tool.label()))
234 aria-pressed=(toggles(tool).then_some("false"))
235 disabled=(disabled)
236 data-rich-text-tool=(tool.key())
237 data-rich-text-prompt=((tool == Tool::Link).then(
238 || crate::t!(cx, "tablo-rich-link-url"),
239 ))
240 >
241 icon(data: tool_icon(tool))
242 </button>
243 }
244 </div>
245 }
246 </div>
247 <div class=(content_class) data-rich-text-content="" hidden=(true)></div>
248 ui_textarea(
249 attrs: attributes! {
250 id=(id)
251 class="rounded-none border-0 focus-visible:ring-0 focus-visible:ring-offset-0"
252 name=(name)
253 placeholder=(placeholder)
254 rows=(8)
255 required=(required)
256 disabled=(disabled)
257 aria-required=(required.then_some("true"))
258 aria-invalid=(aria_invalid)
259 aria-describedby=(described_by)
260 data-rich-text-source=""
261 },
262 (value)
263 )
264 </div>
265 }
266 .boxed();
267 render_field(
268 cx,
269 &chrome,
270 &self.label(),
271 required,
272 attributes! {},
273 control,
274 )
275 }
276}
277
278const EDITOR: &str = "px-3 py-2 [&_.ProseMirror]:min-h-32 [&_.ProseMirror]:outline-none \
281 [&_.is-editor-empty:first-child]:before:pointer-events-none \
282 [&_.is-editor-empty:first-child]:before:float-left [&_.is-editor-empty:first-child]:before:h-0 \
283 [&_.is-editor-empty:first-child]:before:text-muted-foreground \
284 [&_.is-editor-empty:first-child]:before:content-[attr(data-placeholder)]";
285
286fn toggles(tool: Tool) -> bool {
288 !matches!(tool, Tool::Undo | Tool::Redo | Tool::HorizontalRule)
289}
290
291fn tool_icon(tool: Tool) -> IconData {
292 match tool {
293 Tool::Bold => icons::BOLD,
294 Tool::Italic => icons::ITALIC,
295 Tool::Underline => icons::UNDERLINE,
296 Tool::Strike => icons::STRIKETHROUGH,
297 Tool::Code => icons::CODE,
298 Tool::Link => icons::LINK,
299 Tool::Heading1 => icons::HEADING_1,
300 Tool::Heading2 => icons::HEADING_2,
301 Tool::Heading3 => icons::HEADING_3,
302 Tool::BulletList => icons::LIST,
303 Tool::OrderedList => icons::LIST_ORDERED,
304 Tool::Blockquote => icons::TEXT_QUOTE,
305 Tool::CodeBlock => icons::SQUARE_CODE,
306 Tool::HorizontalRule => icons::MINUS,
307 Tool::Undo => icons::UNDO_2,
308 Tool::Redo => icons::REDO_2,
309 }
310}
311
312#[cfg(test)]
313mod tests;