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,277 @@
|
||||
use gpui::SharedString;
|
||||
|
||||
use crate::primitives::IconName;
|
||||
|
||||
/// Where a picture sits across its column.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Align {
|
||||
Left,
|
||||
#[default]
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// A picture, clip or page shown in a block: its address, and its width as a share of the column.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Media {
|
||||
pub source: SharedString,
|
||||
pub width: f32,
|
||||
pub align: Align,
|
||||
}
|
||||
|
||||
impl Media {
|
||||
pub fn new(source: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
source: source.into(),
|
||||
width: 1.0,
|
||||
align: Align::Center,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a block is. Each holds its text in fields: one for most, a title and a body for a toggle, one per column, a caption for media, one per cell for a table, none for a divider.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum BlockKind {
|
||||
Paragraph,
|
||||
Heading(u8),
|
||||
Bullet,
|
||||
Numbered,
|
||||
Todo(bool),
|
||||
Quote,
|
||||
Callout,
|
||||
/// Open or closed; its body shows while open.
|
||||
Toggle(bool),
|
||||
Code,
|
||||
Math,
|
||||
/// Diagram source, such as Mermaid; the host renders the picture.
|
||||
Diagram,
|
||||
Divider,
|
||||
/// Columns across, the header row first; each `merged` cell, as row and column, folds into the cell on its left.
|
||||
Table {
|
||||
columns: usize,
|
||||
merged: Vec<(usize, usize)>,
|
||||
},
|
||||
Image(Media),
|
||||
Video(Media),
|
||||
Embed(Media),
|
||||
/// Side by side, one field each.
|
||||
Columns(usize),
|
||||
/// Text shared with every other block synced to the same source.
|
||||
Synced(SharedString),
|
||||
}
|
||||
|
||||
impl BlockKind {
|
||||
/// Its name in the slash menu.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Paragraph => "Text",
|
||||
Self::Heading(1) => "Heading 1",
|
||||
Self::Heading(2) => "Heading 2",
|
||||
Self::Heading(_) => "Heading 3",
|
||||
Self::Bullet => "Bulleted list",
|
||||
Self::Numbered => "Numbered list",
|
||||
Self::Todo(_) => "To-do",
|
||||
Self::Quote => "Quote",
|
||||
Self::Callout => "Callout",
|
||||
Self::Toggle(_) => "Toggle",
|
||||
Self::Code => "Code",
|
||||
Self::Math => "Math",
|
||||
Self::Diagram => "Diagram",
|
||||
Self::Divider => "Divider",
|
||||
Self::Table { .. } => "Table",
|
||||
Self::Image(_) => "Image",
|
||||
Self::Video(_) => "Video",
|
||||
Self::Embed(_) => "Embed",
|
||||
Self::Columns(_) => "Columns",
|
||||
Self::Synced(_) => "Synced block",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(&self) -> IconName {
|
||||
match self {
|
||||
Self::Paragraph => IconName::Type,
|
||||
Self::Heading(1) => IconName::Heading1,
|
||||
Self::Heading(2) => IconName::Heading2,
|
||||
Self::Heading(_) => IconName::Heading3,
|
||||
Self::Bullet => IconName::List,
|
||||
Self::Numbered => IconName::ListOrdered,
|
||||
Self::Todo(_) => IconName::ListTodo,
|
||||
Self::Quote => IconName::Quote,
|
||||
Self::Callout => IconName::Info,
|
||||
Self::Toggle(_) => IconName::ChevronRight,
|
||||
Self::Code => IconName::Code,
|
||||
Self::Math => IconName::SquareFunction,
|
||||
Self::Diagram => IconName::Workflow,
|
||||
Self::Divider => IconName::Minus,
|
||||
Self::Table { .. } => IconName::Table,
|
||||
Self::Image(_) => IconName::Image,
|
||||
Self::Video(_) => IconName::Video,
|
||||
Self::Embed(_) => IconName::Globe,
|
||||
Self::Columns(_) => IconName::Columns2,
|
||||
Self::Synced(_) => IconName::RefreshCw,
|
||||
}
|
||||
}
|
||||
|
||||
/// What an empty field of this kind says while it has focus.
|
||||
pub fn placeholder(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Paragraph => "Type '/' for commands",
|
||||
Self::Heading(_) => self.label(),
|
||||
Self::Bullet | Self::Numbered => "List",
|
||||
Self::Todo(_) => "To-do",
|
||||
Self::Quote => "Quote",
|
||||
Self::Callout | Self::Toggle(_) | Self::Columns(_) | Self::Synced(_) => "Write",
|
||||
Self::Code => "Code",
|
||||
Self::Math => "A formula in TeX, such as e^{i\\pi} + 1 = 0",
|
||||
Self::Diagram => "A diagram in Mermaid, such as graph LR; a --> b",
|
||||
Self::Image(_) | Self::Video(_) | Self::Embed(_) => "Add a caption",
|
||||
Self::Divider | Self::Table { .. } => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// How many fields a new block of this kind holds.
|
||||
pub fn fields(&self) -> usize {
|
||||
match self {
|
||||
Self::Divider => 0,
|
||||
Self::Toggle(_) => 2,
|
||||
Self::Columns(count) => *count,
|
||||
Self::Table { columns, .. } => columns * 2,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether it can hold `count` fields: a table any whole rows past its header, the rest their own number.
|
||||
pub fn accepts(&self, count: usize) -> bool {
|
||||
match self {
|
||||
Self::Table { columns, .. } => {
|
||||
*columns > 0 && count >= columns * 2 && count.is_multiple_of(*columns)
|
||||
}
|
||||
kind => count == kind.fields(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Prose: Enter splits it, Backspace at its start turns it back into text or joins it up.
|
||||
pub fn is_prose(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Paragraph
|
||||
| Self::Heading(_)
|
||||
| Self::Bullet
|
||||
| Self::Numbered
|
||||
| Self::Todo(_)
|
||||
| Self::Quote
|
||||
| Self::Callout
|
||||
)
|
||||
}
|
||||
|
||||
/// The kind Enter starts after this one: lists go on, the rest return to text.
|
||||
pub fn next(&self) -> Self {
|
||||
match self {
|
||||
Self::Bullet => Self::Bullet,
|
||||
Self::Numbered => Self::Numbered,
|
||||
Self::Todo(_) => Self::Todo(false),
|
||||
_ => Self::Paragraph,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lines of monospaced source, where Enter breaks a line.
|
||||
pub fn is_source(&self) -> bool {
|
||||
matches!(self, Self::Code | Self::Math | Self::Diagram)
|
||||
}
|
||||
}
|
||||
|
||||
/// The kinds the slash menu offers, in order; media and synced blocks come from their owner.
|
||||
pub(crate) fn offered() -> Vec<BlockKind> {
|
||||
vec![
|
||||
BlockKind::Paragraph,
|
||||
BlockKind::Heading(1),
|
||||
BlockKind::Heading(2),
|
||||
BlockKind::Heading(3),
|
||||
BlockKind::Bullet,
|
||||
BlockKind::Numbered,
|
||||
BlockKind::Todo(false),
|
||||
BlockKind::Quote,
|
||||
BlockKind::Callout,
|
||||
BlockKind::Toggle(true),
|
||||
BlockKind::Code,
|
||||
BlockKind::Math,
|
||||
BlockKind::Diagram,
|
||||
BlockKind::Divider,
|
||||
BlockKind::Table {
|
||||
columns: 2,
|
||||
merged: Vec::new(),
|
||||
},
|
||||
BlockKind::Columns(2),
|
||||
]
|
||||
}
|
||||
|
||||
/// The kinds whose name holds `query`, for the slash menu.
|
||||
pub(crate) fn matching(query: &str) -> Vec<BlockKind> {
|
||||
let query = query.to_lowercase();
|
||||
offered()
|
||||
.into_iter()
|
||||
.filter(|kind| kind.label().to_lowercase().contains(&query))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A paragraph's leading markdown that makes it another kind, and how many bytes it takes; the space after it is typed last.
|
||||
pub(crate) fn shortcut(text: &str) -> Option<(BlockKind, usize)> {
|
||||
let prefixes: [(&str, BlockKind); 11] = [
|
||||
("# ", BlockKind::Heading(1)),
|
||||
("## ", BlockKind::Heading(2)),
|
||||
("### ", BlockKind::Heading(3)),
|
||||
("- ", BlockKind::Bullet),
|
||||
("* ", BlockKind::Bullet),
|
||||
("1. ", BlockKind::Numbered),
|
||||
("[] ", BlockKind::Todo(false)),
|
||||
("[ ] ", BlockKind::Todo(false)),
|
||||
("[x] ", BlockKind::Todo(true)),
|
||||
("> ", BlockKind::Quote),
|
||||
("``` ", BlockKind::Code),
|
||||
];
|
||||
if text == "---" {
|
||||
return Some((BlockKind::Divider, 3));
|
||||
}
|
||||
prefixes
|
||||
.into_iter()
|
||||
.find(|(prefix, _)| text.starts_with(prefix))
|
||||
.map(|(prefix, kind)| (kind, prefix.len()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn markdown_at_a_paragraphs_start_turns_it_into_a_kind() {
|
||||
assert_eq!(shortcut("## Lift"), Some((BlockKind::Heading(2), 3)));
|
||||
assert_eq!(shortcut("[x] done"), Some((BlockKind::Todo(true), 4)));
|
||||
assert_eq!(shortcut("---"), Some((BlockKind::Divider, 3)));
|
||||
assert_eq!(shortcut("#hashtag"), None);
|
||||
assert_eq!(shortcut("1.5 pages"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_slash_menu_filters_by_name() {
|
||||
let labels: Vec<&str> = matching("head").iter().map(BlockKind::label).collect();
|
||||
assert_eq!(labels, ["Heading 1", "Heading 2", "Heading 3"]);
|
||||
assert!(matching("zzz").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_holds_whole_rows_past_its_header() {
|
||||
let table = BlockKind::Table {
|
||||
columns: 3,
|
||||
merged: Vec::new(),
|
||||
};
|
||||
assert!(table.accepts(9));
|
||||
assert!(!table.accepts(7) && !table.accepts(3));
|
||||
assert!(BlockKind::Toggle(true).accepts(2) && !BlockKind::Paragraph.accepts(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_go_on_and_the_rest_return_to_text() {
|
||||
assert_eq!(BlockKind::Todo(true).next(), BlockKind::Todo(false));
|
||||
assert_eq!(BlockKind::Heading(1).next(), BlockKind::Paragraph);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user