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:
46 files changed
+5635
-193
No files matched your search
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user