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
This commit is contained in:
1 parent
0ffa001a61
commit
6a9ce8a2c3
46 files changed
+5635
-193
No files matched your search
@@ -0,0 +1,288 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, Context, ElementId, InteractiveElement, IntoElement, MouseButton,
|
||||
ParentElement, Rems, Render, SharedString, Styled, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::{Block, BlockData, BlockEditor, BlockKind, matching};
|
||||
use crate::{
|
||||
buttons::{ButtonVariant, IconButton},
|
||||
documents::suggest::{Offer, offers, replace_trigger},
|
||||
forms::{Backspace, Choice, Down, Enter, Pick, Redo, Submit, Undo, Up},
|
||||
menus::{Menu, MenuItem, OverflowMenu},
|
||||
motion::Reorder,
|
||||
primitives::IconName,
|
||||
theme::{ActiveTheme, ControlSize, Palette, Radius, TextSize},
|
||||
};
|
||||
|
||||
/// Theme values a block reads, taken before its fields borrow the context.
|
||||
pub(crate) struct Look {
|
||||
pub colors: Palette,
|
||||
pub mono: SharedString,
|
||||
pub radius: Rems,
|
||||
pub small: Rems,
|
||||
pub tiny: Rems,
|
||||
pub headings: [Rems; 3],
|
||||
}
|
||||
|
||||
impl Look {
|
||||
fn new(cx: &App) -> Self {
|
||||
let theme = cx.theme();
|
||||
Self {
|
||||
colors: theme.colors.clone(),
|
||||
mono: theme.mono_family.clone(),
|
||||
radius: theme.radius(Radius::Md),
|
||||
small: theme.text_size(TextSize::Sm),
|
||||
tiny: theme.text_size(TextSize::Xs),
|
||||
headings: [TextSize::Xxl, TextSize::Xl, TextSize::Lg].map(|size| theme.text_size(size)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockEditor {
|
||||
pub(crate) fn id(cx: &Context<Self>) -> ElementId {
|
||||
("ely-blocks", cx.entity_id()).into()
|
||||
}
|
||||
|
||||
/// A slash pick: an emptied block becomes the kind; one with text gets the kind below it.
|
||||
fn pick_kind(
|
||||
&mut self,
|
||||
key: u64,
|
||||
kind: BlockKind,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let ix = self.index(key);
|
||||
let empty = self.blocks[ix]
|
||||
.fields
|
||||
.first()
|
||||
.is_some_and(|field| field.read(cx).is_empty());
|
||||
let data = BlockData {
|
||||
texts: vec![String::new(); kind.fields()],
|
||||
kind: kind.clone(),
|
||||
};
|
||||
let placed = if empty {
|
||||
self.turn_into(key, kind.clone(), window, cx);
|
||||
key
|
||||
} else {
|
||||
self.insert(Some(key), data, window, cx)
|
||||
};
|
||||
if kind == BlockKind::Divider {
|
||||
self.insert(
|
||||
Some(placed),
|
||||
BlockData::new(BlockKind::Paragraph, [""]),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A text field with its keys and suggests: `/` in prose opens the kinds.
|
||||
pub(crate) fn field_view(
|
||||
&self,
|
||||
block: &Block,
|
||||
ix: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let (key, field) = (block.key, block.fields[ix].clone());
|
||||
let entity = cx.entity();
|
||||
let keys = div()
|
||||
.w_full()
|
||||
.capture_action(cx.listener(move |editor, _: &Enter, window, cx| {
|
||||
if editor.on_enter(key, ix, window, cx) {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}))
|
||||
.capture_action(cx.listener(move |editor, _: &Backspace, window, cx| {
|
||||
if editor.on_backspace(key, ix, window, cx) {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}))
|
||||
.capture_action(cx.listener(move |editor, _: &Up, window, cx| {
|
||||
if editor.on_vertical(key, ix, false, window, cx) {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}))
|
||||
.capture_action(cx.listener(move |editor, _: &Down, window, cx| {
|
||||
if editor.on_vertical(key, ix, true, window, cx) {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}))
|
||||
.capture_action(cx.listener(move |editor, _: &Submit, window, cx| {
|
||||
cx.stop_propagation();
|
||||
editor.on_submit(key, window, cx);
|
||||
}))
|
||||
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
|
||||
.child(field.clone());
|
||||
let slash = move |query: &str, at: usize, caret: usize| -> (Vec<Choice>, Pick) {
|
||||
let kinds = matching(query);
|
||||
let rows = kinds
|
||||
.iter()
|
||||
.map(|kind| Choice::new(kind.label(), kind.label()).icon(kind.icon()))
|
||||
.collect();
|
||||
let (entity, field) = (entity.clone(), field.clone());
|
||||
let pick: Pick = Rc::new(move |pick: usize, window: &mut Window, cx: &mut App| {
|
||||
replace_trigger(&field, at, caret, "", cx);
|
||||
let kind = kinds[pick].clone();
|
||||
entity.update(cx, |editor, cx| editor.pick_kind(key, kind, window, cx));
|
||||
});
|
||||
(rows, pick)
|
||||
};
|
||||
let extra: Option<(char, Offer)> =
|
||||
(block.kind.is_prose() && ix == 0).then_some(('/', &slash));
|
||||
let id = (Self::id(cx), format!("offers-{key}-{ix}")).into();
|
||||
offers(id, &block.fields[ix], &self.people, extra, cx)
|
||||
.wrap(keys, window, cx)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The handle's menu: turn into another kind, duplicate or delete.
|
||||
fn menu(&self, key: u64, cx: &mut Context<Self>) -> Menu {
|
||||
let entity = cx.entity();
|
||||
let turn = super::kind::offered()
|
||||
.into_iter()
|
||||
.fold(Menu::new(), |menu, kind| {
|
||||
let entity = entity.clone();
|
||||
menu.item(MenuItem::new(kind.label()).icon(kind.icon()).on_click(
|
||||
move |window, cx| {
|
||||
let kind = kind.clone();
|
||||
entity.update(cx, |editor, cx| editor.turn_into(key, kind, window, cx));
|
||||
},
|
||||
))
|
||||
});
|
||||
let (copy, gone) = (entity.clone(), entity);
|
||||
Menu::new()
|
||||
.item(MenuItem::submenu("Turn into", turn).icon(IconName::RefreshCw))
|
||||
.item(
|
||||
MenuItem::new("Duplicate")
|
||||
.icon(IconName::Copy)
|
||||
.on_click(move |window, cx| {
|
||||
copy.update(cx, |editor, cx| editor.duplicate(key, window, cx));
|
||||
}),
|
||||
)
|
||||
.separator()
|
||||
.item(
|
||||
MenuItem::new("Delete")
|
||||
.icon(IconName::Trash2)
|
||||
.on_click(move |window, cx| {
|
||||
gone.update(cx, |editor, cx| editor.remove(key, window, cx));
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for BlockEditor {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let id = Self::id(cx);
|
||||
let look = Look::new(cx);
|
||||
let colors = look.colors.clone();
|
||||
let focused = self.focused(window, cx).map(|(key, _)| key);
|
||||
let mut number = 0;
|
||||
let mut rows: Vec<(SharedString, AnyElement)> = Vec::new();
|
||||
for ix in 0..self.blocks.len() {
|
||||
let block = &self.blocks[ix];
|
||||
number = if block.kind == BlockKind::Numbered {
|
||||
number + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let key = block.key;
|
||||
let group = SharedString::from(format!("block-{key}"));
|
||||
let entity = cx.entity();
|
||||
let plus = IconButton::new((id.clone(), format!("plus-{key}")), IconName::Plus)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Add a block below")
|
||||
.on_click(move |_, window, cx| {
|
||||
entity.update(cx, |editor, cx| {
|
||||
editor.insert(
|
||||
Some(key),
|
||||
BlockData::new(BlockKind::Paragraph, ["/"]),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
});
|
||||
let handle =
|
||||
OverflowMenu::new((id.clone(), format!("handle-{key}")), self.menu(key, cx))
|
||||
.icon(IconName::GripVertical)
|
||||
.tooltip("Drag to move, press for more");
|
||||
let content = self.content(&self.blocks[ix], number, &look, window, cx);
|
||||
let row = div()
|
||||
.id((id.clone(), format!("row-{key}")))
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(move |editor, _, window, cx| {
|
||||
let end = editor.blocks[editor.index(key)]
|
||||
.fields
|
||||
.first()
|
||||
.map(|field| field.read(cx).text().len());
|
||||
if end.is_some() {
|
||||
editor.focus_field(key, 0, end, window, cx);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.group(group.clone())
|
||||
.relative()
|
||||
.pl_16()
|
||||
.py_0p5()
|
||||
.child(content)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.flex()
|
||||
.opacity(if focused == Some(key) { 1.0 } else { 0.0 })
|
||||
.group_hover(group, |gutter| gutter.opacity(1.0))
|
||||
.child(plus)
|
||||
.child(handle),
|
||||
);
|
||||
rows.push((format!("{key}").into(), row.into_any_element()));
|
||||
}
|
||||
let entity = cx.entity();
|
||||
let list = rows.into_iter().fold(
|
||||
Reorder::new((id.clone(), "blocks")).on_reorder(move |from, to, _, cx| {
|
||||
entity.update(cx, |editor, cx| editor.move_block(from, to, cx))
|
||||
}),
|
||||
|list, (key, row)| list.row(key, row),
|
||||
);
|
||||
let start = cx.entity();
|
||||
div()
|
||||
.id(id.clone())
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.capture_action(cx.listener(|editor, _: &Undo, window, cx| {
|
||||
cx.stop_propagation();
|
||||
editor.undo(window, cx);
|
||||
}))
|
||||
.capture_action(cx.listener(|editor, _: &Redo, window, cx| {
|
||||
cx.stop_propagation();
|
||||
editor.redo(window, cx);
|
||||
}))
|
||||
.child(list)
|
||||
.child(
|
||||
div()
|
||||
.id((id, "end"))
|
||||
.min_h_8()
|
||||
.cursor_text()
|
||||
.text_color(colors.fg_subtle)
|
||||
.when(self.blocks.is_empty(), |end| end.child("Press to write"))
|
||||
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
||||
window.prevent_default();
|
||||
start.update(cx, |editor, cx| {
|
||||
let last = editor.blocks.last().map(|block| block.key);
|
||||
editor.insert(
|
||||
last,
|
||||
BlockData::new(BlockKind::Paragraph, [""]),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user