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

+479
View File
@@ -0,0 +1,479 @@
mod content;
mod edit;
mod keys;
mod kind;
mod media;
mod table;
mod view;
use std::{collections::HashMap, rc::Rc};
use gpui::{
App, AppContext as _, Context, Entity, EventEmitter, Focusable, Global, SharedString,
Subscription, Window,
};
pub use kind::{Align, BlockKind, Media};
pub(crate) use kind::{matching, shortcut};
use super::markdown::markdown_highlights;
use crate::forms::{History, InputEvent, TextInput, code_highlights};
/// A block as the editor gives it out and takes it in: its kind and the text of each of its fields.
#[derive(Clone, Debug, PartialEq)]
pub struct BlockData {
pub kind: BlockKind,
pub texts: Vec<String>,
}
impl BlockData {
pub fn new(kind: BlockKind, texts: impl IntoIterator<Item = impl Into<String>>) -> Self {
let texts: Vec<String> = texts.into_iter().map(Into::into).collect();
assert!(
kind.accepts(texts.len()),
"a {} block cannot hold {} fields",
kind.label(),
texts.len()
);
Self { kind, texts }
}
}
/// What a block editor tells its owner.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BlockEvent {
Changed,
}
/// Renders a diagram's source to SVG, or says what is wrong with it.
pub(crate) type RenderDiagram = Rc<dyn Fn(&str) -> Result<String, String>>;
/// Text shared by synced blocks, by name, across every editor in the app.
#[derive(Default)]
struct SyncedTexts(HashMap<SharedString, String>);
impl Global for SyncedTexts {}
pub(crate) struct Block {
pub key: u64,
pub kind: BlockKind,
pub fields: Vec<Entity<TextInput>>,
_changes: Vec<Subscription>,
}
/// The whole document, as undo keeps it.
#[derive(Clone, Debug, PartialEq)]
struct Snapshot(Vec<(u64, BlockData)>);
/// A document of blocks, Notion-like: prose, lists and to-dos, quotes and callouts, toggles, code, math, diagrams, dividers, tables, media, columns and synced blocks. Markdown at a paragraph's start changes its kind; `/` offers every kind; `@` and `:` suggest people and emoji; blocks drag by their handle. Undo covers text and structure alike.
pub struct BlockEditor {
pub(crate) blocks: Vec<Block>,
next: u64,
history: History<Snapshot>,
current: Snapshot,
pub(crate) people: Vec<SharedString>,
pub(crate) diagram: Option<RenderDiagram>,
_synced: Subscription,
}
impl EventEmitter<BlockEvent> for BlockEditor {}
impl BlockEditor {
pub fn new(blocks: Vec<BlockData>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let synced = cx.observe_global_in::<SyncedTexts>(window, |editor, _, cx| editor.pull(cx));
let mut editor = Self {
blocks: Vec::new(),
next: 0,
history: History::default(),
current: Snapshot(Vec::new()),
people: Vec::new(),
diagram: None,
_synced: synced,
};
for data in blocks {
let key = editor.fresh_key();
let block = editor.build(data, key, window, cx);
editor.blocks.push(block);
}
editor.current = editor.snapshot(cx);
editor
}
/// 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
}
/// Draws diagram blocks: their source to SVG, or an error to show in its place.
pub fn diagrams(mut self, render: impl Fn(&str) -> Result<String, String> + 'static) -> Self {
self.diagram = Some(Rc::new(render));
self
}
/// The document as it stands.
pub fn blocks(&self, cx: &App) -> Vec<BlockData> {
self.snapshot(cx)
.0
.into_iter()
.map(|(_, data)| data)
.collect()
}
fn snapshot(&self, cx: &App) -> Snapshot {
Snapshot(
self.blocks
.iter()
.map(|block| {
let texts = block
.fields
.iter()
.map(|field| field.read(cx).text().to_string())
.collect();
(
block.key,
BlockData {
kind: block.kind.clone(),
texts,
},
)
})
.collect(),
)
}
/// A field for `kind`: inline markdown styled as typed, or code colored as code.
fn field(
kind: &BlockKind,
text: &str,
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<TextInput> {
let code = *kind == BlockKind::Code;
let field = cx.new(|cx| {
let hint = match kind {
BlockKind::Paragraph | BlockKind::Table { .. } => "",
kind => kind.placeholder(),
};
let field = TextInput::new(window, cx)
.multi_line(1, usize::MAX)
.placeholder(hint);
match (code, kind.is_source()) {
(true, _) => field.highlighter(code_highlights),
(false, true) => field,
(false, false) => field.highlighter(markdown_highlights),
}
});
field.update(cx, |field, cx| field.set_text(text, cx));
field
}
fn fresh_key(&mut self) -> u64 {
self.next += 1;
self.next
}
fn build(
&mut self,
data: BlockData,
key: u64,
window: &mut Window,
cx: &mut Context<Self>,
) -> Block {
assert!(
data.kind.accepts(data.texts.len()),
"a {} block cannot hold {} fields",
data.kind.label(),
data.texts.len()
);
let mut texts = data.texts;
if let BlockKind::Synced(name) = &data.kind {
let shared = cx
.try_global::<SyncedTexts>()
.and_then(|synced| synced.0.get(name).cloned());
match shared {
Some(shared) => texts[0] = shared,
None => {
cx.default_global::<SyncedTexts>()
.0
.insert(name.clone(), texts[0].clone());
}
}
}
let fields: Vec<Entity<TextInput>> = texts
.iter()
.map(|text| Self::field(&data.kind, text, window, cx))
.collect();
let changes = fields
.iter()
.map(|field| Self::watch(key, field, window, cx))
.collect();
Block {
key,
kind: data.kind,
fields,
_changes: changes,
}
}
fn watch(
key: u64,
field: &Entity<TextInput>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Subscription {
cx.subscribe_in(field, window, move |editor, field, event, window, cx| {
editor.on_field(key, field, *event, window, cx)
})
}
/// Adds an empty field to a block, at `at` or last.
pub(crate) fn add_field(
&mut self,
key: u64,
at: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let ix = self.index(key);
let field = Self::field(&self.blocks[ix].kind, "", window, cx);
let watched = Self::watch(key, &field, window, cx);
let block = &mut self.blocks[ix];
let at = at.unwrap_or(block.fields.len());
block.fields.insert(at, field);
block._changes.insert(at, watched);
}
pub(crate) fn remove_field(&mut self, key: u64, field: usize) {
let ix = self.index(key);
let block = &mut self.blocks[ix];
block.fields.remove(field);
drop(block._changes.remove(field));
}
pub(crate) fn index(&self, key: u64) -> usize {
self.blocks
.iter()
.position(|block| block.key == key)
.unwrap_or_else(|| panic!("block {key} left the editor"))
}
fn on_field(
&mut self,
key: u64,
field: &Entity<TextInput>,
event: InputEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
let ix = self.index(key);
match event {
InputEvent::Changed => {
let typed = field.focus_handle(cx).is_focused(window);
if typed
&& self.blocks[ix].kind == BlockKind::Paragraph
&& let Some((kind, length)) = shortcut(field.read(cx).text())
&& field.read(cx).cursor() == length
{
log::info!(
"block editor: markdown turns block {key} into {}",
kind.label()
);
field.update(cx, |field, cx| {
field.select(0..length, cx);
field.insert("", cx);
});
self.turn_into(key, kind, window, cx);
return;
}
if let BlockKind::Synced(name) = &self.blocks[ix].kind {
let text = field.read(cx).text().to_string();
let shared = cx
.try_global::<SyncedTexts>()
.and_then(|synced| synced.0.get(name));
if shared != Some(&text) {
log::info!("block editor: synced {name} written");
cx.default_global::<SyncedTexts>()
.0
.insert(name.clone(), text);
}
}
self.settle(true, cx);
}
InputEvent::Focus if field.read(cx).is_empty() => {
let hint = self.blocks[ix].kind.placeholder();
field.update(cx, |field, cx| field.set_placeholder(hint, cx));
}
InputEvent::Blur if self.blocks[ix].kind == BlockKind::Paragraph => {
field.update(cx, |field, cx| field.set_placeholder("", cx));
}
_ => {}
}
}
/// Takes in synced text written elsewhere.
fn pull(&mut self, cx: &mut Context<Self>) {
let shared: Vec<(Entity<TextInput>, String)> = self
.blocks
.iter()
.filter_map(|block| match &block.kind {
BlockKind::Synced(name) => cx
.try_global::<SyncedTexts>()
.and_then(|synced| synced.0.get(name))
.map(|text| (block.fields[0].clone(), text.clone())),
_ => None,
})
.collect();
for (field, text) in shared {
if field.read(cx).text() != text {
log::debug!("block editor: synced text arrived");
field.update(cx, |field, cx| field.set_text(text, cx));
}
}
}
/// Records what changed as one undo step, a burst of typing as one, and tells the owner.
fn settle(&mut self, typing: bool, cx: &mut Context<Self>) {
let now = self.snapshot(cx);
if now != self.current {
let before = std::mem::replace(&mut self.current, now);
self.history.record(before, typing);
cx.emit(BlockEvent::Changed);
cx.notify();
}
}
/// Records the document before a change to its blocks.
pub(crate) fn before_change(&mut self, cx: &mut Context<Self>) {
self.settle(true, cx);
self.history.record(self.current.clone(), false);
}
/// After a change to the blocks: the new state is current.
pub(crate) fn after_change(&mut self, cx: &mut Context<Self>) {
self.current = self.snapshot(cx);
cx.emit(BlockEvent::Changed);
cx.notify();
}
pub(crate) fn focus_field(
&self,
key: u64,
field: usize,
at: Option<usize>,
window: &mut Window,
cx: &mut App,
) {
let block = &self.blocks[self.index(key)];
let Some(field) = block.fields.get(field) else {
return;
};
if let Some(at) = at {
field.update(cx, |field, cx| field.select(at..at, cx));
}
window.focus(&field.focus_handle(cx));
}
/// Adds a block after `after`, or first, and puts the caret in it.
pub fn insert(
&mut self,
after: Option<u64>,
data: BlockData,
window: &mut Window,
cx: &mut Context<Self>,
) -> u64 {
self.before_change(cx);
let key = self.fresh_key();
let block = self.build(data, key, window, cx);
let at = after.map_or(0, |after| self.index(after) + 1);
log::info!("block editor: {} block at {at}", block.kind.label());
let end = block
.fields
.first()
.map(|field| field.read(cx).text().len());
self.blocks.insert(at, block);
self.after_change(cx);
self.focus_field(key, 0, end, window, cx);
key
}
/// Changes a block's kind, keeping its first field's text; fields the new kind needs start empty.
pub fn turn_into(
&mut self,
key: u64,
kind: BlockKind,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.before_change(cx);
let ix = self.index(key);
let first = self.blocks[ix]
.fields
.first()
.map(|field| field.read(cx).text().to_string())
.unwrap_or_default();
let mut texts = vec![String::new(); kind.fields()];
if let Some(text) = texts.first_mut() {
*text = first;
}
log::info!("block editor: block {key} turns into {}", kind.label());
let block = self.build(BlockData { kind, texts }, key, window, cx);
self.blocks[ix] = block;
self.after_change(cx);
self.focus_field(key, 0, None, window, cx);
}
/// Changes a block's kind in place, as a to-do's box or a toggle's state does.
pub fn set_kind(&mut self, key: u64, kind: BlockKind, cx: &mut Context<Self>) {
let ix = self.index(key);
assert!(
kind.accepts(self.blocks[ix].fields.len()),
"set_kind keeps the fields; turn_into changes them"
);
self.before_change(cx);
self.blocks[ix].kind = kind;
self.after_change(cx);
}
pub fn remove(&mut self, key: u64, window: &mut Window, cx: &mut Context<Self>) {
self.before_change(cx);
let ix = self.index(key);
log::info!("block editor: block {key} removed");
self.blocks.remove(ix);
self.after_change(cx);
if let Some(previous) = ix.checked_sub(1).and_then(|ix| self.blocks.get(ix)) {
let key = previous.key;
let end = previous
.fields
.first()
.map(|field| field.read(cx).text().len());
self.focus_field(key, 0, end, window, cx);
}
}
pub fn duplicate(&mut self, key: u64, window: &mut Window, cx: &mut Context<Self>) {
let ix = self.index(key);
let data = self.snapshot(cx).0[ix].1.clone();
self.insert(Some(key), data, window, cx);
}
/// Moves the block at `from` to place `to`.
pub fn move_block(&mut self, from: usize, to: usize, cx: &mut Context<Self>) {
self.before_change(cx);
log::info!("block editor: block moved from {from} to {to}");
let block = self.blocks.remove(from);
self.blocks.insert(to.min(self.blocks.len()), block);
self.after_change(cx);
}
/// Whether `key`'s field `field` holds focus.
pub(crate) fn focused(&self, window: &Window, cx: &App) -> Option<(u64, usize)> {
self.blocks.iter().find_map(|block| {
block
.fields
.iter()
.position(|field| field.focus_handle(cx).is_focused(window))
.map(|ix| (block.key, ix))
})
}
}