diff --git a/rust/apps/lexical-playground-core/src/lib.rs b/rust/apps/lexical-playground-core/src/lib.rs index 39c45fb2ce9..072029e8988 100644 --- a/rust/apps/lexical-playground-core/src/lib.rs +++ b/rust/apps/lexical-playground-core/src/lib.rs @@ -44,6 +44,11 @@ pub struct Run { pub style: String, /// The run's start in its block's text, in bytes. pub start: usize, + /// The index, among the block's children, of the child the run is or + /// is inside (a link holds several runs). + pub child: usize, + /// The URL of the link the run is in. + pub link: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -69,21 +74,63 @@ impl Run { } } -/// A child of the root: a paragraph, or later a heading, quote, list ... +/// A block the app draws as a line of text: an element that holds inline +/// content (a paragraph, a heading, a quote, a list item). Lists, and list +/// items that only hold a nested list, aren't drawn themselves; their +/// items are. #[derive(Clone, Debug, PartialEq)] pub struct Block { pub key: String, - /// The node's type (`"paragraph"`, `"heading"`, ...). + /// The node's type (`"paragraph"`, `"heading"`, `"quote"`, + /// `"listitem"`). pub kind: String, /// A heading's `tag` (`"h1"` ...), from its JSON. pub tag: Option, /// The element format (`"left"`, `"center"`, ...; `""` for none). pub align: String, + /// The element's `indent`. + pub indent: usize, + /// For a list item, its list. + pub list: Option, + /// The elements from the root down to the block, each with the index of + /// the child the path goes through: `[("root", 2), ("5", 0)]` for the + /// first item of the list that is the root's third child. + pub path: Vec<(String, usize)>, pub runs: Vec, /// The runs' text, joined. pub text: String, } +/// A list item's list, as the app draws its marker. +#[derive(Clone, Debug, PartialEq)] +pub struct ListInfo { + /// `"bullet"`, `"number"` or `"check"`. + pub list_type: String, + /// How deeply the list is nested; 0 for a top-level list. + pub depth: usize, + /// The item's `value`: its number in a numbered list. + pub value: u32, + /// A check item's `checked`. + pub checked: bool, +} + +impl Block { + /// What the toolbar's block menu says the block is: `"paragraph"`, + /// `"h1"` ... `"h6"`, `"quote"`, or a list item's list type + /// (`"bullet"`, `"number"`, `"check"`), as the JS playground's + /// `blockType`. + pub fn block_type(&self) -> &str { + if let Some(list) = &self.list { + return &list.list_type; + } + match (self.kind.as_str(), &self.tag) { + ("heading", Some(tag)) => tag, + ("quote", _) => "quote", + _ => "paragraph", + } + } +} + /// A position in the document: a block and a byte offset in its text. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Default)] pub struct Pos { @@ -161,13 +208,19 @@ impl Point { #[derive(Clone, Debug, Default, PartialEq)] pub struct Document { pub blocks: Vec, + /// The elements that hold blocks but aren't drawn themselves (lists, + /// and list items that only hold a nested list), by key, as the tree + /// view names them: `"list bullet"`, `"listitem"`. + pub containers: Vec<(String, String)>, } impl Document { /// Reads the current editor state. Call it in a read of the editor. fn read() -> Document { - let blocks = get_root().get_children().iter().map(read_block).collect(); - Document { blocks } + let mut blocks = Vec::new(); + let mut containers = Vec::new(); + read_blocks(&get_root(), &mut vec![], None, &mut blocks, &mut containers); + Document { blocks, containers } } /// The last position. @@ -208,27 +261,62 @@ impl Document { } Point { key: block.key.clone(), - offset: block.runs.iter().filter(|r| r.start < byte).count(), + offset: block + .runs + .iter() + .filter(|r| r.start < byte) + .map(|r| r.child + 1) + .max() + .unwrap_or(0), text: false, } } + /// The format and style a browser's `selectionchange` gives `sel` + /// (`onSelectionChange` in LexicalEvents.ts): a caret at a text point + /// takes that text node's format and style; a range takes the format + /// every text node it covers has (none when it covers no text), and + /// keeps its style. `None` for a caret at an element point, whose + /// format the editor keeps. + pub fn selection_change_format(&self, sel: Sel) -> Option<(u32, Option)> { + if sel.is_collapsed() { + let point = self.point_at(sel.anchor); + if !point.text { + return None; + } + let run = self.blocks[sel.anchor.block] + .runs + .iter() + .find(|r| r.key == point.key)?; + return Some((run.format, Some(run.style.clone()))); + } + let (start, end) = sel.ordered(); + let mut combined = None; + for (i, block) in self.blocks.iter().enumerate() { + if i < start.block || i > end.block { + continue; + } + for run in block.runs.iter().filter(|r| r.is_text()) { + // Text the range covers some of: a node it only touches at + // its edge is left out, as JS leaves out an empty boundary. + let from = if i == start.block { start.byte } else { 0 }; + let to = if i == end.block { end.byte } else { usize::MAX }; + if run.start < to && run.end() > from && !run.text.is_empty() { + combined = Some(combined.unwrap_or(u32::MAX) & run.format); + } + } + } + Some((combined.unwrap_or(0), None)) + } + /// The position of a selection point, if its node is drawn. pub fn pos_of(&self, point: &Point) -> Option { - if point.key == "root" { - return Some(match self.blocks.get(point.offset) { - Some(_) => Pos { - block: point.offset, - byte: 0, - }, - None => self.end(), - }); - } for (i, block) in self.blocks.iter().enumerate() { if block.key == point.key { let byte = block .runs - .get(point.offset) + .iter() + .find(|r| r.child >= point.offset) .map_or(block.text.len(), |r| r.start); return Some(Pos { block: i, byte }); } @@ -241,7 +329,31 @@ impl Document { return Some(Pos { block: i, byte }); } } - None + // An element point in an element that isn't drawn (the root, a + // list): the start of the first block at or after its child + // `offset`, or the end of its last block. + let under = |b: &Block| { + b.path + .iter() + .find(|(key, _)| *key == point.key) + .map(|p| p.1) + }; + let mut last = None; + for (i, block) in self.blocks.iter().enumerate() { + if let Some(child) = under(block) { + if child >= point.offset { + return Some(Pos { block: i, byte: 0 }); + } + last = Some(i); + } + } + if let Some(i) = last { + return Some(Pos { + block: i, + byte: self.blocks[i].text.len(), + }); + } + (point.key == "root").then(|| self.end()) } /// The document as one string: the blocks' text joined with `\n`, as @@ -308,26 +420,51 @@ impl Document { "capitalize", ]; let mut out = String::from("(root) root\n"); + let mut shown = 0; for block in &self.blocks { + // The undrawn elements on the block's path, the first time a + // block under them comes. + for (depth, (key, _)) in block.path.iter().enumerate().skip(1) { + if let Some(i) = self.containers.iter().position(|(k, _)| k == key) + && i >= shown + { + let pad = " ".repeat(depth); + out.push_str(&format!("{pad}({key}) {}\n", self.containers[i].1)); + shown = i + 1; + } + } let tag = block .tag .as_deref() .map(|t| format!(" {t}")) .unwrap_or_default(); - out.push_str(&format!(" ({}) {}{tag}\n", block.key, block.kind)); + let pad = " ".repeat(block.path.len()); + let list = block + .list + .as_ref() + .map(|l| match l.list_type.as_str() { + "check" => format!(" check {}", if l.checked { "☑" } else { "☐" }), + "number" => format!(" number {}", l.value), + other => format!(" {other}"), + }) + .unwrap_or_default(); + out.push_str(&format!("{pad}({}) {}{tag}{list}\n", block.key, block.kind)); for run in &block.runs { let name = match run.kind { RunKind::Text => "text", RunKind::Tab => "tab", RunKind::LineBreak => "linebreak", }; - out.push_str(&format!(" ({}) {name}", run.key)); + out.push_str(&format!("{pad} ({}) {name}", run.key)); if run.kind == RunKind::Text { out.push_str(&format!(" {:?}", run.text)); } for format in FORMATS.iter().filter(|f| run.has_format(f)) { out.push_str(&format!(" {format}")); } + if let Some(url) = &run.link { + out.push_str(&format!(" link {url}")); + } out.push('\n'); } } @@ -423,52 +560,153 @@ impl Document { } } -fn read_block(node: &NodeRef) -> Block { +/// Whether `node` is drawn as a block: an element that isn't inline and +/// holds no blocks (an empty one too). +fn is_drawn_block(node: &NodeRef) -> bool { + node.is_element() + && !node.is_inline() + && !node.is_root() + && node + .get_children() + .iter() + .all(|c| c.is_inline() || !c.is_element()) + && node.get_type() != "list" +} + +/// Appends the blocks under `node`, whose path from the root is `path`; +/// `list` is the list `node` is an item of. +fn read_blocks( + node: &NodeRef, + path: &mut Vec<(String, usize)>, + list: Option<&ListInfo>, + out: &mut Vec, + containers: &mut Vec<(String, String)>, +) { + let key = if node.is_root() { + "root".to_owned() + } else { + node.get_key().to_owned() + }; + let json = node.export_json(false); + // A list sets what its items are; a list in an item nests one deeper. + let list_here = (node.get_type() == "list").then(|| ListInfo { + list_type: json + .get("listType") + .and_then(Value::as_str) + .unwrap_or("bullet") + .to_owned(), + depth: list.map_or(0, |l| l.depth + 1), + value: 0, + checked: false, + }); + for (i, child) in node.get_children().iter().enumerate() { + path.push((key.clone(), i)); + let list = list_here.as_ref().or(list); + let item = (child.get_type() == "listitem").then(|| { + let json = child.export_json(false); + let mut info = list.cloned().unwrap_or(ListInfo { + list_type: "bullet".into(), + depth: 0, + value: 0, + checked: false, + }); + info.value = json.get("value").and_then(Value::as_f64).unwrap_or(1.0) as u32; + info.checked = json.get("checked").and_then(Value::as_bool) == Some(true); + info + }); + if is_drawn_block(child) { + out.push(read_block(child, path.clone(), item)); + } else if child.is_element() && !child.is_inline() { + let mut name = child.get_type().to_owned(); + if let Some(list_type) = child + .export_json(false) + .get("listType") + .and_then(Value::as_str) + { + name = format!("{name} {list_type}"); + } + containers.push((child.get_key().to_owned(), name)); + read_blocks(child, path, list, out, containers); + } + path.pop(); + } +} + +fn read_block(node: &NodeRef, path: Vec<(String, usize)>, list: Option) -> Block { let json = node.export_json(false); let str_field = |name: &str| json.get(name).and_then(Value::as_str).map(str::to_owned); let mut text = String::new(); let mut runs = Vec::new(); - for child in node.get_children() { - let (kind, run_text, format, style) = match child.get_type() { - "linebreak" => (RunKind::LineBreak, "\n".to_owned(), 0, String::new()), - _ if child.is_text() => { - let data = child.text_data(); - let kind = if child.get_type() == "tab" { - RunKind::Tab - } else { - RunKind::Text - }; - ( - kind, - data.get_text().to_owned(), - data.get_format() as u32, - data.get_style().to_owned(), - ) - } - // Inline elements (links) and decorators come with their - // packages; their text stands in for them until then. - _ => (RunKind::Text, child.get_text_content(), 0, String::new()), - }; - runs.push(Run { - key: child.get_key().to_owned(), - kind, - start: text.len(), - text: run_text.clone(), - format, - style, - }); - text.push_str(&run_text); + for (i, child) in node.get_children().iter().enumerate() { + read_runs(child, i, None, &mut text, &mut runs); } Block { key: node.get_key().to_owned(), kind: node.get_type().to_owned(), tag: str_field("tag"), align: str_field("format").unwrap_or_default(), + indent: json.get("indent").and_then(Value::as_f64).unwrap_or(0.0) as usize, + list, + path, runs, text, } } +/// Appends the runs of `node`, the block's child `child`, inside the link +/// `link` if any: a link's runs are its text's. +fn read_runs( + node: &NodeRef, + child: usize, + link: Option<&str>, + text: &mut String, + runs: &mut Vec, +) { + let (kind, run_text, format, style) = match node.get_type() { + "linebreak" => (RunKind::LineBreak, "\n".to_owned(), 0, String::new()), + "link" | "autolink" => { + let json = node.export_json(false); + let url = json + .get("url") + .and_then(Value::as_str) + .unwrap_or("") + .to_owned(); + for inner in node.get_children() { + read_runs(&inner, child, Some(&url), text, runs); + } + return; + } + _ if node.is_text() => { + let data = node.text_data(); + let kind = if node.get_type() == "tab" { + RunKind::Tab + } else { + RunKind::Text + }; + ( + kind, + data.get_text().to_owned(), + data.get_format() as u32, + data.get_style().to_owned(), + ) + } + // Other inline elements and decorators come with their packages; + // their text stands in for them until then. + _ => (RunKind::Text, node.get_text_content(), 0, String::new()), + }; + runs.push(Run { + key: node.get_key().to_owned(), + kind, + start: text.len(), + text: run_text.clone(), + format, + style, + child, + link: link.map(str::to_owned), + }); + text.push_str(&run_text); +} + /// The flag of a text format (`"bold"` → 1), as `TEXT_TYPE_TO_FORMAT`. pub fn format_flag(name: &str) -> u32 { lexical::toggle_text_format_type(0, name, None) as u32 @@ -608,6 +846,13 @@ impl Playground { pub fn with_clock(children: Vec, clock: Clock) -> Result { let mut script = Object::new(); script.insert("name", "playground".into()); + // `RichTextExtension`, `ListExtension`, `CheckListExtension`, + // `LinkExtension`, `CodeExtension` and `MarkdownShortcutPlugin` + // (`registerMarkdownShortcuts` with `TRANSFORMERS`): what the JS + // playground's editor has for these blocks, links and shortcuts. + for flag in ["richText", "list", "link", "code", "markdown"] { + script.insert(flag, true.into()); + } let mut history = Object::new(); history.insert("delay", HISTORY_DELAY.into()); script.insert("history", Value::Object(history)); @@ -684,8 +929,27 @@ impl Playground { self.last_error.as_deref() } - /// The selection's pending format (what typed text gets). + /// The selection's pending format (what typed text gets), as the + /// toolbar shows it: see [`Playground::format_at`]. pub fn selection_format(&self) -> u32 { + self.selection + .map_or_else(|| self.model_format(), |sel| self.format_at(sel)) + } + + /// The format the toolbar shows at `sel`: the editor's selection's + /// when `sel` is it, and otherwise the one the next edit's select step + /// gives it (see [`Document::selection_change_format`]), so the toolbar + /// follows a click or an arrow key before the editor hears of it. + pub fn format_at(&self, sel: Sel) -> u32 { + if self.selection_from_model() != Some(sel) + && let Some((format, _)) = self.document.selection_change_format(sel) + { + return format; + } + self.model_format() + } + + fn model_format(&self) -> u32 { self.model_selection .as_array() .filter(|a| a.first().and_then(Value::as_str) == Some("range")) @@ -727,12 +991,17 @@ impl Playground { self.edit("insertRawText", vec![text.into()]) } + /// Enter: `KEY_ENTER_COMMAND` with no event, as an input layer without + /// a DOM dispatches it. The markdown shortcuts and the code block take + /// it first; otherwise rich text's handler dispatches + /// `INSERT_PARAGRAPH_COMMAND`, so a list item's handler runs. pub fn insert_paragraph(&mut self) -> Outcome { - self.edit("insertParagraph", vec![]) + self.dispatch("KEY_ENTER_COMMAND", None) } + /// Shift-Enter: `INSERT_LINE_BREAK_COMMAND`. pub fn insert_line_break(&mut self) -> Outcome { - self.edit("insertLineBreak", vec![]) + self.dispatch("INSERT_LINE_BREAK_COMMAND", Some(false.into())) } /// Backspace (`backward`) or Delete: `deleteCharacter`, `deleteWord` or @@ -793,18 +1062,122 @@ impl Playground { /// `formatText(name)`: toggles a format on the selected text, or on the /// pending format at a caret. pub fn format_text(&mut self, name: &str) -> Outcome { - self.edit("formatText", vec![name.into()]) + self.dispatch("FORMAT_TEXT_COMMAND", Some(name.into())) + } + + /// The block menu: `$setBlocksType` with a paragraph, a heading + /// (`"h1"` ... `"h6"`) or a quote, or a list (`"bullet"`, `"number"`, + /// `"check"`) through its command. Choosing a list the selection is + /// already in makes it paragraphs again, as the JS playground's toolbar + /// does. + pub fn set_block_type(&mut self, block_type: &str) -> Outcome { + let command = match block_type { + "bullet" => "INSERT_UNORDERED_LIST_COMMAND", + "number" => "INSERT_ORDERED_LIST_COMMAND", + "check" => "INSERT_CHECK_LIST_COMMAND", + _ => { + let mut action = Object::new(); + action.insert("do", "setBlocksType".into()); + action.insert("block", block_type.into()); + return self.update(vec![Value::Object(action)]); + } + }; + if self.block_type() == block_type { + return self.set_block_type("paragraph"); + } + self.dispatch(command, None) + } + + /// The selection's block type (its anchor's block), for the toolbar. + pub fn block_type(&self) -> &str { + self.selection + .map_or("paragraph", |sel| self.block_type_at(sel)) + } + + /// The block type of `sel`'s anchor's block. + pub fn block_type_at(&self, sel: Sel) -> &str { + self.document + .blocks + .get(sel.anchor.block) + .map_or("paragraph", Block::block_type) + } + + /// `INDENT_CONTENT_COMMAND` (`outdent`: `OUTDENT_CONTENT_COMMAND`). + pub fn indent(&mut self, outdent: bool) -> Outcome { + let command = if outdent { + "OUTDENT_CONTENT_COMMAND" + } else { + "INDENT_CONTENT_COMMAND" + }; + self.dispatch(command, None) + } + + /// Tab and Shift-Tab, as the JS playground's TabIndentationPlugin + /// (`$indentOverTab`): Shift-Tab outdents, and Tab indents when the + /// selection starts at its block's start or spans blocks, and inserts + /// a tab otherwise. + pub fn tab(&mut self, shift: bool) -> Outcome { + if shift { + return self.indent(true); + } + let indents = self.selection.is_some_and(|s| { + let (first, last) = s.ordered(); + first.byte == 0 || first.block != last.block + }); + if indents { + self.indent(false) + } else { + self.insert_raw_text("\t") + } + } + + /// The URL of the link at the selection's focus, if it is in one. + pub fn link(&self) -> Option<&str> { + self.link_at(self.selection?) + } + + /// The URL of the link at `sel`'s focus, if it is in one. + pub fn link_at(&self, sel: Sel) -> Option<&str> { + let block = self.document.blocks.get(sel.focus.block)?; + let byte = sel.focus.byte; + block + .runs + .iter() + .find(|r| r.start < byte && byte <= r.end() || (r.start == byte && byte == 0)) + .and_then(|r| r.link.as_deref()) + } + + /// The link button: `TOGGLE_LINK_COMMAND` with `url`, or with `null` + /// to remove the link the selection is in. + pub fn toggle_link(&mut self, url: Option<&str>) -> Outcome { + self.dispatch( + "TOGGLE_LINK_COMMAND", + Some(url.map_or(Value::Null, Value::from)), + ) + } + + /// Checks or unchecks the check item drawn as `block` + /// (`ListItemNode.toggleChecked`), as clicking its box does. + pub fn toggle_checked(&mut self, block: usize) -> Outcome { + let Some(key) = self.document.blocks.get(block).map(|b| b.key.clone()) else { + return Outcome::default(); + }; + let mut action = Object::new(); + action.insert("do", "node".into()); + action.insert("key", key.into()); + action.insert("method", "toggleChecked".into()); + self.update(vec![Value::Object(action)]) } /// ⌘Z: `UNDO_COMMAND`, which history handles. The app's selection /// reaches the editor first, as a browser's `selectionchange` does. pub fn undo(&mut self) -> Outcome { - self.dispatch("UNDO_COMMAND") + self.dispatch("UNDO_COMMAND", None) } /// ⌘⇧Z: `REDO_COMMAND`. pub fn redo(&mut self) -> Outcome { - self.dispatch("REDO_COMMAND") + self.dispatch("REDO_COMMAND", None) } /// Whether there is something to undo, as history last said @@ -818,15 +1191,20 @@ impl Playground { self.can_redo } - fn dispatch(&mut self, command: &str) -> Outcome { + /// Dispatches `command` outside an update, as a key handler does, after + /// the app's selection reaches the editor. + fn dispatch(&mut self, command: &str, payload: Option) -> Outcome { if let Some(select) = self.select_action() { - let outcome = self.run_update(vec![select]); + let outcome = self.run_update(select); if outcome.error.is_some() { return outcome; } } let mut step = Object::new(); step.insert("dispatch", command.into()); + if let Some(payload) = payload { + step.insert("payload", payload); + } self.run(Value::Object(step)) } @@ -902,7 +1280,7 @@ impl Playground { /// history merges typing after a click as it does in a browser. pub fn update(&mut self, actions: Vec) -> Outcome { if let Some(select) = self.select_action() { - let outcome = self.run_update(vec![select]); + let outcome = self.run_update(select); if outcome.error.is_some() || actions.is_empty() { return outcome; } @@ -926,7 +1304,13 @@ impl Playground { /// the selection would take that node's format, and the next ⌘B, which /// toggles the selection's format (in JS as here), would set bold again /// instead of clearing it. - fn select_action(&self) -> Option { + /// + /// The step also gives the selection the format (and a caret the + /// style) that a browser's `selectionchange` does + /// (`onSelectionChange` in LexicalEvents.ts): a caret in text takes its + /// text node's, and a range the format all its text shares, so the + /// toolbar shows bold in bold text and typing there stays bold. + fn select_action(&self) -> Option> { let sel = self.selection?; if self.selection_from_model() == Some(sel) { return None; @@ -937,7 +1321,21 @@ impl Playground { action.insert("do", "select".into()); action.insert("anchor", anchor); action.insert("focus", focus); - Some(Value::Object(action)) + let mut actions = vec![Value::Object(action)]; + let selection_method = |method: &str, arg: Value| { + let mut action = Object::new(); + action.insert("do", "selection".into()); + action.insert("method", method.into()); + action.insert("args", Value::Array(vec![arg])); + Value::Object(action) + }; + if let Some((format, style)) = self.document.selection_change_format(sel) { + actions.push(selection_method("setFormat", f64::from(format).into())); + if let Some(style) = style { + actions.push(selection_method("setStyle", style.into())); + } + } + Some(actions) } fn run(&mut self, step: Value) -> Outcome { @@ -1111,8 +1509,81 @@ pub fn example() -> Vec { "Type, delete, press Enter, or select text and make it bold.", 0.0, )]), + element( + "heading", + &[("tag", "h2".into())], + vec![text("Blocks and links", 0.0)], + ), + list( + "bullet", + vec![ + vec![text( + "Headings, quotes and lists come from the toolbar.", + 0.0, + )], + vec![ + text("Links too, like ", 0.0), + element( + "link", + &[("url", "https://lexical.dev".into())], + vec![text("lexical.dev", 0.0)], + ), + text(".", 0.0), + ], + ], + ), + list( + "check", + vec![ + vec![text("Undo with ⌘Z", 0.0)], + vec![text("Click a box to check it", 0.0)], + ], + ), + element( + "quote", + &[], + vec![text("Lexical is an extensible text editor framework.", 0.0)], + ), ] } +/// A serialized element of `kind` with `fields` and `children`. +fn element(kind: &str, fields: &[(&str, Value)], children: Vec) -> Value { + let mut o = Object::new(); + o.insert("type", kind.into()); + for (name, value) in fields { + o.insert(*name, value.clone()); + } + o.insert("children", Value::Array(children)); + Value::Object(o) +} + +/// A serialized list of `list_type` whose items hold `items`. +fn list(list_type: &str, items: Vec>) -> Value { + let items = items + .into_iter() + .enumerate() + .map(|(i, children)| { + let mut fields = vec![("value", ((i + 1) as f64).into())]; + if list_type == "check" { + fields.push(("checked", (i == 0).into())); + } + element("listitem", &fields, children) + }) + .collect(); + element( + "list", + &[ + ("listType", list_type.into()), + ("start", 1.0.into()), + ( + "tag", + if list_type == "number" { "ol" } else { "ul" }.into(), + ), + ], + items, + ) +} + #[cfg(test)] mod tests; diff --git a/rust/apps/lexical-playground-core/src/surface.rs b/rust/apps/lexical-playground-core/src/surface.rs index 016b5e70ced..cad879c780b 100644 --- a/rust/apps/lexical-playground-core/src/surface.rs +++ b/rust/apps/lexical-playground-core/src/surface.rs @@ -26,6 +26,11 @@ pub struct DisplayRun { pub range: Range, pub format: u32, pub composing: bool, + /// Whether the run is a link's text. + pub link: bool, + /// The block the run is in (the one a newline ends), an index into + /// [`Document::blocks`](crate::Document::blocks). + pub block: usize, } pub struct Surface { @@ -97,6 +102,8 @@ impl Surface { range: offset + run.start..offset + run.end(), format: run.format, composing: false, + link: run.link.is_some(), + block: i, }); } offset += block.text.len(); @@ -105,6 +112,8 @@ impl Surface { range: offset..offset + 1, format: 0, composing: false, + link: false, + block: i, }); offset += 1; } @@ -133,6 +142,8 @@ impl Surface { range: c.replaced.start..c.replaced.start + c.text.len(), format: run.format, composing: true, + link: false, + block: run.block, }); } if after.start < after.end { @@ -143,10 +154,13 @@ impl Surface { } } if !out.iter().any(|r| r.composing) { + let last = self.playground.document().blocks.len().saturating_sub(1); out.push(DisplayRun { range: c.replaced.start..c.replaced.start + c.text.len(), format: 0, composing: true, + link: false, + block: last, }); } out.retain(|r| r.range.start < r.range.end); @@ -179,12 +193,19 @@ impl Surface { } fn push_selection(&mut self) { + let sel = self.shown_selection(); + self.playground.set_selection(Some(sel)); + } + + /// The selection the surface shows, which the toolbar reads (with + /// [`Playground::format_at`] and the like): a click or an arrow key + /// moves it before Lexical hears of it at the next edit. + pub fn shown_selection(&self) -> Sel { let doc = self.playground.document(); - let sel = Sel { + Sel { anchor: doc.pos_at(self.anchor), focus: doc.pos_at(self.focus), - }; - self.playground.set_selection(Some(sel)); + } } /// Moves the caret to `offset` (in the display text, which is the diff --git a/rust/apps/lexical-playground-core/src/tests.rs b/rust/apps/lexical-playground-core/src/tests.rs index 73c9b16b175..b5422045378 100644 --- a/rust/apps/lexical-playground-core/src/tests.rs +++ b/rust/apps/lexical-playground-core/src/tests.rs @@ -8,7 +8,7 @@ fn playground() -> Playground { fn reads_the_example_as_blocks_of_runs() { let p = playground(); let doc = p.document(); - assert_eq!(doc.blocks.len(), 2); + assert_eq!(doc.blocks.len(), 8); let first = &doc.blocks[0]; assert_eq!(first.kind, "paragraph"); assert_eq!( @@ -52,7 +52,7 @@ fn typing_enter_and_formatting_edit_the_document() { }; p.set_selection(Some(Sel::caret(end))); assert_eq!(p.insert_paragraph().error, None); - assert_eq!(p.document().blocks.len(), 3); + assert_eq!(p.document().blocks.len(), 9); assert_eq!(p.format_text("bold").error, None); assert!(p.has_format("bold")); assert_eq!(p.insert_text("Bold").error, None); @@ -130,7 +130,8 @@ fn a_change_the_field_made_replays_as_a_lexical_edit() { // A cut across the two blocks. let cut = format!("家{}", &new[new.find("Type").unwrap()..]); assert_eq!(p.apply_text(&cut).map(|o| o.error), Some(None)); - assert_eq!(p.document().blocks.len(), 1); + // The two paragraphs are one; the heading, lists and quote follow. + assert_eq!(p.document().blocks.len(), 7); assert_eq!(p.document().text(), cut); assert_eq!(p.apply_text(&cut), None); // Offsets round-trip through the joined text. @@ -299,3 +300,223 @@ fn format_toggles_on_and_off_with_one_press_each() { assert_eq!(p.step_count() - steps, if press == 0 { 2 } else { 1 }); } } + +#[test] +fn a_click_gives_the_selection_the_format_there_as_selectionchange_does() { + // "Hello from **Rust**": a caret in "Rust" takes its bold, so the + // toolbar shows bold before any edit and typing there stays bold, as in + // a browser; a range shows only the format all of its text has. + let mut p = playground(); + let in_rust = Sel::caret(Pos { block: 0, byte: 13 }); + assert_eq!(p.format_at(in_rust), format_flag("bold")); + let across = Sel { + anchor: Pos { block: 0, byte: 6 }, + focus: Pos { block: 0, byte: 15 }, + }; + assert_eq!(p.format_at(across), 0); + // A range that ends at the start of "Rust" covers none of it. + let before = Sel { + anchor: Pos { block: 0, byte: 6 }, + focus: Pos { block: 0, byte: 11 }, + }; + assert_eq!(p.format_at(before), 0); + let rust = Sel { + anchor: Pos { block: 0, byte: 11 }, + focus: Pos { block: 0, byte: 15 }, + }; + assert_eq!(p.format_at(rust), format_flag("bold")); + p.set_selection(Some(in_rust)); + assert!(p.has_format("bold")); + let steps = p.step_count(); + assert_eq!(p.insert_text("y").error, None); + let runs: Vec<(&str, bool)> = p.document().blocks[0] + .runs + .iter() + .map(|r| (r.text.as_str(), r.has_format("bold"))) + .collect(); + assert_eq!(runs[1], ("Ruyst", true)); + // The select step, then the typing. + assert_eq!(p.step_count() - steps, 2); +} + +fn caret_in(p: &mut Playground, block: usize, byte: usize) { + p.set_selection(Some(Sel::caret(Pos { block, byte }))); +} + +#[test] +fn reads_headings_lists_links_and_quotes() { + let p = playground(); + let doc = p.document(); + let types: Vec<&str> = doc.blocks.iter().map(Block::block_type).collect(); + assert_eq!( + types, + [ + "paragraph", + "paragraph", + "h2", + "bullet", + "bullet", + "check", + "check", + "quote" + ] + ); + let link = doc.blocks[4] + .runs + .iter() + .find(|r| r.link.is_some()) + .unwrap(); + assert_eq!( + (link.text.as_str(), link.link.as_deref()), + ("lexical.dev", Some("https://lexical.dev")) + ); + // The link's runs are its parent's second child. + assert_eq!(link.child, 1); + assert!(doc.blocks[5].list.as_ref().unwrap().checked); + assert!(!doc.blocks[6].list.as_ref().unwrap().checked); + // An element point in the root is the start of the block it names. + let root = Point { + key: "root".into(), + offset: 3, + text: false, + }; + assert_eq!(doc.pos_of(&root), Some(Pos { block: 3, byte: 0 })); + // After the link: an element point after the block's second child. + let after_link = doc.blocks[4].runs.iter().find(|r| r.text == ".").unwrap(); + let point = doc.point_at(Pos { + block: 4, + byte: after_link.start + 1, + }); + assert!(point.text); +} + +#[test] +fn the_block_menu_converts_blocks_and_toggles_lists() { + let mut p = playground(); + caret_in(&mut p, 1, 3); + assert_eq!(p.set_block_type("h1").error, None); + assert_eq!(p.document().blocks[1].block_type(), "h1"); + assert_eq!(p.block_type(), "h1"); + assert_eq!(p.set_block_type("quote").error, None); + assert_eq!(p.block_type(), "quote"); + assert_eq!(p.set_block_type("number").error, None); + assert_eq!(p.block_type(), "number"); + // The same list again makes it a paragraph. + assert_eq!(p.set_block_type("number").error, None); + assert_eq!(p.block_type(), "paragraph"); + assert_eq!( + p.document().blocks[1].text, + "Type, delete, press Enter, or select text and make it bold." + ); +} + +#[test] +fn enter_in_a_list_item_adds_an_item_and_an_empty_one_ends_the_list() { + let mut p = playground(); + let end = p.document().blocks[3].text.len(); + caret_in(&mut p, 3, end); + assert_eq!(p.insert_paragraph().error, None); + let doc = p.document(); + assert_eq!(doc.blocks[4].block_type(), "bullet"); + assert_eq!(doc.blocks[4].text, ""); + assert_eq!(p.selection(), Some(Sel::caret(Pos { block: 4, byte: 0 }))); + // Enter in the empty item: a paragraph after the list. + assert_eq!(p.insert_paragraph().error, None); + assert_eq!(p.block_type(), "paragraph"); + assert_eq!(p.document().blocks.len(), 9); +} + +#[test] +fn indenting_a_list_item_nests_it() { + let mut p = playground(); + caret_in(&mut p, 4, 2); + assert_eq!(p.indent(false).error, None); + let item = &p.document().blocks[4]; + assert_eq!(item.list.as_ref().map(|l| l.depth), Some(1)); + assert_eq!(p.indent(true).error, None); + assert_eq!( + p.document().blocks[4].list.as_ref().map(|l| l.depth), + Some(0) + ); +} + +#[test] +fn links_toggle_on_a_selection_and_off_at_a_caret() { + let mut p = playground(); + p.set_selection(Some(Sel { + anchor: Pos { block: 0, byte: 0 }, + focus: Pos { block: 0, byte: 5 }, + })); + assert_eq!(p.toggle_link(Some("https://example.com")).error, None); + let run = &p.document().blocks[0].runs[0]; + assert_eq!( + (run.text.as_str(), run.link.as_deref()), + ("Hello", Some("https://example.com")) + ); + caret_in(&mut p, 0, 2); + assert_eq!(p.link(), Some("https://example.com")); + assert_eq!(p.toggle_link(None).error, None); + assert!(p.document().blocks[0].runs.iter().all(|r| r.link.is_none())); + assert_eq!( + p.document().blocks[0].text, + "Hello from Rust. This document lives in a lexical-editor editor." + ); +} + +#[test] +fn clicking_a_check_box_toggles_the_item() { + let mut p = playground(); + assert_eq!(p.toggle_checked(6).error, None); + assert!(p.document().blocks[6].list.as_ref().unwrap().checked); + assert_eq!(p.undo().error, None); + assert!(!p.document().blocks[6].list.as_ref().unwrap().checked); +} + +#[test] +fn the_tree_shows_lists_above_their_items() { + let tree = playground().document().tree(); + assert!( + tree.contains(" (11) list bullet\n (12) listitem bullet\n"), + "{tree}" + ); + assert!(tree.contains(" (19) list check\n (20) listitem check ☑\n")); +} + +/// Types `text` a character at a time, as keys do. +fn type_keys(p: &mut Playground, text: &str) { + for c in text.chars() { + assert_eq!(p.insert_text(&c.to_string()).error, None, "typing {c:?}"); + } +} + +#[test] +fn markdown_shortcuts_turn_typed_markdown_into_rich_text() { + let mut p = Playground::new(vec![]).expect("an empty document"); + caret_in(&mut p, 0, 0); + type_keys(&mut p, "# Title"); + assert_eq!(p.document().blocks[0].block_type(), "h1"); + assert_eq!(p.document().blocks[0].text, "Title"); + p.insert_paragraph(); + type_keys(&mut p, "- one **bold** and `code`"); + let item = &p.document().blocks[1]; + assert_eq!(item.block_type(), "bullet"); + assert_eq!(item.text, "one bold and code"); + let run = |text: &str| item.runs.iter().find(|r| r.text == text).unwrap(); + assert!(run("bold").has_format("bold")); + assert!(run("code").has_format("code")); + // An empty item ends the list; then a code fence on Enter. + p.insert_paragraph(); + p.insert_paragraph(); + type_keys(&mut p, "```rust"); + p.insert_paragraph(); + type_keys(&mut p, "let x = 1;"); + let code = &p.document().blocks[2]; + assert_eq!(code.kind, "code"); + assert_eq!(code.text, "let x = 1;"); + // Undo takes the code back to the fence's text. + let steps = p.step_count(); + p.undo(); + p.undo(); + assert_eq!(p.document().blocks[2].text, "```rust"); + assert_eq!(p.step_count(), steps + 2); +} diff --git a/rust/apps/lexical-playground-gpui/README.md b/rust/apps/lexical-playground-gpui/README.md index 4dc58c020b6..fc84a1ac9fd 100644 --- a/rust/apps/lexical-playground-gpui/README.md +++ b/rust/apps/lexical-playground-gpui/README.md @@ -28,6 +28,31 @@ zbugs-gpui is. macOS. - **The toolbar.** Bold, italic, underline, strikethrough, code, highlight, subscript and superscript follow the selection's format. Select all, and Reset. +- **The block row.** Normal, H1-H3 and Quote run `$setBlocksType`; the + bullet, numbered and check list buttons dispatch the list commands (the + list the caret is already in turns back into paragraphs, as in the web + playground's block menu). Link toggles a link to `https://` on the + selection, or removes the one at the caret (`TOGGLE_LINK_COMMAND`). + Outdent and Indent dispatch `OUTDENT_CONTENT_COMMAND` and + `INDENT_CONTENT_COMMAND`, as do Shift-Tab and Tab at a block's start + (`$indentOverTab`; elsewhere Tab inserts a tab). In a check item, a + button, or a click on its box, checks or unchecks it (`toggleChecked`). The toolbar follows the + selection the editor shows, so a click shows the format and block there + before the next edit, with the format a browser's `selectionchange` gives + it. +- **Blocks.** Headings draw larger and bolder, list items with their + marker (•, 1., ☐ or ☑) indented by depth, a checked item struck through, + a quote with a bar, a code block monospaced on a tinted background, and + links in blue, underlined. +- **Markdown shortcuts.** Typing markdown turns it into rich text, as + the web playground's `MarkdownShortcutPlugin` does + (`registerMarkdownShortcuts` with `TRANSFORMERS`): `# ` to `###### ` + make headings, `> ` a quote, `- `, `* `, `+ ` and `1. ` list items, + `` ``` `` and a space or Return a code block, and `**bold**`, `*italic*`, + `~~strikethrough~~`, `` `code` ``, `==highlight==` and + `[text](url)` their formats and links once the closing tag is typed. + Undo takes a shortcut back to the text typed. Return dispatches + `KEY_ENTER_COMMAND`. - **Light and dark.** The window follows the Mac's appearance and switches live when it changes, as the Rune playground does. - **The side panel.** @@ -44,7 +69,7 @@ zbugs-gpui is. macOS. LEXICAL_REPLAY=/path/to/lexical-playground-session.json pnpm exec vitest run src/replay.test.ts ``` -Headings, lists, links, code blocks, HTML and Markdown arrive as the +Code blocks, hashtags, HTML and Markdown arrive as the port gets the packages ([DESIGN.md](../../DESIGN.md), milestones). ## Running it @@ -73,7 +98,10 @@ to edit it. Built with the `e2e` feature, the app drives its own window once it's up: it types, formats, composes with an input method, types an emoji and -deletes it, adds a paragraph and a line break, and undoes and redoes, through the input +deletes it, adds a paragraph and a line break, undoes and redoes, and +turns the paragraph into a heading and a list, nests and unnests the item +with Tab and Shift-Tab and ends the list, then types markdown shortcuts +(a heading, bold text, a list item and a code block), through the input handler and the key bindings, checking the document after each step. It writes the session to `$LEXICAL_PLAYGROUND_E2E` and exits 0, or 1 with the step that failed. CI (`.github/workflows/rust-playground.yml`) then replays diff --git a/rust/apps/lexical-playground-gpui/src/app.rs b/rust/apps/lexical-playground-gpui/src/app.rs index 7ac21076036..979f2c60ee2 100644 --- a/rust/apps/lexical-playground-gpui/src/app.rs +++ b/rust/apps/lexical-playground-gpui/src/app.rs @@ -5,7 +5,7 @@ use crate::editor::{EditorEvent, LexicalEditor}; use gpui_kit::component::button::*; use gpui_kit::component::*; use gpui_kit::*; -use lexical_playground_core::{Playground, example, replay_vectors}; +use lexical_playground_core::{Playground, example, format_flag, replay_vectors}; #[derive(Clone, Copy, PartialEq, Eq)] enum Panel { @@ -25,6 +25,23 @@ const FORMATS: [(&str, &str); 8] = [ ("superscript", "x²"), ]; +/// The block menu, as the JS playground's `BlockFormatDropDown`: each +/// button is a `blockType` and its label (the same as the Rune app's). +const BLOCKS: [(&str, &str); 8] = [ + ("paragraph", "Normal"), + ("h1", "H1"), + ("h2", "H2"), + ("h3", "H3"), + ("quote", "Quote"), + ("bullet", "• List"), + ("number", "1. List"), + ("check", "☑ List"), +]; + +/// The URL a new link gets, as the JS playground's link button inserts +/// `https://` for the user to edit. +const NEW_LINK_URL: &str = "https://"; + pub struct PlaygroundApp { editor: Entity, panel: Panel, @@ -82,7 +99,10 @@ impl PlaygroundApp { } fn toolbar(&self, cx: &mut Context) -> impl IntoElement { - let playground = &self.editor.read(cx).surface.playground; + let surface = &self.editor.read(cx).surface; + // The surface's selection, which a click or an arrow key moves + // before Lexical hears of it at the next edit. + let format = surface.playground.format_at(surface.shown_selection()); let mut bar = h_flex() .w_full() .gap_1() @@ -97,7 +117,7 @@ impl PlaygroundApp { .child("Lexical"), ); for (name, label) in FORMATS { - let on = playground.has_format(name); + let on = format & format_flag(name) != 0; let button = Button::new(SharedString::from(format!("format-{name}"))) .small() .label(label) @@ -141,6 +161,106 @@ impl PlaygroundApp { ) } + /// The second row: the block menu, the link button, outdent and indent, + /// and a check item's box. + fn block_bar(&self, cx: &mut Context) -> impl IntoElement { + let surface = &self.editor.read(cx).surface; + let sel = surface.shown_selection(); + let playground = &surface.playground; + let current = playground.block_type_at(sel).to_owned(); + let link = playground.link_at(sel).map(str::to_owned); + let check = playground + .document() + .blocks + .get(sel.anchor.block) + .and_then(|b| b.list.as_ref()) + .filter(|l| l.list_type == "check") + .map(|l| l.checked); + let block = sel.anchor.block; + let toggle = |id: String, label: &str, on: bool| { + let button = Button::new(SharedString::from(id)) + .small() + .label(label.to_owned()) + .selected(on); + if on { button.primary() } else { button.ghost() } + }; + let mut bar = h_flex() + .w_full() + .gap_1() + .px_3() + .py_2() + .border_b_1() + .border_color(cx.theme().border); + for (block_type, label) in BLOCKS { + bar = bar.child( + toggle(format!("block-{block_type}"), label, current == block_type) + .tooltip(format!("Format as {label}")) + .on_click(cx.listener(move |this, _, window, cx| { + this.toolbar_edit(window, cx, |p| p.set_block_type(block_type)) + })), + ); + } + bar = bar + .child( + toggle("link".into(), "Link", link.is_some()) + .tooltip(match &link { + Some(url) => format!("Remove the link to {url}"), + None => "Insert a link".to_owned(), + }) + .on_click(cx.listener(|this, _, window, cx| { + let surface = &this.editor.read(cx).surface; + let remove = surface + .playground + .link_at(surface.shown_selection()) + .is_some(); + this.toolbar_edit(window, cx, |p| { + p.toggle_link((!remove).then_some(NEW_LINK_URL)) + }) + })), + ) + .child( + Button::new("outdent") + .small() + .ghost() + .label("Outdent") + .on_click(cx.listener(|this, _, window, cx| { + this.toolbar_edit(window, cx, |p| p.indent(true)) + })), + ) + .child( + Button::new("indent") + .small() + .ghost() + .label("Indent") + .on_click(cx.listener(|this, _, window, cx| { + this.toolbar_edit(window, cx, |p| p.indent(false)) + })), + ); + if let Some(checked) = check { + bar = bar.child( + toggle( + "check-item".into(), + if checked { "☑ Done" } else { "☐ Done" }, + checked, + ) + .tooltip("Check or uncheck this item") + .on_click(cx.listener(move |this, _, window, cx| { + this.toolbar_edit(window, cx, |p| p.toggle_checked(block)) + })), + ); + } + if let Some(url) = link { + bar = bar.child( + div() + .ml_2() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(url), + ); + } + bar + } + fn panel(&self, cx: &mut Context) -> impl IntoElement { let tab = |id: &'static str, label: &'static str, panel: Panel, cx: &mut Context| { Button::new(id) @@ -303,6 +423,7 @@ impl Render for PlaygroundApp { .bg(cx.theme().background) .text_color(cx.theme().foreground) .child(self.toolbar(cx)) + .child(self.block_bar(cx)) .child( h_flex() .flex_1() diff --git a/rust/apps/lexical-playground-gpui/src/e2e.rs b/rust/apps/lexical-playground-gpui/src/e2e.rs index 63f902e2b0a..e5ba1d58abb 100644 --- a/rust/apps/lexical-playground-gpui/src/e2e.rs +++ b/rust/apps/lexical-playground-gpui/src/e2e.rs @@ -2,7 +2,8 @@ //! `e2e` feature and run with `LEXICAL_PLAYGROUND_E2E=`, the app //! drives its own window once it's up, the way macOS does — text through //! the window's input handler (typing and an input method's composition) -//! and keys through their bindings, undo and redo included — checks the +//! and keys through their bindings, undo and redo and the markdown +//! shortcuts included — checks the //! document after each step, //! writes the session to `` as a vector, and exits 0, or 1 with the //! step that failed. CI then replays the file in JS Lexical @@ -217,6 +218,66 @@ async fn drive(window: AnyWindowHandle, app: &Entity, cx: &mut As } } ); + // The block menu and Tab, on the paragraph typed above (lines 1 and 2, + // split by the line break). + let block_menu = |block_type: &'static str| { + let editor = editor.clone(); + move |_: &mut Window, cx: &mut App| { + editor.update(cx, |e, cx| e.edit(cx, |p| p.set_block_type(block_type))) + } + }; + let block_is = |want: &'static str, depth: usize| { + let editor = editor.clone(); + move |cx: &mut AsyncApp| -> Step { + let (got, got_depth) = editor.read_with(cx, |e, _| { + let p = &e.surface.playground; + let depth = p.document().blocks[1].list.as_ref().map_or(0, |l| l.depth); + (p.document().blocks[1].block_type().to_owned(), depth) + }); + if (got.as_str(), got_depth) == (want, depth) { + Ok(()) + } else { + Err(format!( + "the block is {got} at depth {got_depth}, not {want} at {depth}" + )) + } + } + }; + step!( + "H1 from the block menu", + block_menu("h1"), + block_is("h1", 0) + ); + step!( + "• List from the block menu", + block_menu("bullet"), + block_is("bullet", 0) + ); + step!( + "↑ to the item's first line", + key(Box::new(Up)), + expect(2, "next") + ); + step!( + "To the line's start", + key(Box::new(LineStart)), + expect(1, "Typed in GPUI, then plain 日本") + ); + step!( + "Tab nests the item", + key(Box::new(InsertTab)), + block_is("bullet", 1) + ); + step!( + "⇧Tab brings it back", + key(Box::new(Outdent)), + block_is("bullet", 0) + ); + step!( + "• List again ends the list", + block_menu("bullet"), + block_is("paragraph", 0) + ); step!( "The third paragraph is untouched", |_: &mut Window, _: &mut App| {}, @@ -225,5 +286,87 @@ async fn drive(window: AnyWindowHandle, app: &Entity, cx: &mut As "Type, delete, press Enter, or select text and make it bold." ) ); + + // Markdown shortcuts (`registerMarkdownShortcuts`), typed a key at a + // time: `## ` before the paragraph's text makes it a heading, `**md**` + // makes bold text, `- ` a list item, and `` ``` `` then Return a code + // block. + let block_at = |i: usize, want: &'static str| { + let editor = editor.clone(); + move |cx: &mut AsyncApp| -> Step { + let got = editor.read_with(cx, |e, _| { + let blocks = &e.surface.playground.document().blocks; + blocks.get(i).map(|b| match b.kind.as_str() { + "code" => "code".to_owned(), + _ => b.block_type().to_owned(), + }) + }); + if got.as_deref() == Some(want) { + Ok(()) + } else { + Err(format!("block {i} is {got:?}, not {want}")) + } + } + }; + let nothing = |_: &mut AsyncApp| -> Step { Ok(()) }; + for k in ["#", "#"] { + step!("Typing ## ", typed(k), nothing); + } + step!("Typing ## ", typed(" "), block_at(1, "h2")); + step!( + "↓ to the heading's second line", + key(Box::new(Down)), + nothing + ); + step!("To its end", key(Box::new(LineEnd)), expect(2, "next")); + for k in [" ", "*", "*", "m", "d", "*"] { + step!("Typing **md**", typed(k), nothing); + } + step!("Typing **md**", typed("*"), expect(2, "next md")); + step!( + "md is bold", + |_: &mut Window, _: &mut App| {}, + |cx: &mut AsyncApp| -> Step { + let bold = editor.read_with(cx, |e, _| { + e.surface.playground.document().blocks[1] + .runs + .iter() + .any(|r| r.text == "md" && r.has_format("bold")) + }); + if bold { + Ok(()) + } else { + Err("md is not bold".into()) + } + } + ); + step!( + "Return", + key(Box::new(InsertParagraph)), + block_at(2, "paragraph") + ); + for k in ["-", " "] { + step!("Typing - ", typed(k), nothing); + } + step!("Typing an item", typed("item"), block_at(2, "bullet")); + step!( + "Return", + key(Box::new(InsertParagraph)), + block_at(3, "bullet") + ); + step!( + "Return ends the list", + key(Box::new(InsertParagraph)), + block_at(3, "paragraph") + ); + for k in ["`", "`", "`"] { + step!("Typing ```", typed(k), nothing); + } + step!( + "Return makes a code block", + key(Box::new(InsertParagraph)), + block_at(3, "code") + ); + step!("Typing code", typed("let x = 1;"), block_at(3, "code")); Ok(()) } diff --git a/rust/apps/lexical-playground-gpui/src/editor.rs b/rust/apps/lexical-playground-gpui/src/editor.rs index d479ee916b3..09125a6ed9d 100644 --- a/rust/apps/lexical-playground-gpui/src/editor.rs +++ b/rust/apps/lexical-playground-gpui/src/editor.rs @@ -9,7 +9,7 @@ use gpui_kit::component::{ActiveTheme, Theme}; use gpui_kit::*; -use lexical_playground_core::{Outcome, Playground, Surface, Unit, format_flag}; +use lexical_playground_core::{Document, Outcome, Playground, RunKind, Surface, Unit, format_flag}; use std::ops::Range; use std::rc::Rc; use std::time::Duration; @@ -27,6 +27,7 @@ actions!( InsertParagraph, InsertLineBreak, InsertTab, + Outdent, Left, Right, Up, @@ -72,6 +73,7 @@ pub fn bind_keys(cx: &mut App) { KeyBinding::new("enter", InsertParagraph, c), KeyBinding::new("shift-enter", InsertLineBreak, c), KeyBinding::new("tab", InsertTab, c), + KeyBinding::new("shift-tab", Outdent, c), KeyBinding::new("left", Left, c), KeyBinding::new("right", Right, c), KeyBinding::new("up", Up, c), @@ -99,22 +101,131 @@ pub fn bind_keys(cx: &mut App) { ]); } +/// How a line of the display text is drawn: its block's text size and +/// weight (a heading's), its left edge (a list item's or a quote's +/// indent), and what goes in the margin (a list marker, a quote's bar). +#[derive(Clone, Debug)] +struct LineStyle { + /// The block the line is in. + block: usize, + size: Pixels, + /// The height of each of the line's rows. + height: Pixels, + left: Pixels, + /// A list item's marker, on its first line. + marker: Option, + /// Whether the marker is a check box, which a click toggles. + check: bool, + quote: bool, + /// A code block's line: monospaced, on a tinted background. + code: bool, +} + +/// One indent step: a list's nesting level, or a block's `indent`. +const INDENT: f32 = 24.; + +/// How far a code block's text is inset in its background. +const CODE_PADDING: f32 = 12.; + +/// A heading's text size and weight, as the JS playground's theme (and the +/// Rune app) draws `h1` ... `h6` in a 16-point body. +fn heading_style(tag: &str) -> Option<(f32, FontWeight)> { + Some(match tag { + "h1" => (26., FontWeight::BOLD), + "h2" => (21., FontWeight::BOLD), + "h3" => (18., FontWeight::SEMIBOLD), + "h4" => (16., FontWeight::SEMIBOLD), + "h5" => (14., FontWeight::SEMIBOLD), + "h6" => (13., FontWeight::SEMIBOLD), + _ => return None, + }) +} + +/// The style of each line of the display text: a block's lines are its +/// first and one more per line break. +fn line_styles(doc: &Document, size: Pixels, height: Pixels) -> Vec { + let mut out = Vec::new(); + for (i, block) in doc.blocks.iter().enumerate() { + let (size, height) = match block.tag.as_deref().and_then(heading_style) { + Some((s, _)) => (px(s), px((s * 1.5).round())), + None => (size, height), + }; + let mut style = LineStyle { + block: i, + size, + height, + left: px(INDENT * block.indent as f32), + marker: None, + check: false, + quote: block.kind == "quote", + code: block.kind == "code", + }; + if let Some(list) = &block.list { + style.left = px(INDENT * (list.depth + 1) as f32); + style.check = list.list_type == "check"; + style.marker = Some(match list.list_type.as_str() { + "number" => format!("{}.", list.value), + "check" => if list.checked { "☑" } else { "☐" }.to_owned(), + _ => ["•", "◦", "▪"][list.depth % 3].to_owned(), + }); + } else if style.quote { + style.left += px(16.); + } else if style.code { + style.left += px(CODE_PADDING); + } + let breaks = block + .runs + .iter() + .filter(|r| r.kind == RunKind::LineBreak) + .count(); + out.push(style.clone()); + // The marker goes on the item's first line only. + style.marker = None; + out.extend(std::iter::repeat_n(style, breaks)); + } + out +} + +/// A line of the display text as the last paint laid it out. +struct Line { + /// Its first byte in the display text. + start: usize, + top: Pixels, + style: LineStyle, + shape: WrappedLine, +} + +impl Line { + fn rows(&self) -> usize { + self.shape.wrap_boundaries.len() + 1 + } + + fn bottom(&self) -> Pixels { + self.top + self.style.height * self.rows() as f32 + } +} + /// Where the last paint put each line of the display text. struct Layout { bounds: Bounds, - line_height: Pixels, text: String, - /// Each `\n`-separated line: its first byte, its top, and its shape. - lines: Rc>, + lines: Rc>, } impl Layout { /// The line holding byte `index`. - fn line_of(&self, index: usize) -> Option<&(usize, Pixels, WrappedLine)> { + fn line_of(&self, index: usize) -> Option<&Line> { + self.lines.iter().rev().find(|l| l.start <= index) + } + + /// The line at `y` (window coordinates), or the first. + fn line_at(&self, y: Pixels) -> Option<&Line> { + let local = y - self.bounds.origin.y; self.lines .iter() .rev() - .find(|(start, _, _)| *start <= index) + .find(|l| l.top <= local) + .or(self.lines.first()) } /// The byte under `position` (window coordinates). @@ -123,49 +234,63 @@ impl Layout { if local.y < px(0.) { return 0; } - let line = self - .lines - .iter() - .rev() - .find(|(_, top, _)| *top <= local.y) - .or(self.lines.first()); - let Some((start, top, line)) = line else { + let Some(line) = self.line_at(position.y) else { return 0; }; - let at = point(local.x.max(px(0.)), local.y - *top); - match line.closest_index_for_position(at, self.line_height) { - Ok(i) | Err(i) => start + i, + let at = point((local.x - line.style.left).max(px(0.)), local.y - line.top); + match line.shape.closest_index_for_position(at, line.style.height) { + Ok(i) | Err(i) => line.start + i, } } - /// The top left of the caret at byte `index` (window coordinates). - fn position_of(&self, index: usize) -> Option> { - let (start, top, line) = self.line_of(index)?; - let at = line.position_for_index(index - start, self.line_height)?; - Some(self.bounds.origin + point(at.x, *top + at.y)) + /// The top left of the caret at byte `index` (window coordinates), and + /// its height. + fn position_of(&self, index: usize) -> Option<(Point, Pixels)> { + let line = self.line_of(index)?; + let at = line + .shape + .position_for_index(index - line.start, line.style.height)?; + Some(( + self.bounds.origin + point(line.style.left + at.x, line.top + at.y), + line.style.height, + )) } - /// Each row of each line as `(byte range, top left, width)`. - fn rows(&self) -> Vec<(Range, Point, Pixels)> { + /// The check item whose box is under `position`, as its block. + fn check_at(&self, position: Point) -> Option { + let local = position - self.bounds.origin; + let line = self.line_at(position.y)?; + let hit = line.style.check + && line.style.marker.is_some() + && local.y < line.top + line.style.height + && local.x < line.style.left + && local.x >= line.style.left - px(INDENT); + hit.then_some(line.style.block) + } + + /// Each row of each line as `(byte range, top left, width, height)`. + fn rows(&self) -> Vec<(Range, Point, Pixels, Pixels)> { let mut rows = Vec::new(); - for (start, top, line) in self.lines.iter() { - let layout = &line.unwrapped_layout; + for line in self.lines.iter() { + let layout = &line.shape.unwrapped_layout; let mut breaks: Vec = line + .shape .wrap_boundaries .iter() .map(|b| layout.runs[b.run_ix].glyphs[b.glyph_ix].index) .collect(); breaks.insert(0, 0); - breaks.push(line.len()); + breaks.push(line.shape.len()); for (row, pair) in breaks.windows(2).enumerate() { let (from, to) = (pair[0], pair[1]); let x0 = layout.x_for_index(from); let width = layout.x_for_index(to) - x0; - let y = *top + self.line_height * row as f32; + let y = line.top + line.style.height * row as f32; rows.push(( - start + from..start + to, - self.bounds.origin + point(px(0.), y), + line.start + from..line.start + to, + self.bounds.origin + point(line.style.left, y), width, + line.style.height, )); } } @@ -326,14 +451,15 @@ impl LexicalEditor { fn vertical(&mut self, up: bool, extend: bool, cx: &mut Context) { let Some(layout) = &self.layout else { return }; let caret = self.surface.caret(); - let Some(at) = layout.position_of(caret) else { + let Some((at, height)) = layout.position_of(caret) else { return; }; let x = *self.goal_x.get_or_insert(at.x); + // Into the middle of the row above or below, whatever its height. let y = if up { - at.y - layout.line_height * 0.5 + at.y - px(4.) } else { - at.y + layout.line_height * 1.5 + at.y + height + px(4.) }; let to = if y < layout.bounds.top() { 0 @@ -350,8 +476,8 @@ impl LexicalEditor { let to = self.layout.as_ref().and_then(|l| { l.rows() .into_iter() - .find(|(r, _, _)| r.start <= caret && caret <= r.end) - .map(|(r, _, _)| if end { r.end } else { r.start }) + .find(|(r, ..)| r.start <= caret && caret <= r.end) + .map(|(r, ..)| if end { r.end } else { r.start }) }); let text = self.surface.display_text(); let to = to.unwrap_or(if end { text.len() } else { 0 }); @@ -365,6 +491,16 @@ impl LexicalEditor { cx: &mut Context, ) { window.focus(&self.focus_handle, cx); + // A check item's box, as the JS playground's CheckListPlugin + // toggles it on a click. + let check = self + .layout + .as_ref() + .and_then(|l| l.check_at(event.position)); + if let Some(block) = check { + self.edit(cx, |p| p.toggle_checked(block)); + return; + } let Some(layout) = &self.layout else { return }; let at = layout.index_at(event.position); self.selecting = true; @@ -506,8 +642,8 @@ impl EntityInputHandler for LexicalEditor { let layout = self.layout.as_ref()?; let text = &layout.text; let start = lexical_playground_core::byte_of_utf16(text, range.start); - let at = layout.position_of(start)?; - Some(Bounds::new(at, size(px(2.), layout.line_height))) + let (at, height) = layout.position_of(start)?; + Some(Bounds::new(at, size(px(2.), height))) } fn character_index_for_point( @@ -563,11 +699,8 @@ impl Render for LexicalEditor { .on_action(cx.listener(|this, _: &InsertLineBreak, _, cx| { this.edit(cx, Playground::insert_line_break) })) - .on_action( - cx.listener(|this, _: &InsertTab, _, cx| { - this.edit(cx, |p| p.insert_raw_text("\t")) - }), - ) + .on_action(cx.listener(|this, _: &InsertTab, _, cx| this.edit(cx, |p| p.tab(false)))) + .on_action(cx.listener(|this, _: &Outdent, _, cx| this.edit(cx, |p| p.tab(true)))) .on_action(cx.listener(|this, _: &Left, _, cx| this.horizontal(true, false, cx))) .on_action(cx.listener(|this, _: &Right, _, cx| this.horizontal(false, false, cx))) .on_action(cx.listener(|this, _: &SelectLeft, _, cx| this.horizontal(true, true, cx))) @@ -614,10 +747,20 @@ impl Render for LexicalEditor { } } -/// The text runs to shape: each run's format as a font, colors and lines. +/// The text runs to shape: each run's format as a font, colors and lines, +/// with a heading's weight, a link's color and underline, and a checked +/// item struck through, as the JS playground's theme draws them. fn text_runs(surface: &Surface, base: &TextStyle, theme: &Theme) -> Vec { let base_font = base.font(); let accent = theme.primary; + let doc = surface.playground.document(); + // The JS playground's link blue (#216FDB), lighter on a dark window: + // the kit's neutral theme draws links in the text color. + let link = if theme.is_dark() { + hsla(214. / 360., 0.9, 0.68, 1.) + } else { + hsla(214. / 360., 0.74, 0.49, 1.) + }; // A marker yellow behind dark text, a deep amber behind light text. let highlight = if theme.is_dark() { hsla(40. / 360., 0.75, 0.3, 1.) @@ -629,26 +772,39 @@ fn text_runs(surface: &Surface, base: &TextStyle, theme: &Theme) -> Vec .into_iter() .map(|run| { let has = |name| run.format & format_flag(name) != 0; - let mut font = if has("code") { + let block = doc.blocks.get(run.block); + let heading = block.and_then(|b| b.tag.as_deref()).and_then(heading_style); + let checked = block + .and_then(|b| b.list.as_ref()) + .is_some_and(|l| l.list_type == "check" && l.checked); + let in_code_block = block.is_some_and(|b| b.kind == "code"); + let mut font = if has("code") || in_code_block { font("Menlo") } else { base_font.clone() }; + if let Some((_, weight)) = heading { + font.weight = weight; + } if has("bold") { font.weight = FontWeight::BOLD; } if has("italic") { font.style = FontStyle::Italic; } - let line = |on: bool| on.then_some(px(1.)); + let color = if run.link { + link + } else if has("subscript") || has("superscript") { + accent + } else if checked { + theme.muted_foreground + } else { + base.color + }; TextRun { len: run.range.len(), font, - color: if has("subscript") || has("superscript") { - accent - } else { - base.color - }, + color, background_color: if has("highlight") { Some(highlight) } else if has("code") { @@ -656,14 +812,16 @@ fn text_runs(surface: &Surface, base: &TextStyle, theme: &Theme) -> Vec } else { None }, - underline: (has("underline") || run.composing).then(|| UnderlineStyle { - thickness: px(1.), - color: Some(if run.composing { accent } else { base.color }), - wavy: false, + underline: (has("underline") || run.composing || run.link).then(|| { + UnderlineStyle { + thickness: px(1.), + color: Some(if run.composing { accent } else { color }), + wavy: false, + } }), - strikethrough: line(has("strikethrough")).map(|thickness| StrikethroughStyle { - thickness, - color: Some(base.color), + strikethrough: (has("strikethrough") || checked).then(|| StrikethroughStyle { + thickness: px(1.), + color: Some(color), }), } }) @@ -675,9 +833,15 @@ struct EditorElement { } struct Prepaint { - lines: Rc>, + lines: Rc>, selection: Vec, caret: Option, + /// List markers: where each goes, its row's height, and its shape. + markers: Vec<(Point, Pixels, ShapedLine)>, + /// Quotes' bars. + bars: Vec, + /// Code blocks' backgrounds, under the selection. + backgrounds: Vec, } impl IntoElement for EditorElement { @@ -688,33 +852,62 @@ impl IntoElement for EditorElement { } } -/// Shapes `text` at `width`: each line's first byte, top and shape. +/// The part of `runs` (which cover the whole text) over `range`. +fn slice_runs(runs: &[TextRun], range: Range) -> Vec { + let mut out = Vec::new(); + let mut at = 0; + for run in runs { + let (from, to) = (at.max(range.start), (at + run.len).min(range.end)); + at += run.len; + if from < to { + let mut run = run.clone(); + run.len = to - from; + out.push(run); + } + } + out +} + +/// Shapes each line of `text` in its style, wrapping at `width` less the +/// line's indent. fn shape( window: &mut Window, text: &str, runs: &[TextRun], - font_size: Pixels, - line_height: Pixels, + styles: &[LineStyle], width: Option, -) -> Vec<(usize, Pixels, WrappedLine)> { - let shaped = window - .text_system() - .shape_text( - SharedString::from(text.to_owned()), - font_size, - runs, - width, - None, - ) - .unwrap_or_default(); +) -> Vec { let mut out = Vec::new(); let (mut start, mut top) = (0, px(0.)); - for line in shaped { - let rows = line.wrap_boundaries.len() + 1; - let len = line.len(); - out.push((start, top, line)); - start += len + 1; - top += line_height * rows as f32; + for (i, line_text) in text.split('\n').enumerate() { + let style = styles + .get(i) + .or(styles.last()) + .cloned() + .expect("a document has a block"); + let line_runs = slice_runs(runs, start..start + line_text.len()); + let wrap = width.map(|w| (w - style.left).max(px(40.))); + let shape = window + .text_system() + .shape_text( + SharedString::from(line_text.to_owned()), + style.size, + &line_runs, + wrap, + None, + ) + .ok() + .and_then(|lines| lines.into_iter().next()) + .unwrap_or_default(); + let line = Line { + start, + top, + style, + shape, + }; + start += line_text.len() + 1; + top = line.bottom(); + out.push(line); } out } @@ -742,8 +935,11 @@ impl Element for EditorElement { let text = editor.surface.display_text(); let style = window.text_style(); let runs = text_runs(&editor.surface, &style, cx.theme()); - let font_size = style.font_size.to_pixels(window.rem_size()); - let line_height = window.line_height(); + let styles = line_styles( + editor.surface.playground.document(), + style.font_size.to_pixels(window.rem_size()), + window.line_height(), + ); let mut layout_style = Style::default(); layout_style.size.width = relative(1.).into(); let id = @@ -752,10 +948,8 @@ impl Element for EditorElement { AvailableSpace::Definite(w) => Some(w), _ => None, }); - let lines = shape(window, &text, &runs, font_size, line_height, width); - let height = lines.last().map_or(line_height, |(_, top, line)| { - *top + line_height * (line.wrap_boundaries.len() + 1) as f32 - }); + let lines = shape(window, &text, &runs, &styles, width); + let height = lines.last().map_or(px(0.), Line::bottom); size(width.unwrap_or(px(0.)), height) }); (id, ()) @@ -774,30 +968,31 @@ impl Element for EditorElement { let text = editor.surface.display_text(); let style = window.text_style(); let runs = text_runs(&editor.surface, &style, cx.theme()); - let font_size = style.font_size.to_pixels(window.rem_size()); - let line_height = window.line_height(); + let styles = line_styles( + editor.surface.playground.document(), + style.font_size.to_pixels(window.rem_size()), + window.line_height(), + ); let lines = Rc::new(shape( window, &text, &runs, - font_size, - line_height, + &styles, Some(bounds.size.width), )); let layout = Layout { bounds, - line_height, text: text.clone(), lines: lines.clone(), }; let (range, _) = editor.surface.selection(); let mut selection = Vec::new(); if !range.is_empty() { - for (row, origin, width) in layout.rows() { + for (row, origin, width, height) in layout.rows() { if range.end < row.start || range.start > row.end { continue; } - let layout_x = |i: usize| layout.position_of(i).map_or(origin.x, |p| p.x); + let layout_x = |i: usize| layout.position_of(i).map_or(origin.x, |(p, _)| p.x); let x0 = if range.start <= row.start { origin.x } else { @@ -812,10 +1007,7 @@ impl Element for EditorElement { }; if x1 > x0 { selection.push(fill( - Bounds::from_corners( - point(x0, origin.y), - point(x1, origin.y + line_height), - ), + Bounds::from_corners(point(x0, origin.y), point(x1, origin.y + height)), cx.theme().selection, )); } @@ -825,13 +1017,63 @@ impl Element for EditorElement { .is_empty() .then(|| layout.position_of(editor.surface.caret())) .flatten() - .map(|at| fill(Bounds::new(at, size(px(2.), line_height)), cx.theme().caret)); + .map(|(at, height)| fill(Bounds::new(at, size(px(2.), height)), cx.theme().caret)); + let mut markers = Vec::new(); + let mut bars = Vec::new(); + let mut backgrounds = Vec::new(); + let muted = cx.theme().muted_foreground; + for line in lines.iter() { + let top = bounds.origin.y + line.top; + if let Some(marker) = &line.style.marker { + let color = if line.style.check { style.color } else { muted }; + let run = TextRun { + len: marker.len(), + font: style.font(), + color, + background_color: None, + underline: None, + strikethrough: None, + }; + let shaped = window.text_system().shape_line( + SharedString::from(marker.clone()), + line.style.size, + &[run], + None, + ); + let x = bounds.origin.x + line.style.left - shaped.width() - px(8.); + markers.push((point(x, top), line.style.height, shaped)); + } + if line.style.code { + backgrounds.push(fill( + Bounds::new( + point(bounds.origin.x + line.style.left - px(CODE_PADDING), top), + size( + bounds.size.width - line.style.left + px(CODE_PADDING), + line.bottom() - line.top, + ), + ), + muted.opacity(0.12), + )); + } + if line.style.quote { + bars.push(fill( + Bounds::new( + point(bounds.origin.x + line.style.left - px(14.), top), + size(px(3.), line.bottom() - line.top), + ), + muted.opacity(0.5), + )); + } + } self.editor .update(cx, |editor, _| editor.layout = Some(layout)); Prepaint { lines, selection, caret, + markers, + bars, + backgrounds, } } @@ -854,30 +1096,43 @@ impl Element for EditorElement { ElementInputHandler::new(bounds, self.editor.clone()), cx, ); + for quad in prepaint.backgrounds.drain(..) { + window.paint_quad(quad); + } for quad in prepaint.selection.drain(..) { window.paint_quad(quad); } - let line_height = window.line_height(); - for (_, top, line) in prepaint.lines.iter() { - let origin = bounds.origin + point(px(0.), *top); - line.paint_background( - origin, - line_height, - TextAlign::Left, - Some(bounds), - window, - cx, - ) - .ok(); - line.paint( - origin, - line_height, - TextAlign::Left, - Some(bounds), - window, - cx, - ) - .ok(); + for quad in prepaint.bars.drain(..) { + window.paint_quad(quad); + } + for (origin, height, marker) in &prepaint.markers { + marker + .paint(*origin, *height, TextAlign::Left, None, window, cx) + .ok(); + } + for line in prepaint.lines.iter() { + let origin = bounds.origin + point(line.style.left, line.top); + let line_bounds = Bounds::from_corners(origin, bounds.bottom_right()); + line.shape + .paint_background( + origin, + line.style.height, + TextAlign::Left, + Some(line_bounds), + window, + cx, + ) + .ok(); + line.shape + .paint( + origin, + line.style.height, + TextAlign::Left, + Some(line_bounds), + window, + cx, + ) + .ok(); } if focus_handle.is_focused(window) && window.is_window_active() diff --git a/rust/apps/lexical-playground-rune/README.md b/rust/apps/lexical-playground-rune/README.md index 8724606c351..4fb01ec716e 100644 --- a/rust/apps/lexical-playground-rune/README.md +++ b/rust/apps/lexical-playground-rune/README.md @@ -23,6 +23,31 @@ macOS first; it also builds and renders on Linux. - **The toolbar.** Bold, italic, underline, strikethrough, code, highlight, subscript and superscript follow the selection's format, as in the web playground. Select all, and Reset. +- **The block row.** Normal, H1-H3 and Quote run `$setBlocksType`; the + bullet, numbered and check list buttons dispatch the list commands (the + list the caret is already in turns back into paragraphs, as in the web + playground's block menu). Link toggles a link to `https://` on the + selection, or removes the one at the caret (`TOGGLE_LINK_COMMAND`). + Outdent and Indent dispatch `OUTDENT_CONTENT_COMMAND` and + `INDENT_CONTENT_COMMAND`, as do Shift-Tab and Tab at a block's start + (`$indentOverTab`; elsewhere Tab inserts a tab). In a check item, a + button checks or unchecks it (`toggleChecked`). The toolbar follows the + selection the editor shows, so a click shows the format and block there + before the next edit, with the format a browser's `selectionchange` gives + it. +- **Blocks.** List items draw as the field's bullets and numbers at their + depth (a check item as a bullet, struck through when checked), a quote + with the field's bar, a code block as the field's, headings larger and + bolder, and links as the field's links. +- **Markdown shortcuts.** Typing markdown turns it into rich text, as + the web playground's `MarkdownShortcutPlugin` does + (`registerMarkdownShortcuts` with `TRANSFORMERS`): `# ` to `###### ` + make headings, `> ` a quote, `- `, `* `, `+ ` and `1. ` list items, + `` ``` `` and a space or Return a code block, and `**bold**`, `*italic*`, + `~~strikethrough~~`, `` `code` ``, `==highlight==` and + `[text](url)` their formats and links once the closing tag is typed. + Undo takes a shortcut back to the text typed. Return dispatches + `KEY_ENTER_COMMAND`. - **The side panel.** - **Tree:** the node tree with keys. - **JSON:** the editor state's JSON. @@ -37,7 +62,7 @@ macOS first; it also builds and renders on Linux. LEXICAL_REPLAY=/path/to/lexical-playground-session.json pnpm exec vitest run src/replay.test.ts ``` -Headings, lists, links, code blocks, HTML and Markdown arrive as the +Code blocks, hashtags, HTML and Markdown arrive as the port gets the packages ([DESIGN.md](../../DESIGN.md), milestones). ## Running it diff --git a/rust/apps/lexical-playground-rune/src/main.rs b/rust/apps/lexical-playground-rune/src/main.rs index 74477c59afd..1253befed3f 100644 --- a/rust/apps/lexical-playground-rune/src/main.rs +++ b/rust/apps/lexical-playground-rune/src/main.rs @@ -88,13 +88,19 @@ impl State { self.edit.focused = focused; } - /// Hands the field's selection to the editor, before an edit. - fn take_selection(&mut self) { + /// The field's selection, which the toolbar shows: a click or an + /// arrow key moves it before Lexical hears of it at the next edit. + pub fn shown_selection(&self) -> Sel { let doc = self.playground.document(); - let sel = Sel { + Sel { anchor: doc.pos_at(self.edit.anchor), focus: doc.pos_at(self.edit.focus), - }; + } + } + + /// Hands the field's selection to the editor, before an edit. + fn take_selection(&mut self) { + let sel = self.shown_selection(); self.playground.set_selection(Some(sel)); } @@ -134,7 +140,7 @@ impl State { self.edit_with(|p| p.delete_character(backward)); } } - Key::Tab if !m.shift => self.edit_with(|p| p.insert_raw_text("\t")), + Key::Tab => self.edit_with(|p| p.tab(m.shift)), Key::Command(c) => match c { 'b' => self.edit_with(|p| p.format_text("bold")), 'i' => self.edit_with(|p| p.format_text("italic")), @@ -171,14 +177,23 @@ impl State { } /// The field's model for `doc`: its text, a mark per format the field -/// draws, and its line breaks as soft breaks. +/// draws, its links, a Rune block per line, and its line breaks as soft +/// breaks. pub fn mirror(doc: &Document, selection: Option) -> TextEdit { let mut marks = Vec::new(); + let mut blocks = Vec::new(); let mut offset = 0; for block in &doc.blocks { + let checked = block + .list + .as_ref() + .is_some_and(|l| l.list_type == "check" && l.checked); for run in &block.runs { if run.kind == RunKind::Text { let range = offset + run.start..offset + run.end(); + if range.is_empty() { + continue; + } for (name, mark) in [ ("bold", Mark::Bold), ("italic", Mark::Italic), @@ -186,30 +201,79 @@ pub fn mirror(doc: &Document, selection: Option) -> TextEdit { ("strikethrough", Mark::Strike), ("code", Mark::Code), ] { - if run.has_format(name) && !range.is_empty() { + // A checked item is struck through, as the JS + // playground's `.PlaygroundEditorTheme__listItemChecked`. + if run.has_format(name) || (checked && mark == Mark::Strike) { marks.push(MarkRange::new(range.clone(), mark)); } } + if let Some(url) = &run.link { + marks.push(MarkRange::new(range, Mark::Link(url.clone()))); + } } } + let lines = 1 + block + .runs + .iter() + .filter(|r| r.kind == RunKind::LineBreak) + .count(); + blocks.extend(std::iter::repeat_n(rune_block(block), lines)); offset += block.text.len() + 1; } - let text = doc.text(); - let lines = text.matches('\n').count() + 1; - let mut edit = TextEdit::from_parts(text, marks, vec![Block::Paragraph; lines]) - .with_soft_breaks(doc.soft_breaks()); + let mut edit = + TextEdit::from_parts(doc.text(), marks, blocks).with_soft_breaks(doc.soft_breaks()); let sel = selection.unwrap_or(Sel::caret(Pos::default())); edit.set_selection(doc.offset_of(sel.anchor), doc.offset_of(sel.focus)); edit } -/// Highlight, subscript and superscript, which the field has no marks for, -/// drawn over it. `dark` is the window's appearance: highlight is a marker +/// How the field draws `block`: a list item as a bullet or a number at its +/// list's depth (a check item as a bullet; its box is the toolbar's Check +/// button), a quote with the field's quote bar. Headings are paragraphs +/// with larger text (see [`decorations`]). +fn rune_block(block: &lexical_playground_core::Block) -> Block { + if let Some(list) = &block.list { + let level = list.depth.min(usize::from(rune::MAX_LIST_LEVEL)) as u8; + return match list.list_type.as_str() { + "number" => Block::Numbered(level), + _ => Block::Bullet(level), + }; + } + match block.kind.as_str() { + "quote" => Block::Quote, + "code" => Block::Code, + _ => Block::Paragraph, + } +} + +/// A heading's text size and weight, as the JS playground's theme +/// (`h1` 24px ... `h6`, in the field's 16-point body). +fn heading_style(tag: &str) -> Option<(f32, f32)> { + Some(match tag { + "h1" => (26.0, 700.0), + "h2" => (21.0, 700.0), + "h3" => (18.0, 650.0), + "h4" => (16.0, 650.0), + "h5" => (14.0, 650.0), + "h6" => (13.0, 650.0), + _ => return None, + }) +} + +/// Headings, highlight, subscript and superscript, which the field has no +/// marks or blocks for, drawn over it. `dark` is the window's appearance: highlight is a marker /// yellow behind dark text and a deep amber behind light text. pub fn decorations(doc: &Document, dark: bool) -> Vec { let mut spans = Vec::new(); let mut offset = 0; for block in &doc.blocks { + if let Some((size, weight)) = block.tag.as_deref().and_then(heading_style) + && !block.text.is_empty() + { + let mut span = rune::Span::new(offset, offset + block.text.len()).weight(weight); + span.size = Some(size); + spans.push(span); + } for run in &block.runs { let (start, end) = (offset + run.start, offset + run.end()); if run.kind != RunKind::Text || start == end { diff --git a/rust/apps/lexical-playground-rune/src/tests.rs b/rust/apps/lexical-playground-rune/src/tests.rs index ff834017262..528d1a72b18 100644 --- a/rust/apps/lexical-playground-rune/src/tests.rs +++ b/rust/apps/lexical-playground-rune/src/tests.rs @@ -1,4 +1,5 @@ use super::*; +use lexical_playground_core::format_flag; use rune::{Env, Mods, Runtime}; fn runtime() -> Runtime { @@ -27,13 +28,14 @@ fn typed(rt: &mut Runtime, s: &str) { fn typing_return_and_backspace_edit_the_lexical_document() { let mut rt = runtime(); // The caret starts at the document's start. + let blocks = rt.state.playground.document().blocks.len(); typed(&mut rt, "Hi "); press(&mut rt, Key::Enter, Mods::default()); typed(&mut rt, "x"); press(&mut rt, Key::Backspace, Mods::default()); let doc = rt.state.playground.document(); assert_eq!(rt.state.playground.last_error(), None); - assert_eq!(doc.blocks.len(), 3); + assert_eq!(doc.blocks.len(), blocks + 1); assert_eq!(doc.blocks[0].text, "Hi "); assert!(doc.blocks[1].text.starts_with("Hello from ")); // The field mirrors the document, with the caret where Lexical put it. @@ -94,3 +96,213 @@ fn a_toolbar_button_formats_the_selection_and_keeps_the_keyboard() { .starts_with("!") ); } + +/// Presses the button keyed `key`. Rune ignores a press on a button that +/// appeared that instant (the check button appears with the caret in a +/// check item), so a moment passes first, as it does for a person. +fn click(rt: &mut Runtime, key: &str) { + rt.frame(1.0); + let (x, y) = rt.find(key).unwrap_or_else(|| panic!("the {key} button")); + rt.pointer_down(x, y); + rt.pointer_up(x, y); + rt.frame(0.0); +} + +#[test] +fn the_field_draws_the_examples_lists_quote_and_link() { + let rt = runtime(); + let doc = rt.state.playground.document(); + let kinds: Vec<&str> = doc.blocks.iter().map(|b| b.block_type()).collect(); + assert_eq!( + kinds, + [ + "paragraph", + "paragraph", + "h2", + "bullet", + "bullet", + "check", + "check", + "quote" + ] + ); + assert_eq!( + rt.state.edit.blocks(), + &[ + Block::Paragraph, + Block::Paragraph, + Block::Paragraph, + Block::Bullet(0), + Block::Bullet(0), + Block::Bullet(0), + Block::Bullet(0), + Block::Quote, + ] + ); + let link = rt + .state + .edit + .marks() + .iter() + .find(|m| matches!(&m.mark, Mark::Link(url) if url == "https://lexical.dev")) + .expect("the link's mark"); + assert_eq!(&rt.state.edit.text[link.range()], "lexical.dev"); + // The h2 draws larger and heavier. + let heading = doc.offset_of(Pos { block: 2, byte: 0 }); + assert!( + decorations(doc, false) + .iter() + .any(|s| s.start as usize == heading && s.size == Some(21.0)) + ); +} + +#[test] +fn the_block_buttons_convert_the_caret_block_and_show_its_type() { + let mut rt = runtime(); + rt.update(|s, _| s.edit.set_selection(2, 2)); + rt.frame(0.0); + click(&mut rt, "block-quote"); + assert_eq!(rt.state.playground.block_type(), "quote"); + assert_eq!(rt.state.edit.blocks()[0], Block::Quote); + assert!(rt.state.edit.focused); + click(&mut rt, "block-number"); + assert_eq!(rt.state.edit.blocks()[0], Block::Numbered(0)); + // Tab at an item's start nests it; Shift-Tab brings it back. + rt.update(|s, _| s.edit.set_selection(0, 0)); + press(&mut rt, Key::Tab, Mods::default()); + assert_eq!(rt.state.edit.blocks()[0], Block::Numbered(1)); + press( + &mut rt, + Key::Tab, + Mods { + shift: true, + ..Mods::default() + }, + ); + assert_eq!(rt.state.edit.blocks()[0], Block::Numbered(0)); + // The same list again makes it a paragraph. + click(&mut rt, "block-number"); + assert_eq!(rt.state.playground.block_type(), "paragraph"); + assert_eq!(rt.state.edit.blocks()[0], Block::Paragraph); + assert_eq!(rt.state.playground.last_error(), None); +} + +#[test] +fn the_link_button_links_the_selection_and_unlinks_it() { + let mut rt = runtime(); + rt.update(|s, _| s.edit.set_selection(0, 5)); + rt.frame(0.0); + click(&mut rt, "link"); + assert_eq!(rt.state.playground.link(), Some("https://")); + assert!(rt.state.edit.marks().iter().any(|m| { + matches!(&m.mark, Mark::Link(url) if url == "https://") && m.range() == (0..5) + })); + click(&mut rt, "link"); + assert_eq!(rt.state.playground.link(), None); + assert!( + !rt.state + .edit + .marks() + .iter() + .any(|m| matches!(&m.mark, Mark::Link(url) if url == "https://")) + ); +} + +#[test] +fn the_check_button_toggles_the_caret_item_and_strikes_it_when_checked() { + let mut rt = runtime(); + let checked = |rt: &Runtime| { + rt.state.playground.document().blocks[6] + .list + .as_ref() + .is_some_and(|l| l.checked) + }; + let struck = |rt: &Runtime| { + let doc = rt.state.playground.document(); + let item = doc.offset_of(Pos { block: 6, byte: 0 }); + let len = doc.blocks[6].text.len(); + rt.state + .edit + .marks() + .iter() + .any(|m| m.mark == Mark::Strike && m.range() == (item..item + len)) + }; + // The example's second check item starts unchecked. + assert!(!checked(&rt) && !struck(&rt)); + let item = rt + .state + .playground + .document() + .offset_of(Pos { block: 6, byte: 3 }); + rt.update(|s, _| s.edit.set_selection(item, item)); + rt.frame(0.0); + click(&mut rt, "check-item"); + assert!(checked(&rt) && struck(&rt)); + click(&mut rt, "check-item"); + assert!(!checked(&rt) && !struck(&rt)); + assert_eq!(rt.state.playground.last_error(), None); +} + +#[test] +fn the_toolbar_follows_a_click_before_lexical_hears_of_it() { + let mut rt = runtime(); + let steps = rt.state.playground.step_count(); + // Into "Rust", which is bold, then into the h2: no edit yet. + rt.update(|s, _| s.edit.set_selection(13, 13)); + rt.frame(0.0); + let format = rt.state.playground.format_at(rt.state.shown_selection()); + assert_eq!(format, format_flag("bold")); + let h2 = rt + .state + .playground + .document() + .offset_of(Pos { block: 2, byte: 1 }); + rt.update(|s, _| s.edit.set_selection(h2, h2)); + rt.frame(0.0); + let sel = rt.state.shown_selection(); + assert_eq!(rt.state.playground.block_type_at(sel), "h2"); + assert_eq!(rt.state.playground.step_count(), steps); +} + +#[test] +fn markdown_shortcuts_typed_in_the_field_become_rich_text() { + let mut rt = runtime(); + // A new line at the document's end (set directly: End moves to the + // line's end on Linux and to the document's on macOS), then markdown + // as typed keys. + let before = rt.state.playground.document().blocks.len(); + rt.update(|s, _| { + let end = s.edit.text.len(); + s.edit.set_selection(end, end); + }); + press(&mut rt, Key::Enter, Mods::default()); + typed(&mut rt, "## Title"); + press(&mut rt, Key::Enter, Mods::default()); + typed(&mut rt, "- **bold** item"); + press(&mut rt, Key::Enter, Mods::default()); + press(&mut rt, Key::Enter, Mods::default()); + typed(&mut rt, "```"); + press(&mut rt, Key::Enter, Mods::default()); + typed(&mut rt, "code"); + assert_eq!(rt.state.playground.last_error(), None); + let doc = rt.state.playground.document(); + assert_eq!(doc.blocks.len(), before + 3); + let [heading, item, code] = &doc.blocks[before..] else { + unreachable!() + }; + assert_eq!(heading.block_type(), "h2"); + assert_eq!(heading.text, "Title"); + assert_eq!(item.block_type(), "bullet"); + assert_eq!(item.text, "bold item"); + assert!(item.runs[0].has_format("bold")); + assert_eq!(code.kind, "code"); + assert_eq!(code.text, "code"); + assert!(matches!(rune_block(code), Block::Code)); + // The field mirrors the document, with the caret at the code's end. + assert_eq!(rt.state.edit.text, doc.text()); + let caret = doc.offset_of(Pos { + block: before + 2, + byte: 4, + }); + assert_eq!(rt.state.edit.range(), (caret, caret)); +} diff --git a/rust/apps/lexical-playground-rune/src/view.rs b/rust/apps/lexical-playground-rune/src/view.rs index ca581e8f916..35b64db3a20 100644 --- a/rust/apps/lexical-playground-rune/src/view.rs +++ b/rust/apps/lexical-playground-rune/src/view.rs @@ -2,7 +2,7 @@ //! tree, its JSON, and parity with JS Lexical. use crate::{Panel, State, decorations, on_change, on_key, on_paste, on_window_focus}; -use lexical_playground_core::{Playground, example, replay_vectors}; +use lexical_playground_core::{Playground, example, format_flag, replay_vectors}; use rune::{Color, Cx, El, ScrollSpec, TextStyle, col, row, text, text_input}; use rune_kit::{Button, ButtonVariant, Kit, SegmentedControl}; use std::sync::OnceLock; @@ -28,6 +28,23 @@ const FORMATS: [(&str, &str); 8] = [ ("superscript", "x²"), ]; +/// The block menu, as the JS playground's `BlockFormatDropDown`: each +/// button is a `blockType` and its label. +const BLOCKS: [(&str, &str); 8] = [ + ("paragraph", "Normal"), + ("h1", "H1"), + ("h2", "H2"), + ("h3", "H3"), + ("quote", "Quote"), + ("bullet", "• List"), + ("number", "1. List"), + ("check", "☑ List"), +]; + +/// The URL a new link gets, as the JS playground's link button inserts +/// `https://` for the user to edit. +const NEW_LINK_URL: &str = "https://"; + pub fn view(s: &State, cx: &mut Cx) -> El { let k = kit(cx.system_dark()); col() @@ -36,6 +53,7 @@ pub fn view(s: &State, cx: &mut Cx) -> El { .fill(k.c.page) .on_window_focus(on_window_focus) .child(toolbar(s, k, cx.chrome_insets().leading)) + .child(block_bar(s, k)) .child( row() .w_full() @@ -58,8 +76,9 @@ fn toolbar(s: &State, k: &Kit, leading: f32) -> El { .pl(if leading > 0.0 { leading + 12.0 } else { 10.0 }) .fill(k.c.bg_base); bar = bar.child(text("Lexical").font(k.ty.body_medium).color(k.c.text_base)); + let format = s.playground.format_at(s.shown_selection()); for (name, label) in FORMATS { - let on = s.playground.has_format(name); + let on = format & format_flag(name) != 0; bar = bar.child( Button::new(label) .variant(if on { @@ -95,6 +114,89 @@ fn toolbar(s: &State, k: &Kit, leading: f32) -> El { ) } +fn toggle(label: &str, on: bool, help: String) -> Button { + Button::new(label) + .variant(if on { + ButtonVariant::Contrast + } else { + ButtonVariant::Ghost + }) + .help(help) +} + +/// The second row: the block menu, the link button, indent and outdent, +/// and a check item's box. +fn block_bar(s: &State, k: &Kit) -> El { + let p = &s.playground; + let sel = s.shown_selection(); + let current = p.block_type_at(sel); + let mut bar = row().w_full().gap(6.0).px(10.0).pb(10.0).fill(k.c.bg_base); + for (block_type, label) in BLOCKS { + bar = bar.child( + toggle(label, current == block_type, format!("Format as {label}")) + .on_press(move |s: &mut State| s.toolbar_edit(|p| p.set_block_type(block_type))) + .build(k) + .key(format!("block-{block_type}")), + ); + } + let link = p.link_at(sel).map(str::to_owned); + bar = bar.child( + toggle( + "Link", + link.is_some(), + match &link { + Some(url) => format!("Remove the link to {url}"), + None => "Insert a link".to_owned(), + }, + ) + .on_press(move |s: &mut State| { + let remove = s.playground.link_at(s.shown_selection()).is_some(); + s.toolbar_edit(|p| p.toggle_link((!remove).then_some(NEW_LINK_URL))) + }) + .build(k) + .key("link"), + ); + bar = bar + .child( + Button::new("Outdent") + .variant(ButtonVariant::Ghost) + .on_press(|s: &mut State| s.toolbar_edit(|p| p.indent(true))) + .build(k) + .key("outdent"), + ) + .child( + Button::new("Indent") + .variant(ButtonVariant::Ghost) + .on_press(|s: &mut State| s.toolbar_edit(|p| p.indent(false))) + .build(k) + .key("indent"), + ); + let block = sel.anchor.block; + if let Some(list) = p.document().blocks.get(block).and_then(|b| b.list.as_ref()) + && list.list_type == "check" + { + bar = bar.child( + toggle( + if list.checked { "☑ Done" } else { "☐ Done" }, + list.checked, + "Check or uncheck this item".to_owned(), + ) + .on_press(move |s: &mut State| s.toolbar_edit(|p| p.toggle_checked(block))) + .build(k) + .key("check-item"), + ); + } + if let Some(url) = link { + bar = bar.child( + text(url) + .font(k.ty.small) + .color(k.c.text_muted) + .key("link-url"), + ); + } + bar +} + fn editor(s: &State, k: &Kit) -> El { // The field's own ink is white, for a dark window; the kit's follows // the system's appearance.