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,233 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{App, Entity};
|
||||
|
||||
use super::markdown::{Mark, marks};
|
||||
use crate::forms::TextInput;
|
||||
|
||||
/// A format the toolbars apply to markdown.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Format {
|
||||
Bold,
|
||||
Italic,
|
||||
Strike,
|
||||
Code,
|
||||
Link,
|
||||
Heading(u8),
|
||||
Quote,
|
||||
Bullet,
|
||||
Numbered,
|
||||
Task,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
/// The marker an inline format wraps its text in.
|
||||
fn marker(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Bold => Some("**"),
|
||||
Self::Italic => Some("*"),
|
||||
Self::Strike => Some("~~"),
|
||||
Self::Code => Some("`"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The prefix a line format starts its lines with; numbered lines count up from one.
|
||||
fn prefix(self, nth: usize) -> String {
|
||||
match self {
|
||||
Self::Heading(level) => format!("{} ", "#".repeat(level.clamp(1, 6) as usize)),
|
||||
Self::Quote => "> ".into(),
|
||||
Self::Bullet => "- ".into(),
|
||||
Self::Numbered => format!("{}. ", nth + 1),
|
||||
Self::Task => "- [ ] ".into(),
|
||||
_ => unreachable!("an inline format has no prefix"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A numbered line's prefix, such as `12. `, in bytes; none for other lines.
|
||||
fn number_prefix(line: &str) -> usize {
|
||||
let digits = line.chars().take_while(char::is_ascii_digit).count();
|
||||
if digits > 0 && line[digits..].starts_with(". ") {
|
||||
digits + 2
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `line` already starts as `format` writes it.
|
||||
fn has(format: Format, line: &str) -> bool {
|
||||
match format {
|
||||
Format::Task => line.starts_with("- [ ] ") || line.starts_with("- [x] "),
|
||||
Format::Bullet => {
|
||||
(line.starts_with("- ") || line.starts_with("* ")) && !has(Format::Task, line)
|
||||
}
|
||||
Format::Numbered => number_prefix(line) > 0,
|
||||
_ => line.starts_with(&format.prefix(0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A line's own prefix: heading hashes, quote, bullet, number or task box, with its space.
|
||||
fn line_prefix(line: &str) -> usize {
|
||||
let hashes = line.chars().take_while(|ch| *ch == '#').count();
|
||||
if (1..=6).contains(&hashes) && line[hashes..].starts_with(' ') {
|
||||
return hashes + 1;
|
||||
}
|
||||
for mark in ["- [ ] ", "- [x] ", "> ", "- ", "* "] {
|
||||
if line.starts_with(mark) {
|
||||
return mark.len();
|
||||
}
|
||||
}
|
||||
number_prefix(line)
|
||||
}
|
||||
|
||||
/// `text` with `format` applied over `selection`, and where the selection lands. An inline format wraps the selection, or unwraps it when already wrapped; an empty one leaves the caret between its markers. A line format prefixes each touched line, or takes the prefix off when every line has it.
|
||||
pub fn apply(text: &str, selection: Range<usize>, format: Format) -> (String, Range<usize>) {
|
||||
assert!(
|
||||
selection.start <= selection.end && selection.end <= text.len(),
|
||||
"the selection {selection:?} lies outside {} bytes",
|
||||
text.len()
|
||||
);
|
||||
if format == Format::Link {
|
||||
let label = &text[selection.clone()];
|
||||
let linked = format!("{}[{label}]()", &text[..selection.start]);
|
||||
let caret = linked.len() - 1;
|
||||
return (format!("{linked}{}", &text[selection.end..]), caret..caret);
|
||||
}
|
||||
if let Some(marker) = format.marker() {
|
||||
let (before, after) = (&text[..selection.start], &text[selection.end..]);
|
||||
let inner = &text[selection.clone()];
|
||||
if before.ends_with(marker) && after.starts_with(marker) {
|
||||
let start = selection.start - marker.len();
|
||||
let unwrapped = format!("{}{inner}{}", &before[..start], &after[marker.len()..]);
|
||||
return (unwrapped, start..selection.end - marker.len());
|
||||
}
|
||||
let shift = marker.len();
|
||||
let wrapped = format!("{before}{marker}{inner}{marker}{after}");
|
||||
return (wrapped, selection.start + shift..selection.end + shift);
|
||||
}
|
||||
let first = text[..selection.start].rfind('\n').map_or(0, |at| at + 1);
|
||||
let last = text[selection.end..]
|
||||
.find('\n')
|
||||
.map_or(text.len(), |at| selection.end + at);
|
||||
let lines: Vec<&str> = text[first..last].split('\n').collect();
|
||||
let every = lines.iter().all(|line| has(format, line));
|
||||
let changed: Vec<String> = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(nth, line)| {
|
||||
let bare = &line[line_prefix(line)..];
|
||||
if every {
|
||||
bare.to_string()
|
||||
} else {
|
||||
format!("{}{bare}", format.prefix(nth))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let block = changed.join("\n");
|
||||
let out = format!("{}{block}{}", &text[..first], &text[last..]);
|
||||
(out, first..first + block.len())
|
||||
}
|
||||
|
||||
/// The one stretch where `new` differs from `old`: the range it replaces and what replaces it.
|
||||
fn changed<'a>(old: &str, new: &'a str) -> (Range<usize>, &'a str) {
|
||||
let head: usize = old
|
||||
.chars()
|
||||
.zip(new.chars())
|
||||
.take_while(|(was, now)| was == now)
|
||||
.map(|(was, _)| was.len_utf8())
|
||||
.sum();
|
||||
let tail: usize = old[head..]
|
||||
.chars()
|
||||
.rev()
|
||||
.zip(new[head..].chars().rev())
|
||||
.take_while(|(was, now)| was == now)
|
||||
.map(|(was, _)| was.len_utf8())
|
||||
.sum();
|
||||
(head..old.len() - tail, &new[head..new.len() - tail])
|
||||
}
|
||||
|
||||
/// Applies `format` to the field's selection as one undo step, then selects what it formatted.
|
||||
pub fn format(field: &Entity<TextInput>, format: Format, cx: &mut App) {
|
||||
field.update(cx, |input, cx| {
|
||||
let text = input.text().to_string();
|
||||
let (next, selection) = apply(&text, input.selection(), format);
|
||||
let (range, inserted) = changed(&text, &next);
|
||||
log::info!("format {format:?}: {range:?} becomes {inserted:?}");
|
||||
input.select(range, cx);
|
||||
input.insert(inserted, cx);
|
||||
input.select(selection, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// The formats that hold all of `selection`, for a toolbar to show pressed.
|
||||
pub fn active(text: &str, selection: Range<usize>) -> Vec<Format> {
|
||||
let mut on: Vec<Format> = marks(text)
|
||||
.into_iter()
|
||||
.filter(|(range, _)| range.start <= selection.start && selection.end <= range.end)
|
||||
.filter_map(|(_, mark)| match mark {
|
||||
Mark::Strong => Some(Format::Bold),
|
||||
Mark::Emphasis => Some(Format::Italic),
|
||||
Mark::Strike => Some(Format::Strike),
|
||||
Mark::Code => Some(Format::Code),
|
||||
Mark::Link => Some(Format::Link),
|
||||
Mark::Heading(level) => Some(Format::Heading(level)),
|
||||
Mark::Quote => Some(Format::Quote),
|
||||
Mark::Syntax => None,
|
||||
})
|
||||
.collect();
|
||||
let line = &text[text[..selection.start].rfind('\n').map_or(0, |at| at + 1)..];
|
||||
on.extend(
|
||||
[Format::Task, Format::Bullet, Format::Numbered]
|
||||
.into_iter()
|
||||
.find(|list| has(*list, line)),
|
||||
);
|
||||
on
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn inline_formats_wrap_and_unwrap() {
|
||||
let (bold, selection) = apply("a lift b", 2..6, Format::Bold);
|
||||
assert_eq!((bold.as_str(), selection.clone()), ("a **lift** b", 4..8));
|
||||
assert_eq!(
|
||||
apply(&bold, selection, Format::Bold),
|
||||
("a lift b".into(), 2..6)
|
||||
);
|
||||
assert_eq!(apply("ab", 1..1, Format::Code), ("a``b".into(), 2..2));
|
||||
assert_eq!(
|
||||
apply("see docs", 4..8, Format::Link),
|
||||
("see [docs]()".into(), 11..11)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_formats_prefix_every_touched_line_or_take_it_off() {
|
||||
let text = "one\ntwo\nthree";
|
||||
let (listed, selection) = apply(text, 1..6, Format::Numbered);
|
||||
assert_eq!(listed, "1. one\n2. two\nthree");
|
||||
assert_eq!(apply(&listed, selection, Format::Numbered).0, text);
|
||||
assert_eq!(apply("## Old", 3..3, Format::Heading(1)).0, "# Old");
|
||||
assert_eq!(apply("- item", 2..2, Format::Task).0, "- [ ] item");
|
||||
assert_eq!(apply("- [x] done", 2..2, Format::Task).0, "done");
|
||||
assert_eq!(apply("- [ ] task", 2..2, Format::Bullet).0, "- task");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_change_is_the_stretch_between_what_stayed() {
|
||||
assert_eq!(changed("a lift b", "a **lift** b"), (2..6, "**lift**"));
|
||||
assert_eq!(changed("same", "same"), (4..4, ""));
|
||||
assert_eq!(changed("héllo", "hello"), (1..3, "e"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressed_formats_hold_the_selection() {
|
||||
let text = "- [ ] a **bold** word";
|
||||
assert_eq!(active(text, 10..12), [Format::Bold, Format::Task]);
|
||||
assert_eq!(active("## Lift", 4..4), [Format::Heading(2)]);
|
||||
assert_eq!(active("12. twelve", 5..5), [Format::Numbered]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user