Files
Ely-GPUI-Components/src/documents/rich.rs
T
ZacharyZhang-NY 6a9ce8a2c3 T21a: Documents, editing
- markdown read with pulldown-cmark: marks for live styling, blocks, links, words
- forms::Highlight gains weight, slant and strike; measure and paint share runs
- Inter Italic embedded, so emphasis reads slanted
- forms::mention's popup and keys become Suggestions, shared with the editors
- RichTextEditor with FixedFormatToolbar, the floating toolbar, LinkEditor,
  @ and : suggestions, WordCount and ReadingTime
- MarkdownEditor: visual (a pressed block opens as source), source, split
- BlockEditor: prose, lists, to-dos, quotes, callouts, toggles, code, math,
  diagrams the app draws, dividers, tables that grow and merge, pictures to
  resize and align, cards for video and pages, columns, synced text; slash menu,
  markdown shortcuts, handles that drag and open a menu, one undo for all
- TodoItem and Checklist
- icons: heading-1..3, workflow, merge
- gallery: the Documents page with scripted states
2026-09-26 03:32:24 -04:00

187 lines
6.5 KiB
Rust

use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, Entity, InteractiveElement, IntoElement, KeyBinding, ParentElement,
RenderOnce, SharedString, Styled, Window, actions, div, prelude::*,
};
use super::{
counts::{ReadingTime, WordCount},
format::{Format, active, format},
link::LinkEditor,
suggest::offers,
toolbar::{FixedFormatToolbar, INLINE, tool},
};
use crate::{
forms::{Run, TextInput},
overlays::FloatingToolbar,
theme::{ActiveTheme, Radius, TextSize},
};
actions!(ely_rich_text, [ToggleBold, ToggleItalic, EditLink]);
const CONTEXT: &str = "ElyRichText";
/// Binds the rich text keys. `init` calls it.
pub(crate) fn bind_keys(cx: &mut App) {
cx.bind_keys([
KeyBinding::new("secondary-b", ToggleBold, Some(CONTEXT)),
KeyBinding::new("secondary-i", ToggleItalic, Some(CONTEXT)),
KeyBinding::new("secondary-k", EditLink, Some(CONTEXT)),
]);
}
/// Markdown that reads as it is written: styles show as they are typed, the toolbar above and the one over a selection apply them, a link opens its editor, `@` and `:` suggest people and emoji, and the foot counts words and minutes.
#[derive(IntoElement)]
pub struct RichTextEditor {
id: ElementId,
field: Entity<TextInput>,
people: Vec<SharedString>,
end: Option<AnyElement>,
beside: Option<AnyElement>,
}
impl RichTextEditor {
/// Give the field several lines and `markdown_highlights` as its highlighter.
pub fn new(id: impl Into<ElementId>, field: &Entity<TextInput>) -> Self {
Self {
id: id.into(),
field: field.clone(),
people: Vec::new(),
end: None,
beside: None,
}
}
/// Sits at the toolbar's far end, such as a switch of views.
pub fn toolbar_end(mut self, element: impl IntoElement) -> Self {
self.end = Some(element.into_any_element());
self
}
/// Sits beside the text, as a preview does.
pub fn beside(mut self, element: impl IntoElement) -> Self {
self.beside = Some(element.into_any_element());
self
}
/// Handles `@` suggests.
pub fn people(mut self, handles: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
self.people = handles.into_iter().map(Into::into).collect();
self
}
}
impl RenderOnce for RichTextEditor {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let input = self.field.read(cx);
assert!(
input.is_multi_line(),
"rich text {:?} needs a field of several lines",
self.id
);
let text = SharedString::from(input.text().to_string());
let pressed = active(input.text(), input.selection());
let linking = window.use_keyed_state((self.id.clone(), "linking"), cx, |_, _| false);
let set_linking = |open: bool| -> Run {
let linking = linking.clone();
Rc::new(move |_, cx| {
log::info!(
"rich text: link editor {}",
if open { "opens" } else { "closes" }
);
linking.update(cx, |linking, cx| {
*linking = open;
cx.notify();
});
})
};
let (open_link, close_link) = (set_linking(true), set_linking(false));
let float_id: ElementId = (self.id.clone(), "float").into();
let floating = FloatingToolbar::new(float_id.clone(), &self.field).children(
INLINE.iter().map(|tool_of| {
let on = pressed.contains(&tool_of.0);
tool(
&float_id,
&self.field,
*tool_of,
on,
Some(open_link.clone()),
)
}),
);
let suggestions = offers(
(self.id.clone(), "offers").into(),
&self.field,
&self.people,
None,
cx,
)
.wrap(self.field.clone(), window, cx);
let theme = cx.theme();
let colors = theme.colors.clone();
let (bold, italic, link) = (self.field.clone(), self.field.clone(), open_link.clone());
div()
.id(self.id.clone())
.key_context(CONTEXT)
.on_action(move |_: &ToggleBold, _, cx| format(&bold, Format::Bold, cx))
.on_action(move |_: &ToggleItalic, _, cx| format(&italic, Format::Italic, cx))
.on_action(move |_: &EditLink, window, cx| link(window, cx))
.flex()
.flex_col()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(colors.border)
.bg(colors.surface)
.child(
div()
.flex()
.items_center()
.justify_between()
.px_2()
.border_b_1()
.border_color(colors.border)
.child(
FixedFormatToolbar::new((self.id.clone(), "tools"), &self.field)
.on_link(move |window, cx| open_link(window, cx)),
)
.children(self.end),
)
.child(
div()
.flex()
.child(div().flex_1().min_w_0().px_4().py_3().child(suggestions))
.children(self.beside.map(|beside| {
div()
.flex_1()
.min_w_0()
.px_4()
.py_3()
.border_l_1()
.border_color(colors.border)
.child(beside)
})),
)
.child(floating)
.child(
div()
.flex()
.justify_between()
.px_4()
.py_2()
.border_t_1()
.border_color(colors.border)
.text_size(theme.text_size(TextSize::Xs))
.child(WordCount::new(text.clone()))
.child(ReadingTime::new(text)),
)
.when(*linking.read(cx), |editor| {
editor.child(LinkEditor::new(
(self.id.clone(), "link"),
&self.field,
move |window, cx| close_link(window, cx),
))
})
}
}