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, Posting, 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 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
276const 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
284fn 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;