- 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
234 lines
8.8 KiB
Rust
234 lines
8.8 KiB
Rust
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)"
|
|
);
|
|
}
|
|
}
|