use std::marker::PhantomData;
use tablo_ui::{icons, textarea as ui_textarea};
use toasty::stmt::Path;
use topcoat::{
Result,
context::Cx,
icon::{IconData, icon},
view::*,
};
use super::{ControlKind, Field, Posting, render_field};
use crate::{
FieldRuleFault,
rich_text::{PROSE, Tool, clean_with, text_length},
};
pub struct RichTextField<F = ()>(pub(super) Field, pub(super) PhantomData<fn() -> F>);
pub(crate) struct RichTextControl {
pub(super) toolbar: Vec<Vec<Tool>>,
pub(super) placeholder: Option<crate::Text>,
pub(super) min_length: Option<usize>,
pub(super) max_length: Option<usize>,
pub(super) non_text: Option<&'static str>,
}
impl Default for RichTextControl {
fn default() -> Self {
Self {
toolbar: Tool::DEFAULT.iter().map(|group| group.to_vec()).collect(),
placeholder: None,
min_length: None,
max_length: None,
non_text: None,
}
}
}
impl RichTextControl {
fn tools(&self) -> impl Iterator<Item = Tool> + '_ {
self.toolbar.iter().flatten().copied()
}
pub(crate) fn clean(&self, posted: &str) -> String {
clean_with(posted, self.tools())
}
pub(crate) fn refusal(&self, label: &crate::Text, value: &str) -> Option<crate::Text> {
let length = text_length(value);
if let Some(min) = self.min_length.filter(|min| length < *min) {
return Some(crate::text!(
"tablo-field-min-length",
label = label,
min = min
));
}
let max = self.max_length.filter(|max| length > *max)?;
Some(crate::text!(
"tablo-field-max-length",
label = label,
max = max
))
}
pub(crate) fn rule_faults(&self) -> Vec<FieldRuleFault> {
let mut faults: Vec<_> = self
.non_text
.map(FieldRuleFault::NotText)
.into_iter()
.collect();
if let (Some(min), Some(max)) = (self.min_length, self.max_length)
&& min > max
{
faults.push(FieldRuleFault::EmptyRange);
}
faults
}
}
impl<F> RichTextField<F> {
fn rich(&mut self) -> &mut RichTextControl {
match &mut self.0.control {
ControlKind::RichText(rich) => rich,
_ => unreachable!("a RichTextField holds a rich text control"),
}
}
pub fn toolbar(mut self, groups: &[&[Tool]]) -> Self {
self.rich().toolbar = groups.iter().map(|group| group.to_vec()).collect();
self
}
pub fn placeholder(mut self, placeholder: impl Into<crate::Text>) -> Self {
self.rich().placeholder = Some(placeholder.into());
self
}
pub fn min_length(mut self, chars: usize) -> Self {
self.rich().min_length = Some(chars);
self
}
pub fn max_length(mut self, chars: usize) -> Self {
self.rich().max_length = Some(chars);
self
}
}
impl Field {
pub fn rich_text<M, T>(lens: impl Into<Path<M, T>>) -> RichTextField
where
M: toasty::schema::Model,
T: toasty::schema::Field<Inner = String>,
{
RichTextField::new(Self::bound(
super::Binding::of::<M, T>(&lens.into()),
ControlKind::RichText(RichTextControl::default()),
))
}
pub fn rich_text_input(name: impl Into<String>) -> RichTextField {
RichTextField::new(Self::bound(
Self::named(name.into()),
ControlKind::RichText(RichTextControl::default()),
))
}
pub(super) fn render_rich_text<'a>(
&self,
rich: &RichTextControl,
cx: &'a Cx,
value: Option<&str>,
error: Option<&crate::values::FieldError>,
posting: Posting,
) -> Result<BoxView<'a>> {
let Posting { id, name, disabled } = posting;
let required = self.is_required() && !disabled;
let chrome = self.chrome(cx, id.clone(), error, None);
let aria_invalid = chrome.aria_invalid();
let described_by = chrome.described_by();
let placeholder = rich.placeholder.clone();
let value = value.unwrap_or("").to_string();
let tools = rich.tools().map(Tool::key).collect::<Vec<_>>().join(" ");
let groups: Vec<Vec<Tool>> = rich.toolbar.clone();
let content_class = format!("{PROSE} {EDITOR}");
let control = view! {
<div
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"
data-rich-text=""
data-rich-text-tools=(tools)
>
<div
class="flex flex-wrap gap-1 border-b border-border bg-muted/40 p-1"
role="toolbar"
aria-label=(crate::t!(cx, "tablo-rich-toolbar"))
aria-controls=(id.clone())
data-rich-text-toolbar=""
hidden=(true)
>
for group in groups {
<div class="flex gap-0.5" role="group">
for tool in group {
<button
type="button"
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"
tabindex="-1"
title=(crate::t!(cx, tool.label()))
aria-label=(crate::t!(cx, tool.label()))
aria-pressed=(toggles(tool).then_some("false"))
disabled=(disabled)
data-rich-text-tool=(tool.key())
data-rich-text-prompt=((tool == Tool::Link).then(
|| crate::t!(cx, "tablo-rich-link-url"),
))
>
icon(data: tool_icon(tool))
</button>
}
</div>
}
</div>
<div class=(content_class) data-rich-text-content="" hidden=(true)></div>
ui_textarea(
attrs: attributes! {
id=(id)
class="rounded-none border-0 focus-visible:ring-0 focus-visible:ring-offset-0"
name=(name)
placeholder=(placeholder)
rows=(8)
required=(required)
disabled=(disabled)
aria-required=(required.then_some("true"))
aria-invalid=(aria_invalid)
aria-describedby=(described_by)
data-rich-text-source=""
},
(value)
)
</div>
}
.boxed();
render_field(
cx,
&chrome,
&self.label(),
required,
attributes! {},
control,
)
}
}
const EDITOR: &str = "px-3 py-2 [&_.ProseMirror]:min-h-32 [&_.ProseMirror]:outline-none \
[&_.is-editor-empty:first-child]:before:pointer-events-none \
[&_.is-editor-empty:first-child]:before:float-left [&_.is-editor-empty:first-child]:before:h-0 \
[&_.is-editor-empty:first-child]:before:text-muted-foreground \
[&_.is-editor-empty:first-child]:before:content-[attr(data-placeholder)]";
fn toggles(tool: Tool) -> bool {
!matches!(tool, Tool::Undo | Tool::Redo | Tool::HorizontalRule)
}
fn tool_icon(tool: Tool) -> IconData {
match tool {
Tool::Bold => icons::BOLD,
Tool::Italic => icons::ITALIC,
Tool::Underline => icons::UNDERLINE,
Tool::Strike => icons::STRIKETHROUGH,
Tool::Code => icons::CODE,
Tool::Link => icons::LINK,
Tool::Heading1 => icons::HEADING_1,
Tool::Heading2 => icons::HEADING_2,
Tool::Heading3 => icons::HEADING_3,
Tool::BulletList => icons::LIST,
Tool::OrderedList => icons::LIST_ORDERED,
Tool::Blockquote => icons::TEXT_QUOTE,
Tool::CodeBlock => icons::SQUARE_CODE,
Tool::HorizontalRule => icons::MINUS,
Tool::Undo => icons::UNDO_2,
Tool::Redo => icons::REDO_2,
}
}
#[cfg(test)]
mod tests;