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,233 @@
|
||||
use std::{ops::Range, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
App, AppContext as _, Context, ElementId, Entity, Focusable, InteractiveElement, IntoElement,
|
||||
ParentElement, RenderOnce, Styled, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::markdown::link_at;
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant, IconButton},
|
||||
forms::{Enter, Input, Run, TextInput, float, is_url, surface},
|
||||
primitives::{FocusScope, IconName, give_back, take_focus},
|
||||
theme::{ActiveTheme, ControlSize},
|
||||
};
|
||||
|
||||
/// What the editor edits: a link's range and words, or the selection that becomes one.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Linking {
|
||||
pub range: Range<usize>,
|
||||
pub label: String,
|
||||
/// The address, when the range is already a link.
|
||||
pub address: Option<String>,
|
||||
}
|
||||
|
||||
impl Linking {
|
||||
/// The link around the selection, or the selection itself.
|
||||
pub fn at(text: &str, selection: Range<usize>) -> Self {
|
||||
match link_at(text, selection.start) {
|
||||
Some((range, label, address)) if selection.end <= range.end => Self {
|
||||
range,
|
||||
label,
|
||||
address: Some(address),
|
||||
},
|
||||
_ => Self {
|
||||
label: text[selection.clone()].to_string(),
|
||||
range: selection,
|
||||
address: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The link written in markdown for `address`; with no words of its own it reads as its address.
|
||||
pub fn written(&self, address: &str) -> String {
|
||||
let label = if self.label.is_empty() {
|
||||
address
|
||||
} else {
|
||||
&self.label
|
||||
};
|
||||
format!("[{label}]({address})")
|
||||
}
|
||||
}
|
||||
|
||||
/// The address field and whether it took focus yet.
|
||||
struct Editing {
|
||||
address: Entity<TextInput>,
|
||||
seeded: bool,
|
||||
}
|
||||
|
||||
/// Replaces `range` of the field's text with `text` as one undo step, and closes.
|
||||
fn write(field: &Entity<TextInput>, range: Range<usize>, text: &str, cx: &mut App) {
|
||||
field.update(cx, |input, cx| {
|
||||
input.select(range, cx);
|
||||
input.insert(text, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Edits the link at the caret, or links the selection, in a panel under it: the address is checked as it is typed and applied with Enter; the link can be removed or opened from the same place. Escape or a press outside closes it, and focus returns to the text. Render it only while open.
|
||||
#[derive(IntoElement)]
|
||||
pub struct LinkEditor {
|
||||
id: ElementId,
|
||||
field: Entity<TextInput>,
|
||||
on_close: Run,
|
||||
}
|
||||
|
||||
impl LinkEditor {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
field: &Entity<TextInput>,
|
||||
on_close: impl Fn(&mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
field: field.clone(),
|
||||
on_close: Rc::new(on_close),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for LinkEditor {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let input = self.field.read(cx);
|
||||
let linking = Linking::at(input.text(), input.selection());
|
||||
let anchor = input.bounds_for(linking.range.start);
|
||||
let takeover = take_focus((self.id.clone(), "takeover"), window, cx);
|
||||
let editing = window.use_keyed_state(
|
||||
(self.id.clone(), "editing"),
|
||||
cx,
|
||||
|window, cx: &mut Context<Editing>| Editing {
|
||||
address: cx.new(|cx| TextInput::new(window, cx).placeholder("https://")),
|
||||
seeded: false,
|
||||
},
|
||||
);
|
||||
let address = editing.read(cx).address.clone();
|
||||
if !editing.read(cx).seeded {
|
||||
log::info!("link editor: open over {:?}", linking.range);
|
||||
let written = linking.address.clone().unwrap_or_default();
|
||||
address.update(cx, |field, cx| field.set_text(written, cx));
|
||||
window.focus(&address.focus_handle(cx));
|
||||
editing.update(cx, |editing, _| editing.seeded = true);
|
||||
}
|
||||
let focus = takeover.read(cx).focus.clone();
|
||||
let close: Run = {
|
||||
let (takeover, on_close) = (takeover.clone(), self.on_close.clone());
|
||||
Rc::new(move |window, cx| {
|
||||
log::info!("link editor: closed");
|
||||
give_back(&takeover, window, cx);
|
||||
on_close(window, cx);
|
||||
})
|
||||
};
|
||||
let typed = address.read(cx).text().trim().to_string();
|
||||
let valid = is_url(&typed);
|
||||
let apply: Run = {
|
||||
let (field, linking, close) = (self.field.clone(), linking.clone(), close.clone());
|
||||
let typed = typed.clone();
|
||||
Rc::new(move |window, cx| {
|
||||
if !is_url(&typed) {
|
||||
return;
|
||||
}
|
||||
log::info!("link editor: {typed} over {:?}", linking.range);
|
||||
write(&field, linking.range.clone(), &linking.written(&typed), cx);
|
||||
close(window, cx);
|
||||
})
|
||||
};
|
||||
let remove: Option<Run> = linking.address.as_ref().map(|_| {
|
||||
let (field, linking, close) = (self.field.clone(), linking.clone(), close.clone());
|
||||
Rc::new(move |window: &mut Window, cx: &mut App| {
|
||||
log::info!("link editor: unlinked {:?}", linking.range);
|
||||
write(&field, linking.range.clone(), &linking.label, cx);
|
||||
close(window, cx);
|
||||
}) as Run
|
||||
});
|
||||
let Some(anchor) = anchor else {
|
||||
return div().id(self.id);
|
||||
};
|
||||
let (enter, escape, out) = (apply.clone(), close.clone(), close.clone());
|
||||
let press = apply.clone();
|
||||
let opened = linking.address.clone();
|
||||
let panel = div()
|
||||
.id((self.id.clone(), "panel"))
|
||||
.occlude()
|
||||
.capture_action(move |_: &Enter, window, cx| {
|
||||
cx.stop_propagation();
|
||||
enter(window, cx);
|
||||
})
|
||||
.on_key_down(move |event, window, cx| {
|
||||
if event.keystroke.key == "escape" {
|
||||
cx.stop_propagation();
|
||||
escape(window, cx);
|
||||
}
|
||||
})
|
||||
.on_mouse_down_out(move |_, window, cx| out(window, cx))
|
||||
.child(
|
||||
FocusScope::new(&focus).trap().child(
|
||||
surface((self.id.clone(), "surface"), cx)
|
||||
.p_2()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div().w(cx.theme().menu_width()).child(
|
||||
Input::new(&address)
|
||||
.size(ControlSize::Sm)
|
||||
.invalid(!typed.is_empty() && !valid),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new((self.id.clone(), "apply"), "Apply")
|
||||
.variant(ButtonVariant::Primary)
|
||||
.size(ControlSize::Sm)
|
||||
.disabled(!valid)
|
||||
.on_click(move |_, window, cx| press(window, cx)),
|
||||
)
|
||||
.when_some(opened, |row, url| {
|
||||
row.child(
|
||||
IconButton::new((self.id.clone(), "open"), IconName::ExternalLink)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Open link")
|
||||
.on_click(move |_, _, cx| cx.open_url(&url)),
|
||||
)
|
||||
})
|
||||
.when_some(remove, |row, remove| {
|
||||
row.child(
|
||||
IconButton::new((self.id.clone(), "remove"), IconName::Trash2)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Remove link")
|
||||
.on_click(move |_, window, cx| remove(window, cx)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
);
|
||||
div().id(self.id).child(float(anchor, 1, panel, window, cx))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Linking;
|
||||
|
||||
#[test]
|
||||
fn a_link_is_edited_whole_and_a_selection_becomes_one() {
|
||||
let text = "see [the docs](https://ely.dev) here";
|
||||
let inside = Linking::at(text, 8..8);
|
||||
assert_eq!(
|
||||
(inside.range, inside.address.as_deref()),
|
||||
(4..31, Some("https://ely.dev"))
|
||||
);
|
||||
let plain = Linking::at(text, 32..36);
|
||||
assert_eq!(
|
||||
(
|
||||
plain.range.clone(),
|
||||
plain.label.as_str(),
|
||||
plain.address.clone()
|
||||
),
|
||||
(32..36, "here", None)
|
||||
);
|
||||
assert_eq!(plain.written("https://x.dev"), "[here](https://x.dev)");
|
||||
let bare = Linking::at(text, 0..0);
|
||||
assert_eq!(
|
||||
bare.written("https://x.dev"),
|
||||
"[https://x.dev](https://x.dev)"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user