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:
ZacharyZhang-NY committed 2026-09-26 03:32:24 -04:00
1 parent 05e764304d
commit 9edd0a8612
46 files changed
+5635 -193

No files matched your search

+233
View File
@@ -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]);
}
}