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,235 @@
|
||||
use gpui::{
|
||||
AnyElement, Context, FontWeight, InteractiveElement, IntoElement, MouseButton, ParentElement,
|
||||
SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::{Block, BlockEditor, BlockKind, media, table, view::Look};
|
||||
use crate::{
|
||||
buttons::CopyButton,
|
||||
forms::Checkbox,
|
||||
primitives::{Disclosure, Icon, IconName},
|
||||
theme::IconSize,
|
||||
typography::Latex,
|
||||
};
|
||||
|
||||
impl BlockEditor {
|
||||
/// What a block shows, by kind.
|
||||
pub(crate) fn content(
|
||||
&self,
|
||||
block: &Block,
|
||||
number: usize,
|
||||
look: &Look,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let colors = look.colors.clone();
|
||||
let (key, id) = (block.key, Self::id(cx));
|
||||
let marker = |text: SharedString| {
|
||||
div()
|
||||
.flex_none()
|
||||
.min_w_5()
|
||||
.text_color(colors.fg_muted)
|
||||
.child(text)
|
||||
};
|
||||
let row = |marker: AnyElement, field: AnyElement| {
|
||||
div()
|
||||
.flex()
|
||||
.gap_1p5()
|
||||
.child(marker)
|
||||
.child(div().flex_1().min_w_0().child(field))
|
||||
};
|
||||
match &block.kind {
|
||||
BlockKind::Paragraph => self.field_view(block, 0, window, cx),
|
||||
BlockKind::Heading(level) => div()
|
||||
.pt_2()
|
||||
.text_size(look.headings[(*level as usize).clamp(1, 3) - 1])
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.child(self.field_view(block, 0, window, cx))
|
||||
.into_any_element(),
|
||||
BlockKind::Bullet => row(
|
||||
marker("•".into()).into_any_element(),
|
||||
self.field_view(block, 0, window, cx),
|
||||
)
|
||||
.into_any_element(),
|
||||
BlockKind::Numbered => row(
|
||||
marker(format!("{number}.").into()).into_any_element(),
|
||||
self.field_view(block, 0, window, cx),
|
||||
)
|
||||
.into_any_element(),
|
||||
BlockKind::Todo(done) => {
|
||||
let (entity, done) = (cx.entity(), *done);
|
||||
let check = Checkbox::new((id.clone(), format!("todo-{key}")), done).on_change(
|
||||
move |on, _, cx| {
|
||||
entity.update(cx, |editor, cx| {
|
||||
editor.set_kind(key, BlockKind::Todo(on), cx)
|
||||
})
|
||||
},
|
||||
);
|
||||
row(
|
||||
check.into_any_element(),
|
||||
self.field_view(block, 0, window, cx),
|
||||
)
|
||||
.when(done, |row| row.text_color(colors.fg_subtle))
|
||||
.into_any_element()
|
||||
}
|
||||
BlockKind::Quote => div()
|
||||
.pl_3()
|
||||
.border_l_2()
|
||||
.border_color(colors.border_strong)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(self.field_view(block, 0, window, cx))
|
||||
.into_any_element(),
|
||||
BlockKind::Callout => div()
|
||||
.flex()
|
||||
.gap_2()
|
||||
.p_3()
|
||||
.rounded(look.radius)
|
||||
.bg(colors.sunken)
|
||||
.child(
|
||||
Icon::new(IconName::Lightbulb)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.warning),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(self.field_view(block, 0, window, cx)),
|
||||
)
|
||||
.into_any_element(),
|
||||
BlockKind::Toggle(open) => {
|
||||
let (entity, open) = (cx.entity(), *open);
|
||||
let chevron = div()
|
||||
.id((id.clone(), format!("toggle-{key}")))
|
||||
.flex_none()
|
||||
.cursor_pointer()
|
||||
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
|
||||
.on_click(move |_, _, cx| {
|
||||
entity.update(cx, |editor, cx| {
|
||||
editor.set_kind(key, BlockKind::Toggle(!open), cx)
|
||||
})
|
||||
})
|
||||
.child(Disclosure::new(
|
||||
(id.clone(), format!("disclosure-{key}")),
|
||||
open,
|
||||
));
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(row(
|
||||
chevron.into_any_element(),
|
||||
self.field_view(block, 0, window, cx),
|
||||
))
|
||||
.when(open, |toggle| {
|
||||
toggle.child(div().pl_6().child(self.field_view(block, 1, window, cx)))
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
BlockKind::Code => {
|
||||
let text = block.fields[0].read(cx).text().to_string();
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.p_3()
|
||||
.rounded(look.radius)
|
||||
.bg(colors.sunken)
|
||||
.font_family(look.mono.clone())
|
||||
.text_size(look.small)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_between()
|
||||
.items_center()
|
||||
.text_size(look.tiny)
|
||||
.text_color(colors.fg_subtle)
|
||||
.child("Code")
|
||||
.child(CopyButton::new((id.clone(), format!("copy-{key}")), text)),
|
||||
)
|
||||
.child(self.field_view(block, 0, window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
BlockKind::Math => {
|
||||
let tex = block.fields[0].read(cx).text().to_string();
|
||||
let shown = match Latex::parse(&tex) {
|
||||
_ if tex.trim().is_empty() => div().into_any_element(),
|
||||
Ok(math) => div()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.py_2()
|
||||
.child(math)
|
||||
.into_any_element(),
|
||||
Err(error) => div()
|
||||
.text_color(colors.danger)
|
||||
.child(error.to_string())
|
||||
.into_any_element(),
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.p_3()
|
||||
.rounded(look.radius)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.child(
|
||||
div()
|
||||
.font_family(look.mono.clone())
|
||||
.text_size(look.small)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(self.field_view(block, 0, window, cx)),
|
||||
)
|
||||
.child(shown)
|
||||
.into_any_element()
|
||||
}
|
||||
BlockKind::Diagram => media::diagram(self, block, look, window, cx),
|
||||
BlockKind::Divider => div()
|
||||
.py_2()
|
||||
.child(div().h_0().border_t_1().border_color(colors.border))
|
||||
.into_any_element(),
|
||||
BlockKind::Table { .. } => table::table(self, block, look, window, cx),
|
||||
BlockKind::Image(_) | BlockKind::Video(_) | BlockKind::Embed(_) => {
|
||||
media::media(self, block, look, window, cx)
|
||||
}
|
||||
BlockKind::Columns(_) => div()
|
||||
.flex()
|
||||
.gap_4()
|
||||
.children((0..block.fields.len()).map(|ix| {
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.when(ix > 0, |column| {
|
||||
column.pl_4().border_l_1().border_color(colors.border)
|
||||
})
|
||||
.child(self.field_view(block, ix, window, cx))
|
||||
}))
|
||||
.into_any_element(),
|
||||
BlockKind::Synced(name) => div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.rounded(look.radius)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.text_size(look.tiny)
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(
|
||||
Icon::new(IconName::RefreshCw)
|
||||
.size(IconSize::Xs)
|
||||
.color(colors.fg_subtle),
|
||||
)
|
||||
.child(format!("Synced · {name}")),
|
||||
)
|
||||
.child(self.field_view(block, 0, window, cx))
|
||||
.into_any_element(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use gpui::{Context, Window};
|
||||
|
||||
use super::{Block, BlockData, BlockEditor, BlockEvent, BlockKind, Snapshot};
|
||||
|
||||
impl BlockEditor {
|
||||
/// Splits a prose block at the caret: the text after it moves to a new block of the kind that follows.
|
||||
pub(crate) fn split(&mut self, key: u64, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let ix = self.index(key);
|
||||
let kind = self.blocks[ix].kind.clone();
|
||||
let field = self.blocks[ix].fields[0].clone();
|
||||
if field.read(cx).is_empty()
|
||||
&& kind != BlockKind::Paragraph
|
||||
&& kind.next() != BlockKind::Paragraph
|
||||
{
|
||||
self.turn_into(key, BlockKind::Paragraph, window, cx);
|
||||
return;
|
||||
}
|
||||
let caret = field.read(cx).cursor();
|
||||
let tail = field.read(cx).text()[caret..].to_string();
|
||||
log::info!("block editor: block {key} splits at {caret}");
|
||||
self.before_change(cx);
|
||||
field.update(cx, |field, cx| {
|
||||
let end = field.text().len();
|
||||
field.select(caret..end, cx);
|
||||
field.insert("", cx);
|
||||
});
|
||||
let key = self.fresh_key();
|
||||
let next = self.build(
|
||||
BlockData {
|
||||
kind: kind.next(),
|
||||
texts: vec![tail],
|
||||
},
|
||||
key,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
self.blocks.insert(ix + 1, next);
|
||||
self.after_change(cx);
|
||||
self.focus_field(key, 0, Some(0), window, cx);
|
||||
}
|
||||
|
||||
/// Backspace at a prose block's start: another kind turns back into text; text joins the prose above, or an empty block goes.
|
||||
pub(crate) fn join(&mut self, key: u64, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let ix = self.index(key);
|
||||
if self.blocks[ix].kind != BlockKind::Paragraph {
|
||||
self.turn_into(key, BlockKind::Paragraph, window, cx);
|
||||
return;
|
||||
}
|
||||
let text = self.blocks[ix].fields[0].read(cx).text().to_string();
|
||||
let Some(above) = ix.checked_sub(1).map(|ix| &self.blocks[ix]) else {
|
||||
return;
|
||||
};
|
||||
if !above.kind.is_prose() {
|
||||
if text.is_empty() {
|
||||
self.remove(key, window, cx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (above_key, above_field) = (above.key, above.fields[0].clone());
|
||||
log::info!("block editor: block {key} joins block {above_key}");
|
||||
self.before_change(cx);
|
||||
let joint = above_field.read(cx).text().len();
|
||||
above_field.update(cx, |field, cx| {
|
||||
field.select(joint..joint, cx);
|
||||
field.insert(&text, cx);
|
||||
});
|
||||
self.blocks.remove(ix);
|
||||
self.after_change(cx);
|
||||
self.focus_field(above_key, 0, Some(joint), window, cx);
|
||||
}
|
||||
|
||||
pub(crate) fn undo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.settle(true, cx);
|
||||
if let Some(previous) = self.history.undo(self.current.clone()) {
|
||||
log::info!("block editor: undo");
|
||||
self.restore(previous, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn redo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(next) = self.history.redo(self.current.clone()) {
|
||||
log::info!("block editor: redo");
|
||||
self.restore(next, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts the document back as `snapshot` holds it, keeping fields whose blocks stay.
|
||||
fn restore(&mut self, snapshot: Snapshot, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let mut kept: HashMap<u64, Block> = self
|
||||
.blocks
|
||||
.drain(..)
|
||||
.map(|block| (block.key, block))
|
||||
.collect();
|
||||
for (key, data) in &snapshot.0 {
|
||||
let block = match kept.remove(key) {
|
||||
Some(mut block) if block.fields.len() == data.texts.len() => {
|
||||
block.kind = data.kind.clone();
|
||||
for (field, text) in block.fields.iter().zip(&data.texts) {
|
||||
if field.read(cx).text() != text {
|
||||
field.update(cx, |field, cx| field.set_text(text.clone(), cx));
|
||||
}
|
||||
}
|
||||
block
|
||||
}
|
||||
_ => self.build(data.clone(), *key, window, cx),
|
||||
};
|
||||
self.blocks.push(block);
|
||||
}
|
||||
self.current = snapshot;
|
||||
cx.emit(BlockEvent::Changed);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
use gpui::{Context, Window};
|
||||
|
||||
use super::{BlockData, BlockEditor, BlockKind};
|
||||
use crate::documents::modes::at_edge;
|
||||
|
||||
/// A new empty paragraph.
|
||||
fn paragraph() -> BlockData {
|
||||
BlockData::new(BlockKind::Paragraph, [""])
|
||||
}
|
||||
|
||||
impl BlockEditor {
|
||||
/// The fields in reading order, as `(block, field)`: a closed toggle hides its body.
|
||||
fn reading_order(&self) -> Vec<(u64, usize)> {
|
||||
self.blocks
|
||||
.iter()
|
||||
.flat_map(|block| {
|
||||
let shown = match block.kind {
|
||||
BlockKind::Toggle(false) => 1,
|
||||
_ => block.fields.len(),
|
||||
};
|
||||
(0..shown).map(move |field| (block.key, field))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Enter in a field: prose splits, a toggle's title opens its body, a caption starts text below, a cell steps down a row; elsewhere Enter breaks the line.
|
||||
pub(crate) fn on_enter(
|
||||
&mut self,
|
||||
key: u64,
|
||||
field: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let kind = self.blocks[self.index(key)].kind.clone();
|
||||
match kind {
|
||||
kind if kind.is_prose() => self.split(key, window, cx),
|
||||
BlockKind::Toggle(open) if field == 0 => {
|
||||
if !open {
|
||||
self.set_kind(key, BlockKind::Toggle(true), cx);
|
||||
}
|
||||
self.focus_field(key, 1, None, window, cx);
|
||||
}
|
||||
BlockKind::Image(_) | BlockKind::Video(_) | BlockKind::Embed(_) => {
|
||||
self.insert(Some(key), paragraph(), window, cx);
|
||||
}
|
||||
BlockKind::Table { columns, .. } => {
|
||||
let below = field + columns;
|
||||
if below >= self.blocks[self.index(key)].fields.len() {
|
||||
self.add_row(key, window, cx);
|
||||
}
|
||||
self.focus_field(key, below, None, window, cx);
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Backspace at a field's start: prose joins up or turns back into text; an empty code, math or diagram block turns into text.
|
||||
pub(crate) fn on_backspace(
|
||||
&mut self,
|
||||
key: u64,
|
||||
field: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let block = &self.blocks[self.index(key)];
|
||||
let input = block.fields[field].read(cx);
|
||||
if input.cursor() != 0 || !input.selection().is_empty() {
|
||||
return false;
|
||||
}
|
||||
let empty = input.is_empty();
|
||||
match &block.kind {
|
||||
kind if kind.is_prose() => self.join(key, window, cx),
|
||||
kind if kind.is_source() && empty => {
|
||||
self.turn_into(key, BlockKind::Paragraph, window, cx)
|
||||
}
|
||||
BlockKind::Toggle(_) if field == 0 && block.fields[1].read(cx).is_empty() => {
|
||||
self.turn_into(key, BlockKind::Paragraph, window, cx)
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Up or Down on a field's first or last line moves to the field above or below, a table cell by a row.
|
||||
pub(crate) fn on_vertical(
|
||||
&mut self,
|
||||
key: u64,
|
||||
field: usize,
|
||||
down: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let block = &self.blocks[self.index(key)];
|
||||
if !at_edge(block.fields[field].read(cx), down) {
|
||||
return false;
|
||||
}
|
||||
if let BlockKind::Table { columns, .. } = block.kind {
|
||||
let row = if down {
|
||||
Some(field + columns)
|
||||
} else {
|
||||
field.checked_sub(columns)
|
||||
};
|
||||
if let Some(row) = row.filter(|row| *row < block.fields.len()) {
|
||||
self.focus_field(key, row, None, window, cx);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let order = self.reading_order();
|
||||
let at = order
|
||||
.iter()
|
||||
.position(|place| *place == (key, field))
|
||||
.expect("a focused field is in reading order");
|
||||
let target = if down {
|
||||
order[at + 1..].iter().find(|(other, _)| *other != key)
|
||||
} else {
|
||||
order[..at].iter().rev().find(|(other, _)| *other != key)
|
||||
};
|
||||
let Some(&(target, target_field)) = target else {
|
||||
if down && !self.blocks.last().is_some_and(|last| last.kind.is_prose()) {
|
||||
self.insert(Some(key), paragraph(), window, cx);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
let end = self.blocks[self.index(target)].fields[target_field]
|
||||
.read(cx)
|
||||
.text()
|
||||
.len();
|
||||
self.focus_field(
|
||||
target,
|
||||
target_field,
|
||||
Some(if down { 0 } else { end }),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
/// The submit key in any field leaves the block for a new paragraph below.
|
||||
pub(crate) fn on_submit(&mut self, key: u64, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.insert(Some(key), paragraph(), window, cx);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
use gpui::SharedString;
|
||||
|
||||
use crate::primitives::IconName;
|
||||
|
||||
/// Where a picture sits across its column.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Align {
|
||||
Left,
|
||||
#[default]
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// A picture, clip or page shown in a block: its address, and its width as a share of the column.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Media {
|
||||
pub source: SharedString,
|
||||
pub width: f32,
|
||||
pub align: Align,
|
||||
}
|
||||
|
||||
impl Media {
|
||||
pub fn new(source: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
source: source.into(),
|
||||
width: 1.0,
|
||||
align: Align::Center,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a block is. Each holds its text in fields: one for most, a title and a body for a toggle, one per column, a caption for media, one per cell for a table, none for a divider.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum BlockKind {
|
||||
Paragraph,
|
||||
Heading(u8),
|
||||
Bullet,
|
||||
Numbered,
|
||||
Todo(bool),
|
||||
Quote,
|
||||
Callout,
|
||||
/// Open or closed; its body shows while open.
|
||||
Toggle(bool),
|
||||
Code,
|
||||
Math,
|
||||
/// Diagram source, such as Mermaid; the host renders the picture.
|
||||
Diagram,
|
||||
Divider,
|
||||
/// Columns across, the header row first; each `merged` cell, as row and column, folds into the cell on its left.
|
||||
Table {
|
||||
columns: usize,
|
||||
merged: Vec<(usize, usize)>,
|
||||
},
|
||||
Image(Media),
|
||||
Video(Media),
|
||||
Embed(Media),
|
||||
/// Side by side, one field each.
|
||||
Columns(usize),
|
||||
/// Text shared with every other block synced to the same source.
|
||||
Synced(SharedString),
|
||||
}
|
||||
|
||||
impl BlockKind {
|
||||
/// Its name in the slash menu.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Paragraph => "Text",
|
||||
Self::Heading(1) => "Heading 1",
|
||||
Self::Heading(2) => "Heading 2",
|
||||
Self::Heading(_) => "Heading 3",
|
||||
Self::Bullet => "Bulleted list",
|
||||
Self::Numbered => "Numbered list",
|
||||
Self::Todo(_) => "To-do",
|
||||
Self::Quote => "Quote",
|
||||
Self::Callout => "Callout",
|
||||
Self::Toggle(_) => "Toggle",
|
||||
Self::Code => "Code",
|
||||
Self::Math => "Math",
|
||||
Self::Diagram => "Diagram",
|
||||
Self::Divider => "Divider",
|
||||
Self::Table { .. } => "Table",
|
||||
Self::Image(_) => "Image",
|
||||
Self::Video(_) => "Video",
|
||||
Self::Embed(_) => "Embed",
|
||||
Self::Columns(_) => "Columns",
|
||||
Self::Synced(_) => "Synced block",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(&self) -> IconName {
|
||||
match self {
|
||||
Self::Paragraph => IconName::Type,
|
||||
Self::Heading(1) => IconName::Heading1,
|
||||
Self::Heading(2) => IconName::Heading2,
|
||||
Self::Heading(_) => IconName::Heading3,
|
||||
Self::Bullet => IconName::List,
|
||||
Self::Numbered => IconName::ListOrdered,
|
||||
Self::Todo(_) => IconName::ListTodo,
|
||||
Self::Quote => IconName::Quote,
|
||||
Self::Callout => IconName::Info,
|
||||
Self::Toggle(_) => IconName::ChevronRight,
|
||||
Self::Code => IconName::Code,
|
||||
Self::Math => IconName::SquareFunction,
|
||||
Self::Diagram => IconName::Workflow,
|
||||
Self::Divider => IconName::Minus,
|
||||
Self::Table { .. } => IconName::Table,
|
||||
Self::Image(_) => IconName::Image,
|
||||
Self::Video(_) => IconName::Video,
|
||||
Self::Embed(_) => IconName::Globe,
|
||||
Self::Columns(_) => IconName::Columns2,
|
||||
Self::Synced(_) => IconName::RefreshCw,
|
||||
}
|
||||
}
|
||||
|
||||
/// What an empty field of this kind says while it has focus.
|
||||
pub fn placeholder(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Paragraph => "Type '/' for commands",
|
||||
Self::Heading(_) => self.label(),
|
||||
Self::Bullet | Self::Numbered => "List",
|
||||
Self::Todo(_) => "To-do",
|
||||
Self::Quote => "Quote",
|
||||
Self::Callout | Self::Toggle(_) | Self::Columns(_) | Self::Synced(_) => "Write",
|
||||
Self::Code => "Code",
|
||||
Self::Math => "A formula in TeX, such as e^{i\\pi} + 1 = 0",
|
||||
Self::Diagram => "A diagram in Mermaid, such as graph LR; a --> b",
|
||||
Self::Image(_) | Self::Video(_) | Self::Embed(_) => "Add a caption",
|
||||
Self::Divider | Self::Table { .. } => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// How many fields a new block of this kind holds.
|
||||
pub fn fields(&self) -> usize {
|
||||
match self {
|
||||
Self::Divider => 0,
|
||||
Self::Toggle(_) => 2,
|
||||
Self::Columns(count) => *count,
|
||||
Self::Table { columns, .. } => columns * 2,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether it can hold `count` fields: a table any whole rows past its header, the rest their own number.
|
||||
pub fn accepts(&self, count: usize) -> bool {
|
||||
match self {
|
||||
Self::Table { columns, .. } => {
|
||||
*columns > 0 && count >= columns * 2 && count.is_multiple_of(*columns)
|
||||
}
|
||||
kind => count == kind.fields(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Prose: Enter splits it, Backspace at its start turns it back into text or joins it up.
|
||||
pub fn is_prose(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Paragraph
|
||||
| Self::Heading(_)
|
||||
| Self::Bullet
|
||||
| Self::Numbered
|
||||
| Self::Todo(_)
|
||||
| Self::Quote
|
||||
| Self::Callout
|
||||
)
|
||||
}
|
||||
|
||||
/// The kind Enter starts after this one: lists go on, the rest return to text.
|
||||
pub fn next(&self) -> Self {
|
||||
match self {
|
||||
Self::Bullet => Self::Bullet,
|
||||
Self::Numbered => Self::Numbered,
|
||||
Self::Todo(_) => Self::Todo(false),
|
||||
_ => Self::Paragraph,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lines of monospaced source, where Enter breaks a line.
|
||||
pub fn is_source(&self) -> bool {
|
||||
matches!(self, Self::Code | Self::Math | Self::Diagram)
|
||||
}
|
||||
}
|
||||
|
||||
/// The kinds the slash menu offers, in order; media and synced blocks come from their owner.
|
||||
pub(crate) fn offered() -> Vec<BlockKind> {
|
||||
vec![
|
||||
BlockKind::Paragraph,
|
||||
BlockKind::Heading(1),
|
||||
BlockKind::Heading(2),
|
||||
BlockKind::Heading(3),
|
||||
BlockKind::Bullet,
|
||||
BlockKind::Numbered,
|
||||
BlockKind::Todo(false),
|
||||
BlockKind::Quote,
|
||||
BlockKind::Callout,
|
||||
BlockKind::Toggle(true),
|
||||
BlockKind::Code,
|
||||
BlockKind::Math,
|
||||
BlockKind::Diagram,
|
||||
BlockKind::Divider,
|
||||
BlockKind::Table {
|
||||
columns: 2,
|
||||
merged: Vec::new(),
|
||||
},
|
||||
BlockKind::Columns(2),
|
||||
]
|
||||
}
|
||||
|
||||
/// The kinds whose name holds `query`, for the slash menu.
|
||||
pub(crate) fn matching(query: &str) -> Vec<BlockKind> {
|
||||
let query = query.to_lowercase();
|
||||
offered()
|
||||
.into_iter()
|
||||
.filter(|kind| kind.label().to_lowercase().contains(&query))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A paragraph's leading markdown that makes it another kind, and how many bytes it takes; the space after it is typed last.
|
||||
pub(crate) fn shortcut(text: &str) -> Option<(BlockKind, usize)> {
|
||||
let prefixes: [(&str, BlockKind); 11] = [
|
||||
("# ", BlockKind::Heading(1)),
|
||||
("## ", BlockKind::Heading(2)),
|
||||
("### ", BlockKind::Heading(3)),
|
||||
("- ", BlockKind::Bullet),
|
||||
("* ", BlockKind::Bullet),
|
||||
("1. ", BlockKind::Numbered),
|
||||
("[] ", BlockKind::Todo(false)),
|
||||
("[ ] ", BlockKind::Todo(false)),
|
||||
("[x] ", BlockKind::Todo(true)),
|
||||
("> ", BlockKind::Quote),
|
||||
("``` ", BlockKind::Code),
|
||||
];
|
||||
if text == "---" {
|
||||
return Some((BlockKind::Divider, 3));
|
||||
}
|
||||
prefixes
|
||||
.into_iter()
|
||||
.find(|(prefix, _)| text.starts_with(prefix))
|
||||
.map(|(prefix, kind)| (kind, prefix.len()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn markdown_at_a_paragraphs_start_turns_it_into_a_kind() {
|
||||
assert_eq!(shortcut("## Lift"), Some((BlockKind::Heading(2), 3)));
|
||||
assert_eq!(shortcut("[x] done"), Some((BlockKind::Todo(true), 4)));
|
||||
assert_eq!(shortcut("---"), Some((BlockKind::Divider, 3)));
|
||||
assert_eq!(shortcut("#hashtag"), None);
|
||||
assert_eq!(shortcut("1.5 pages"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_slash_menu_filters_by_name() {
|
||||
let labels: Vec<&str> = matching("head").iter().map(BlockKind::label).collect();
|
||||
assert_eq!(labels, ["Heading 1", "Heading 2", "Heading 3"]);
|
||||
assert!(matching("zzz").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_holds_whole_rows_past_its_header() {
|
||||
let table = BlockKind::Table {
|
||||
columns: 3,
|
||||
merged: Vec::new(),
|
||||
};
|
||||
assert!(table.accepts(9));
|
||||
assert!(!table.accepts(7) && !table.accepts(3));
|
||||
assert!(BlockKind::Toggle(true).accepts(2) && !BlockKind::Paragraph.accepts(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_go_on_and_the_rest_return_to_text() {
|
||||
assert_eq!(BlockKind::Todo(true).next(), BlockKind::Todo(false));
|
||||
assert_eq!(BlockKind::Heading(1).next(), BlockKind::Paragraph);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
use gpui::{
|
||||
AnyElement, AppContext as _, Bounds, Context, EmptyView, EntityId, Image, ImageFormat,
|
||||
ImageSource, InteractiveElement, IntoElement, ParentElement, Pixels, SharedUri,
|
||||
StatefulInteractiveElement, Styled, Window, div, img, prelude::*,
|
||||
};
|
||||
use regex::Regex;
|
||||
|
||||
use super::{Align, Block, BlockEditor, BlockKind, Media, view::Look};
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant, IconButton},
|
||||
primitives::{Icon, IconName, Measure},
|
||||
theme::{ControlSize, IconSize},
|
||||
};
|
||||
|
||||
/// Narrowest a picture gets, as a share of its column.
|
||||
const NARROWEST: f32 = 0.2;
|
||||
|
||||
/// A picture's edge on its way, and whose.
|
||||
struct Resize {
|
||||
owner: EntityId,
|
||||
key: u64,
|
||||
}
|
||||
|
||||
/// A web address loads over the network; anything else is a file.
|
||||
pub(crate) fn source(text: &str) -> ImageSource {
|
||||
if text.starts_with("http://") || text.starts_with("https://") {
|
||||
SharedUri::from(text.to_string()).into()
|
||||
} else {
|
||||
Path::new(text).into()
|
||||
}
|
||||
}
|
||||
|
||||
/// The share of `column` a picture set by `align` spans when its edge is at `x`.
|
||||
pub(crate) fn share(align: Align, column: Bounds<Pixels>, x: Pixels) -> f32 {
|
||||
let width = f32::from(column.size.width).max(1.0);
|
||||
let reach = match align {
|
||||
Align::Left => f32::from(x - column.left()),
|
||||
Align::Right => f32::from(column.right() - x),
|
||||
Align::Center => 2.0 * f32::from(x - column.center().x).abs(),
|
||||
};
|
||||
(reach / width).clamp(NARROWEST, 1.0)
|
||||
}
|
||||
|
||||
fn media_of(kind: &mut BlockKind) -> &mut Media {
|
||||
match kind {
|
||||
BlockKind::Image(media) | BlockKind::Video(media) | BlockKind::Embed(media) => media,
|
||||
other => unreachable!("{} holds no media", other.label()),
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockEditor {
|
||||
/// Sets a picture's width while its edge is dragged; the drag's start records undo.
|
||||
fn resize(&mut self, key: u64, width: f32, first: bool, cx: &mut Context<Self>) {
|
||||
if first {
|
||||
self.before_change(cx);
|
||||
}
|
||||
let ix = self.index(key);
|
||||
media_of(&mut self.blocks[ix].kind).width = width;
|
||||
self.after_change(cx);
|
||||
}
|
||||
|
||||
pub(crate) fn align(&mut self, key: u64, align: Align, cx: &mut Context<Self>) {
|
||||
let ix = self.index(key);
|
||||
let mut kind = self.blocks[ix].kind.clone();
|
||||
media_of(&mut kind).align = align;
|
||||
self.set_kind(key, kind, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// A picture sized as a share of its column, set left, center or right, with an edge to drag and a caption below; a clip or a page shows as a card to open.
|
||||
pub(super) fn media(
|
||||
editor: &BlockEditor,
|
||||
block: &Block,
|
||||
look: &Look,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<BlockEditor>,
|
||||
) -> AnyElement {
|
||||
let (key, id) = (block.key, BlockEditor::id(cx));
|
||||
let colors = look.colors.clone();
|
||||
let caption = div()
|
||||
.text_size(look.small)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(editor.field_view(block, 0, window, cx));
|
||||
let (media, icon) = match &block.kind {
|
||||
BlockKind::Image(media) => (media.clone(), None),
|
||||
BlockKind::Video(media) => (media.clone(), Some(IconName::Video)),
|
||||
BlockKind::Embed(media) => (media.clone(), Some(IconName::Globe)),
|
||||
other => unreachable!("{} is no media", other.label()),
|
||||
};
|
||||
if let Some(icon) = icon {
|
||||
let address = media.source.clone();
|
||||
return div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.p_3()
|
||||
.rounded(look.radius)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.child(Icon::new(icon).size(IconSize::Md).color(colors.fg_muted))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_color(colors.fg_muted)
|
||||
.child(media.source.clone()),
|
||||
)
|
||||
.child(
|
||||
Button::new((id.clone(), format!("open-{key}")), "Open")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, _, cx| cx.open_url(&address)),
|
||||
),
|
||||
)
|
||||
.child(caption)
|
||||
.into_any_element();
|
||||
}
|
||||
let column = window.use_keyed_state((id.clone(), format!("column-{key}")), cx, |_, _| {
|
||||
Bounds::<Pixels>::default()
|
||||
});
|
||||
let bounds = *column.read(cx);
|
||||
let (align, width) = (media.align, media.width);
|
||||
let owner = cx.entity_id();
|
||||
let edge = |side: &'static str, cx: &mut Context<BlockEditor>| {
|
||||
let entity = cx.entity();
|
||||
div()
|
||||
.id((id.clone(), format!("edge-{key}-{side}")))
|
||||
.absolute()
|
||||
.top_0()
|
||||
.bottom_0()
|
||||
.map(|edge| {
|
||||
if side == "left" {
|
||||
edge.left_0()
|
||||
} else {
|
||||
edge.right_0()
|
||||
}
|
||||
})
|
||||
.w_2()
|
||||
.cursor_col_resize()
|
||||
.on_drag(Resize { owner, key }, move |_, _, _, cx| {
|
||||
entity.update(cx, |editor, cx| editor.resize(key, width, true, cx));
|
||||
log::info!("image {key}: resize");
|
||||
cx.new(|_| EmptyView)
|
||||
})
|
||||
};
|
||||
let aligned = |align_to: Align, icon: IconName, cx: &mut Context<BlockEditor>| {
|
||||
IconButton::new((id.clone(), format!("align-{key}-{align_to:?}")), icon)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip(format!("Align {align_to:?}").to_lowercase())
|
||||
.on_click(cx.listener(move |editor, _, _, cx| editor.align(key, align_to, cx)))
|
||||
};
|
||||
let tools = div()
|
||||
.absolute()
|
||||
.top_1()
|
||||
.right_1()
|
||||
.flex()
|
||||
.gap_0p5()
|
||||
.p_0p5()
|
||||
.rounded(look.radius)
|
||||
.bg(colors.overlay)
|
||||
.opacity(0.0)
|
||||
.group_hover(format!("picture-{key}"), |tools| tools.opacity(1.0))
|
||||
.child(aligned(Align::Left, IconName::AlignLeft, cx))
|
||||
.child(aligned(Align::Center, IconName::AlignCenter, cx))
|
||||
.child(aligned(Align::Right, IconName::AlignRight, cx));
|
||||
let picture = div()
|
||||
.id((id.clone(), format!("picture-{key}")))
|
||||
.group(format!("picture-{key}"))
|
||||
.relative()
|
||||
.w(bounds.size.width * width)
|
||||
.child(
|
||||
img(source(&media.source))
|
||||
.id((id.clone(), format!("img-{key}")))
|
||||
.w(bounds.size.width * width)
|
||||
.rounded(look.radius),
|
||||
)
|
||||
.child(edge("left", cx))
|
||||
.child(edge("right", cx))
|
||||
.child(tools);
|
||||
let entity = cx.entity();
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.id((id.clone(), format!("column-{key}")))
|
||||
.w_full()
|
||||
.on_drag_move(move |event: &gpui::DragMoveEvent<Resize>, _, cx| {
|
||||
if event.drag(cx).owner != owner || event.drag(cx).key != key {
|
||||
return;
|
||||
}
|
||||
let width = share(align, event.bounds, event.event.position.x);
|
||||
entity.update(cx, |editor, cx| editor.resize(key, width, false, cx));
|
||||
})
|
||||
.child(
|
||||
Measure::new(
|
||||
(id.clone(), format!("measure-{key}")),
|
||||
move |measured, _, cx| {
|
||||
column.update(cx, |column, cx| {
|
||||
*column = measured;
|
||||
cx.notify();
|
||||
})
|
||||
},
|
||||
)
|
||||
.w_full()
|
||||
.flex()
|
||||
.map(|row| match align {
|
||||
Align::Left => row.justify_start(),
|
||||
Align::Center => row.justify_center(),
|
||||
Align::Right => row.justify_end(),
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.w(bounds.size.width * width)
|
||||
.child(picture)
|
||||
.child(caption),
|
||||
),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// A diagram the host drew, and the width its SVG asks for.
|
||||
#[derive(Clone)]
|
||||
struct Drawn {
|
||||
image: Arc<Image>,
|
||||
width: Option<f32>,
|
||||
}
|
||||
|
||||
/// The width an SVG's root asks for, in pixels, when it gives one.
|
||||
pub(crate) fn svg_width(svg: &str) -> Option<f32> {
|
||||
let root = Regex::new(r#"<svg\b[^>]*?\swidth=['"]([0-9.]+)(px)?['"]"#)
|
||||
.expect("the SVG width pattern compiles");
|
||||
root.captures(svg)?.get(1)?.as_str().parse().ok()
|
||||
}
|
||||
|
||||
/// A diagram's source above the picture the host draws from it.
|
||||
pub(super) fn diagram(
|
||||
editor: &BlockEditor,
|
||||
block: &Block,
|
||||
look: &Look,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<BlockEditor>,
|
||||
) -> AnyElement {
|
||||
let (key, id) = (block.key, BlockEditor::id(cx));
|
||||
let text = block.fields[0].read(cx).text().to_string();
|
||||
let drawn = window.use_keyed_state((id.clone(), format!("drawn-{key}")), cx, |_, _| {
|
||||
None::<(String, Result<Drawn, String>)>
|
||||
});
|
||||
let stale = drawn
|
||||
.read(cx)
|
||||
.as_ref()
|
||||
.is_none_or(|(source, _)| *source != text);
|
||||
if stale && let Some(render) = &editor.diagram {
|
||||
let picture = render(&text).map(|svg| Drawn {
|
||||
width: svg_width(&svg),
|
||||
image: Arc::new(Image::from_bytes(ImageFormat::Svg, svg.into_bytes())),
|
||||
});
|
||||
log::info!(
|
||||
"diagram {key}: drawn, {}",
|
||||
if picture.is_ok() {
|
||||
"fine"
|
||||
} else {
|
||||
"with an error"
|
||||
}
|
||||
);
|
||||
drawn.update(cx, |drawn, _| *drawn = Some((text.clone(), picture)));
|
||||
}
|
||||
let colors = look.colors.clone();
|
||||
let shown: AnyElement = match (editor.diagram.is_some(), drawn.read(cx).clone()) {
|
||||
_ if text.trim().is_empty() => div().into_any_element(),
|
||||
(false, _) => div()
|
||||
.text_color(colors.fg_subtle)
|
||||
.child("Diagrams draw once the app gives a renderer")
|
||||
.into_any_element(),
|
||||
(true, Some((_, Ok(drawn)))) => {
|
||||
let column =
|
||||
window.use_keyed_state((id.clone(), format!("diagram-width-{key}")), cx, |_, _| {
|
||||
Pixels::ZERO
|
||||
});
|
||||
let width = drawn.width.map_or(*column.read(cx), |natural| {
|
||||
Pixels::from(natural).min(*column.read(cx))
|
||||
});
|
||||
Measure::new(
|
||||
(id.clone(), format!("diagram-measure-{key}")),
|
||||
move |bounds, _, cx| {
|
||||
column.update(cx, |column, cx| {
|
||||
*column = bounds.size.width;
|
||||
cx.notify();
|
||||
})
|
||||
},
|
||||
)
|
||||
.w_full()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.child(
|
||||
img(drawn.image)
|
||||
.id((id.clone(), format!("diagram-{key}")))
|
||||
.w(width),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
(true, Some((_, Err(error)))) => div()
|
||||
.text_color(colors.danger)
|
||||
.child(error)
|
||||
.into_any_element(),
|
||||
(true, None) => unreachable!("a renderer draws before this shows"),
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.p_3()
|
||||
.rounded(look.radius)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.child(
|
||||
div()
|
||||
.font_family(look.mono.clone())
|
||||
.text_size(look.small)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(editor.field_view(block, 0, window, cx)),
|
||||
)
|
||||
.child(shown)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpui::{Bounds, point, px, size};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_svg_gives_its_width_when_it_has_one() {
|
||||
assert_eq!(
|
||||
svg_width("<svg xmlns='x' width='504' height='44'>"),
|
||||
Some(504.0)
|
||||
);
|
||||
assert_eq!(svg_width("<svg width=\"120px\">"), Some(120.0));
|
||||
assert_eq!(
|
||||
svg_width("<svg viewBox='0 0 10 10'><rect width='5'/></svg>"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edge_sets_its_share_by_how_the_picture_sits() {
|
||||
let column = Bounds::new(point(px(100.0), px(0.0)), size(px(400.0), px(10.0)));
|
||||
assert_eq!(share(Align::Left, column, px(300.0)), 0.5);
|
||||
assert_eq!(share(Align::Right, column, px(300.0)), 0.5);
|
||||
assert_eq!(share(Align::Center, column, px(400.0)), 0.5);
|
||||
assert_eq!(
|
||||
share(Align::Center, column, px(300.0)),
|
||||
NARROWEST,
|
||||
"a picture keeps some width"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
use gpui::{
|
||||
AnyElement, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, Styled,
|
||||
Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::{Block, BlockEditor, BlockKind, view::Look};
|
||||
use crate::{
|
||||
buttons::{ButtonVariant, IconButton},
|
||||
primitives::IconName,
|
||||
theme::ControlSize,
|
||||
};
|
||||
|
||||
/// A table's shape: columns, rows, and the cells that fold into their left neighbor.
|
||||
fn shape(kind: &BlockKind, fields: usize) -> (usize, usize, &[(usize, usize)]) {
|
||||
let BlockKind::Table { columns, merged } = kind else {
|
||||
unreachable!("a table block is a table")
|
||||
};
|
||||
(*columns, fields / columns, merged)
|
||||
}
|
||||
|
||||
/// How many columns the cell at `(row, column)` spans: itself and every cell folded into it.
|
||||
pub(crate) fn span(merged: &[(usize, usize)], columns: usize, row: usize, column: usize) -> usize {
|
||||
1 + (column + 1..columns)
|
||||
.take_while(|next| merged.contains(&(row, *next)))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Merges after `column` is removed: its own go, the ones right of it move left.
|
||||
pub(crate) fn without_column(merged: &[(usize, usize)], column: usize) -> Vec<(usize, usize)> {
|
||||
merged
|
||||
.iter()
|
||||
.filter(|(_, at)| *at != column && *at != column + 1)
|
||||
.map(|&(row, at)| (row, if at > column { at - 1 } else { at }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Merges after `row` is removed.
|
||||
pub(crate) fn without_row(merged: &[(usize, usize)], row: usize) -> Vec<(usize, usize)> {
|
||||
merged
|
||||
.iter()
|
||||
.filter(|(at, _)| *at != row)
|
||||
.map(|&(at, column)| (if at > row { at - 1 } else { at }, column))
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl BlockEditor {
|
||||
/// Adds a row of empty cells at the table's foot.
|
||||
pub(crate) fn add_row(&mut self, key: u64, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let ix = self.index(key);
|
||||
let (columns, _, _) = shape(&self.blocks[ix].kind, self.blocks[ix].fields.len());
|
||||
self.before_change(cx);
|
||||
for _ in 0..columns {
|
||||
self.add_field(key, None, window, cx);
|
||||
}
|
||||
log::info!("table {key}: a row added");
|
||||
self.after_change(cx);
|
||||
}
|
||||
|
||||
/// Adds a column of empty cells at the table's right.
|
||||
pub(crate) fn add_column(&mut self, key: u64, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let ix = self.index(key);
|
||||
let (columns, rows, merged) = shape(&self.blocks[ix].kind, self.blocks[ix].fields.len());
|
||||
let merged = merged.to_vec();
|
||||
self.before_change(cx);
|
||||
for row in (0..rows).rev() {
|
||||
self.add_field(key, Some((row + 1) * columns), window, cx);
|
||||
}
|
||||
self.blocks[ix].kind = BlockKind::Table {
|
||||
columns: columns + 1,
|
||||
merged,
|
||||
};
|
||||
log::info!("table {key}: a column added");
|
||||
self.after_change(cx);
|
||||
}
|
||||
|
||||
/// Removes the row or column holding cell `cell`; a table keeps a header, a row and a column.
|
||||
pub(crate) fn remove_line(
|
||||
&mut self,
|
||||
key: u64,
|
||||
cell: usize,
|
||||
column: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let ix = self.index(key);
|
||||
let (columns, rows, merged) = shape(&self.blocks[ix].kind, self.blocks[ix].fields.len());
|
||||
let merged = merged.to_vec();
|
||||
let (row, at) = (cell / columns, cell % columns);
|
||||
if (column && columns == 1) || (!column && rows <= 2) {
|
||||
log::info!("table {key}: its last line stays");
|
||||
return;
|
||||
}
|
||||
self.before_change(cx);
|
||||
let (gone, kind): (Vec<usize>, BlockKind) = if column {
|
||||
(
|
||||
(0..rows).map(|row| row * columns + at).collect(),
|
||||
BlockKind::Table {
|
||||
columns: columns - 1,
|
||||
merged: without_column(&merged, at),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
(
|
||||
(row * columns..(row + 1) * columns).collect(),
|
||||
BlockKind::Table {
|
||||
columns,
|
||||
merged: without_row(&merged, row),
|
||||
},
|
||||
)
|
||||
};
|
||||
for ix_field in gone.into_iter().rev() {
|
||||
self.remove_field(key, ix_field);
|
||||
}
|
||||
self.blocks[ix].kind = kind;
|
||||
log::info!(
|
||||
"table {key}: {} removed",
|
||||
if column { "a column" } else { "a row" }
|
||||
);
|
||||
self.after_change(cx);
|
||||
}
|
||||
|
||||
/// Folds the cell right of `cell` into it, moving its text over; or, when already merged, parts them again.
|
||||
pub(crate) fn merge_right(&mut self, key: u64, cell: usize, cx: &mut Context<Self>) {
|
||||
let ix = self.index(key);
|
||||
let (columns, _, merged) = shape(&self.blocks[ix].kind, self.blocks[ix].fields.len());
|
||||
let (row, column) = (cell / columns, cell % columns);
|
||||
let spanned = span(merged, columns, row, column);
|
||||
if column + spanned >= columns && spanned == 1 {
|
||||
return;
|
||||
}
|
||||
let mut merged = merged.to_vec();
|
||||
self.before_change(cx);
|
||||
if spanned > 1 {
|
||||
merged
|
||||
.retain(|(at_row, at)| !(*at_row == row && *at > column && *at < column + spanned));
|
||||
log::info!("table {key}: cell {cell} parted");
|
||||
} else {
|
||||
let right = self.blocks[ix].fields[cell + 1].read(cx).text().to_string();
|
||||
if !right.is_empty() {
|
||||
self.blocks[ix].fields[cell].update(cx, |field, cx| {
|
||||
let end = field.text().len();
|
||||
field.select(end..end, cx);
|
||||
field.insert(&format!(" {right}"), cx);
|
||||
});
|
||||
self.blocks[ix].fields[cell + 1].update(cx, |field, cx| field.set_text("", cx));
|
||||
}
|
||||
merged.push((row, column + 1));
|
||||
log::info!("table {key}: cell {cell} merged right");
|
||||
}
|
||||
self.blocks[ix].kind = BlockKind::Table { columns, merged };
|
||||
self.after_change(cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// The table: a header row, then rows; cells merged across span their neighbors; tools above while a cell has focus.
|
||||
pub(super) fn table(
|
||||
editor: &BlockEditor,
|
||||
block: &Block,
|
||||
look: &Look,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<BlockEditor>,
|
||||
) -> AnyElement {
|
||||
let (columns, rows, merged) = shape(&block.kind, block.fields.len());
|
||||
let merged = merged.to_vec();
|
||||
let (key, id) = (block.key, BlockEditor::id(cx));
|
||||
let focused = editor
|
||||
.focused(window, cx)
|
||||
.filter(|(at, _)| *at == key)
|
||||
.map(|(_, cell)| cell);
|
||||
let colors = &look.colors;
|
||||
let tool = |name: &'static str,
|
||||
icon: IconName,
|
||||
run: fn(&mut BlockEditor, u64, usize, &mut Window, &mut Context<BlockEditor>),
|
||||
cell: usize,
|
||||
cx: &mut Context<BlockEditor>| {
|
||||
IconButton::new((id.clone(), format!("table-{key}-{name}")), icon)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip(name)
|
||||
.on_click(cx.listener(move |editor, _, window, cx| run(editor, key, cell, window, cx)))
|
||||
};
|
||||
let tools = focused.map(|cell| {
|
||||
div()
|
||||
.flex()
|
||||
.gap_0p5()
|
||||
.child(tool(
|
||||
"Add row",
|
||||
IconName::Rows2,
|
||||
|editor, key, _, window, cx| editor.add_row(key, window, cx),
|
||||
cell,
|
||||
cx,
|
||||
))
|
||||
.child(tool(
|
||||
"Add column",
|
||||
IconName::Columns2,
|
||||
|editor, key, _, window, cx| editor.add_column(key, window, cx),
|
||||
cell,
|
||||
cx,
|
||||
))
|
||||
.child(tool(
|
||||
"Remove row",
|
||||
IconName::Minus,
|
||||
|editor, key, cell, _, cx| editor.remove_line(key, cell, false, cx),
|
||||
cell,
|
||||
cx,
|
||||
))
|
||||
.child(tool(
|
||||
"Remove column",
|
||||
IconName::X,
|
||||
|editor, key, cell, _, cx| editor.remove_line(key, cell, true, cx),
|
||||
cell,
|
||||
cx,
|
||||
))
|
||||
.child(tool(
|
||||
"Merge or part cells",
|
||||
IconName::Merge,
|
||||
|editor, key, cell, _, cx| editor.merge_right(key, cell, cx),
|
||||
cell,
|
||||
cx,
|
||||
))
|
||||
});
|
||||
let mut grid = div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.rounded(look.radius)
|
||||
.border_1()
|
||||
.border_color(colors.border);
|
||||
for row in 0..rows {
|
||||
let mut line = div().flex().when(row > 0, |line| {
|
||||
line.border_t_1().border_color(colors.border)
|
||||
});
|
||||
if row == 0 {
|
||||
line = line.bg(colors.hover).font_weight(FontWeight::MEDIUM);
|
||||
}
|
||||
for column in 0..columns {
|
||||
if merged.contains(&(row, column)) {
|
||||
continue;
|
||||
}
|
||||
let cell = row * columns + column;
|
||||
let wide = span(&merged, columns, row, column);
|
||||
line = line.child(
|
||||
div()
|
||||
.flex_grow()
|
||||
.flex_basis(gpui::relative(wide as f32 / columns as f32))
|
||||
.min_w_0()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.when(column > 0, |cell| {
|
||||
cell.border_l_1().border_color(colors.border)
|
||||
})
|
||||
.child(editor.field_view(block, cell, window, cx)),
|
||||
);
|
||||
}
|
||||
grid = grid.child(line);
|
||||
}
|
||||
div()
|
||||
.id((id.clone(), format!("table-{key}")))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.children(tools)
|
||||
.child(grid)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_merged_cell_spans_what_folds_into_it() {
|
||||
let merged = [(1, 1), (1, 2)];
|
||||
assert_eq!(span(&merged, 4, 1, 0), 3);
|
||||
assert_eq!(span(&merged, 4, 0, 0), 1);
|
||||
assert_eq!(span(&merged, 4, 1, 3), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_line_moves_the_merges_past_it() {
|
||||
let merged = [(0, 1), (1, 3), (2, 2)];
|
||||
assert_eq!(
|
||||
without_column(&merged, 1),
|
||||
[(1, 2)],
|
||||
"a cell folded into the removed column parts"
|
||||
);
|
||||
assert_eq!(without_row(&merged, 1), [(0, 1), (1, 2)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, Context, ElementId, InteractiveElement, IntoElement, MouseButton,
|
||||
ParentElement, Rems, Render, SharedString, Styled, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::{Block, BlockData, BlockEditor, BlockKind, matching};
|
||||
use crate::{
|
||||
buttons::{ButtonVariant, IconButton},
|
||||
documents::suggest::{Offer, offers, replace_trigger},
|
||||
forms::{Backspace, Choice, Down, Enter, Pick, Redo, Submit, Undo, Up},
|
||||
menus::{Menu, MenuItem, OverflowMenu},
|
||||
motion::Reorder,
|
||||
primitives::IconName,
|
||||
theme::{ActiveTheme, ControlSize, Palette, Radius, TextSize},
|
||||
};
|
||||
|
||||
/// Theme values a block reads, taken before its fields borrow the context.
|
||||
pub(crate) struct Look {
|
||||
pub colors: Palette,
|
||||
pub mono: SharedString,
|
||||
pub radius: Rems,
|
||||
pub small: Rems,
|
||||
pub tiny: Rems,
|
||||
pub headings: [Rems; 3],
|
||||
}
|
||||
|
||||
impl Look {
|
||||
fn new(cx: &App) -> Self {
|
||||
let theme = cx.theme();
|
||||
Self {
|
||||
colors: theme.colors.clone(),
|
||||
mono: theme.mono_family.clone(),
|
||||
radius: theme.radius(Radius::Md),
|
||||
small: theme.text_size(TextSize::Sm),
|
||||
tiny: theme.text_size(TextSize::Xs),
|
||||
headings: [TextSize::Xxl, TextSize::Xl, TextSize::Lg].map(|size| theme.text_size(size)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockEditor {
|
||||
pub(crate) fn id(cx: &Context<Self>) -> ElementId {
|
||||
("ely-blocks", cx.entity_id()).into()
|
||||
}
|
||||
|
||||
/// A slash pick: an emptied block becomes the kind; one with text gets the kind below it.
|
||||
fn pick_kind(
|
||||
&mut self,
|
||||
key: u64,
|
||||
kind: BlockKind,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let ix = self.index(key);
|
||||
let empty = self.blocks[ix]
|
||||
.fields
|
||||
.first()
|
||||
.is_some_and(|field| field.read(cx).is_empty());
|
||||
let data = BlockData {
|
||||
texts: vec![String::new(); kind.fields()],
|
||||
kind: kind.clone(),
|
||||
};
|
||||
let placed = if empty {
|
||||
self.turn_into(key, kind.clone(), window, cx);
|
||||
key
|
||||
} else {
|
||||
self.insert(Some(key), data, window, cx)
|
||||
};
|
||||
if kind == BlockKind::Divider {
|
||||
self.insert(
|
||||
Some(placed),
|
||||
BlockData::new(BlockKind::Paragraph, [""]),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A text field with its keys and suggests: `/` in prose opens the kinds.
|
||||
pub(crate) fn field_view(
|
||||
&self,
|
||||
block: &Block,
|
||||
ix: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let (key, field) = (block.key, block.fields[ix].clone());
|
||||
let entity = cx.entity();
|
||||
let keys = div()
|
||||
.w_full()
|
||||
.capture_action(cx.listener(move |editor, _: &Enter, window, cx| {
|
||||
if editor.on_enter(key, ix, window, cx) {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}))
|
||||
.capture_action(cx.listener(move |editor, _: &Backspace, window, cx| {
|
||||
if editor.on_backspace(key, ix, window, cx) {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}))
|
||||
.capture_action(cx.listener(move |editor, _: &Up, window, cx| {
|
||||
if editor.on_vertical(key, ix, false, window, cx) {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}))
|
||||
.capture_action(cx.listener(move |editor, _: &Down, window, cx| {
|
||||
if editor.on_vertical(key, ix, true, window, cx) {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}))
|
||||
.capture_action(cx.listener(move |editor, _: &Submit, window, cx| {
|
||||
cx.stop_propagation();
|
||||
editor.on_submit(key, window, cx);
|
||||
}))
|
||||
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
|
||||
.child(field.clone());
|
||||
let slash = move |query: &str, at: usize, caret: usize| -> (Vec<Choice>, Pick) {
|
||||
let kinds = matching(query);
|
||||
let rows = kinds
|
||||
.iter()
|
||||
.map(|kind| Choice::new(kind.label(), kind.label()).icon(kind.icon()))
|
||||
.collect();
|
||||
let (entity, field) = (entity.clone(), field.clone());
|
||||
let pick: Pick = Rc::new(move |pick: usize, window: &mut Window, cx: &mut App| {
|
||||
replace_trigger(&field, at, caret, "", cx);
|
||||
let kind = kinds[pick].clone();
|
||||
entity.update(cx, |editor, cx| editor.pick_kind(key, kind, window, cx));
|
||||
});
|
||||
(rows, pick)
|
||||
};
|
||||
let extra: Option<(char, Offer)> =
|
||||
(block.kind.is_prose() && ix == 0).then_some(('/', &slash));
|
||||
let id = (Self::id(cx), format!("offers-{key}-{ix}")).into();
|
||||
offers(id, &block.fields[ix], &self.people, extra, cx)
|
||||
.wrap(keys, window, cx)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The handle's menu: turn into another kind, duplicate or delete.
|
||||
fn menu(&self, key: u64, cx: &mut Context<Self>) -> Menu {
|
||||
let entity = cx.entity();
|
||||
let turn = super::kind::offered()
|
||||
.into_iter()
|
||||
.fold(Menu::new(), |menu, kind| {
|
||||
let entity = entity.clone();
|
||||
menu.item(MenuItem::new(kind.label()).icon(kind.icon()).on_click(
|
||||
move |window, cx| {
|
||||
let kind = kind.clone();
|
||||
entity.update(cx, |editor, cx| editor.turn_into(key, kind, window, cx));
|
||||
},
|
||||
))
|
||||
});
|
||||
let (copy, gone) = (entity.clone(), entity);
|
||||
Menu::new()
|
||||
.item(MenuItem::submenu("Turn into", turn).icon(IconName::RefreshCw))
|
||||
.item(
|
||||
MenuItem::new("Duplicate")
|
||||
.icon(IconName::Copy)
|
||||
.on_click(move |window, cx| {
|
||||
copy.update(cx, |editor, cx| editor.duplicate(key, window, cx));
|
||||
}),
|
||||
)
|
||||
.separator()
|
||||
.item(
|
||||
MenuItem::new("Delete")
|
||||
.icon(IconName::Trash2)
|
||||
.on_click(move |window, cx| {
|
||||
gone.update(cx, |editor, cx| editor.remove(key, window, cx));
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for BlockEditor {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let id = Self::id(cx);
|
||||
let look = Look::new(cx);
|
||||
let colors = look.colors.clone();
|
||||
let focused = self.focused(window, cx).map(|(key, _)| key);
|
||||
let mut number = 0;
|
||||
let mut rows: Vec<(SharedString, AnyElement)> = Vec::new();
|
||||
for ix in 0..self.blocks.len() {
|
||||
let block = &self.blocks[ix];
|
||||
number = if block.kind == BlockKind::Numbered {
|
||||
number + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let key = block.key;
|
||||
let group = SharedString::from(format!("block-{key}"));
|
||||
let entity = cx.entity();
|
||||
let plus = IconButton::new((id.clone(), format!("plus-{key}")), IconName::Plus)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Add a block below")
|
||||
.on_click(move |_, window, cx| {
|
||||
entity.update(cx, |editor, cx| {
|
||||
editor.insert(
|
||||
Some(key),
|
||||
BlockData::new(BlockKind::Paragraph, ["/"]),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
});
|
||||
let handle =
|
||||
OverflowMenu::new((id.clone(), format!("handle-{key}")), self.menu(key, cx))
|
||||
.icon(IconName::GripVertical)
|
||||
.tooltip("Drag to move, press for more");
|
||||
let content = self.content(&self.blocks[ix], number, &look, window, cx);
|
||||
let row = div()
|
||||
.id((id.clone(), format!("row-{key}")))
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(move |editor, _, window, cx| {
|
||||
let end = editor.blocks[editor.index(key)]
|
||||
.fields
|
||||
.first()
|
||||
.map(|field| field.read(cx).text().len());
|
||||
if end.is_some() {
|
||||
editor.focus_field(key, 0, end, window, cx);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.group(group.clone())
|
||||
.relative()
|
||||
.pl_16()
|
||||
.py_0p5()
|
||||
.child(content)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.flex()
|
||||
.opacity(if focused == Some(key) { 1.0 } else { 0.0 })
|
||||
.group_hover(group, |gutter| gutter.opacity(1.0))
|
||||
.child(plus)
|
||||
.child(handle),
|
||||
);
|
||||
rows.push((format!("{key}").into(), row.into_any_element()));
|
||||
}
|
||||
let entity = cx.entity();
|
||||
let list = rows.into_iter().fold(
|
||||
Reorder::new((id.clone(), "blocks")).on_reorder(move |from, to, _, cx| {
|
||||
entity.update(cx, |editor, cx| editor.move_block(from, to, cx))
|
||||
}),
|
||||
|list, (key, row)| list.row(key, row),
|
||||
);
|
||||
let start = cx.entity();
|
||||
div()
|
||||
.id(id.clone())
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.capture_action(cx.listener(|editor, _: &Undo, window, cx| {
|
||||
cx.stop_propagation();
|
||||
editor.undo(window, cx);
|
||||
}))
|
||||
.capture_action(cx.listener(|editor, _: &Redo, window, cx| {
|
||||
cx.stop_propagation();
|
||||
editor.redo(window, cx);
|
||||
}))
|
||||
.child(list)
|
||||
.child(
|
||||
div()
|
||||
.id((id, "end"))
|
||||
.min_h_8()
|
||||
.cursor_text()
|
||||
.text_color(colors.fg_subtle)
|
||||
.when(self.blocks.is_empty(), |end| end.child("Press to write"))
|
||||
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
||||
window.prevent_default();
|
||||
start.update(cx, |editor, cx| {
|
||||
let last = editor.blocks.last().map(|block| block.key);
|
||||
editor.insert(
|
||||
last,
|
||||
BlockData::new(BlockKind::Paragraph, [""]),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user