- a field notes its selection when a key goes down, so undo of typing selects what the typing replaced - reading order skips table cells merged away, so Up and Down across blocks land on cells that show
453 lines
15 KiB
Rust
453 lines
15 KiB
Rust
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, and where the caret was: block, field and selection.
|
|
#[derive(Clone, Debug)]
|
|
struct Snapshot {
|
|
blocks: Vec<(u64, BlockData)>,
|
|
caret: Option<(u64, usize, std::ops::Range<usize>)>,
|
|
}
|
|
|
|
/// 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,
|
|
/// The field that last held focus, as block and field.
|
|
caret: Option<(u64, usize)>,
|
|
/// The selection a key went down on, before its field changed.
|
|
typed_from: Option<(u64, usize, std::ops::Range<usize>)>,
|
|
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 {
|
|
blocks: Vec::new(),
|
|
caret: None,
|
|
},
|
|
caret: None,
|
|
typed_from: None,
|
|
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)
|
|
.blocks
|
|
.into_iter()
|
|
.map(|(_, data)| data)
|
|
.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 => {
|
|
self.caret = self.blocks[ix]
|
|
.fields
|
|
.iter()
|
|
.position(|each| each == field)
|
|
.map(|at| (key, at));
|
|
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));
|
|
}
|
|
}
|
|
}
|
|
|
|
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).blocks[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))
|
|
})
|
|
}
|
|
}
|