diff --git a/rust/DESIGN.md b/rust/DESIGN.md index 10ae85e5a14..310b0cb2936 100644 --- a/rust/DESIGN.md +++ b/rust/DESIGN.md @@ -170,7 +170,7 @@ Each milestone ends with green CI and a report to the Space. | Milestone | Scope | Vectors that pass when done | Status | | --- | --- | --- | --- | | M0 | The `rust/` workspace, JSON as JS writes it, the core nodes (root, paragraph, text, tab, line break), `parseEditorState` and `toJSON` in both forms, the conformance runner, CI | `meta`, `serialization/core`, the core-only cases of `property/documents` (JSON and text) | done | -| M1 | The core model: `editor.update` and the commit (dirty nodes, normalization, transforms, listeners, commands, tags), node creation and methods (`splitText`, `mergeWithSibling`, `spliceText`, `insertAfter`, `remove`, `replace`...) with UTF-16 offsets, `RangeSelection` and `NodeSelection` editing, carets, NodeState, slots, the schema facility, `DecoratorNode`, the extension API (`buildEditorFromExtensions`), the parity ledger | `scripts/*`, `fuzz/core`, `file/*` | in progress: the update cycle, `setEditorState`, commands, transforms, listeners and the node methods are done (`scripts/updates`, `commands` pass; `listeners`, `transforms` pass but for the cases that type); next, `RangeSelection` editing and carets | +| M1 | The core model: `editor.update` and the commit (dirty nodes, normalization, transforms, listeners, commands, tags), node creation and methods (`splitText`, `mergeWithSibling`, `spliceText`, `insertAfter`, `remove`, `replace`...) with UTF-16 offsets, `RangeSelection` and `NodeSelection` editing, carets, NodeState, slots, the schema facility, `DecoratorNode`, the extension API (`buildEditorFromExtensions`), the parity ledger | `scripts/*`, `fuzz/core`, `file/*` | in progress: the update cycle, `setEditorState`, commands, transforms, listeners and the node methods are done (`scripts/updates`, `commands` pass; `listeners`, `transforms` pass but for the cases that type); carets and selection reading (`getNodes`, `getTextContent`) are done (`scripts/caret` passes); next, `RangeSelection` editing | | M2 | Node packages: `rich-text`, `list`, `link`, `code`, `overflow`, `hashtag`, with `selection` and `utils` | `serialization/*`, `property/documents` (JSON and text), `entities/hashtag-*` | | | M3 | `history`, and the commands `property/sequences` dispatches | `property/sequences` | | | M4 | `html`: a DOM abstraction over an HTML parser, CSS declarations, export and import | `html/*`, `css/*`, `property/documents` (HTML) | | diff --git a/rust/PARITY.md b/rust/PARITY.md index 286c618b77b..04ecf87fa72 100644 --- a/rust/PARITY.md +++ b/rust/PARITY.md @@ -19,6 +19,10 @@ updated with each milestone. | Text methods | `LexicalTextNode.ts` (`splitText`, `mergeWithSibling`, `spliceText`, `setTextContent`, `select`, the format, detail, mode and style setters and toggles), `toggleTextFormatType`, `TabNode`'s overrides | `nodes::text` | `scripts/updates`, `scripts/listeners`, `scripts/transforms` | | Tree surgery | `caret/LexicalCaretTree.ts` (`$insertNodeBetween`, `$detachNode`, `$detachSiblingRange`, `$detachNodeWithSelection`, `$insertSibling`, `$selectAdjacentNode`) | `caret_tree` | `scripts/updates` | | Selection model | `LexicalSelection.ts`: points, `RangeSelection` and `NodeSelection` fields, `$getSelection`, `$setSelection`, `$createRangeSelection`, and the helpers node methods use to keep a selection valid | `selection` | `scripts/updates` | +| Selection reading | `LexicalSelection.ts`: `Point.isBefore`, `isBackward` across nodes, `RangeSelection.getNodes` (`$getNodesFromCaretRangeCompat`) and `getTextContent`, `NodeSelection.getNodes` and `getTextContent`, the `_cachedNodes` cache | `selection` | `scripts/caret`; a unit test against JS's output | +| Carets | `caret/LexicalCaret.ts`: sibling, child and text point carets in both directions and their methods (`getAdjacentCaret`, `getNodeAtCaret`, `getFlipped`, `getParentCaret`, `getChildCaret`, `insert`, `remove`, `replaceOrInsert`, `splice`, iteration), `$getSiblingCaret`, `$getChildCaret`, `$getTextPointCaret`, `$getTextNodeOffset`, text slices, caret ranges (`$getCaretRange`, `iterNodeCarets`, `getTextSlices`, `isCollapsed`), `$getAdjacentChildCaret`, `$getCaretInDirection`, `$extendCaretToRange`, `$comparePointCaretNext`, `$getCommonAncestor`, `makeStepwiseIterator` | `caret` | `scripts/caret` | +| Caret utilities | `caret/LexicalCaretUtils.ts`: `$caretFromPoint`, `$setPointFromCaret`, `$caretRangeFromSelection`, `$setSelectionFromCaretRange`, `$updateRangeSelectionFromCaretRange`, `$rewindSiblingCaret`, `$removeTextFromCaretRange` (block merges, empty-container repair), `$normalizeCaret`, `$isExtendableTextPointCaret`, `$getCaretRangeInDirection`, `$getChildCaretAtIndex`, `$getAdjacentSiblingOrParentSiblingCaret`, `$getTextPointCaretSliceForNode`; with `$getNodeByKeyOrThrow`, `$removeFromParent`, `$restoreEmptyContainerParagraph`, `$isShadowRootNode` and `INTERNAL_$isBlock` from `LexicalUtils.ts` | `caret_utils`, `utils` | `scripts/caret` | +| Selection editing | `LexicalSelection.ts`: `insertText` (`$insertText`, `$insertTextAtPoint`), `insertRawText` (`tokenizeRawText`, `$generateNodesFromRawText`), `insertNodes` for both selection kinds (`$removeTextAndSplitBlock`, `$splitNodeAtPoint`, `$wrapInlineNodes`), `insertParagraph`, `insertLineBreak`, `removeText`, `formatText` (`$updateTextFormat`, `$setTextFormat`, `$getCharacterOffsets`), `toggleFormat`, `setFormat`, `setStyle`, `deleteCharacter`, `deleteWord` and `deleteLine` up to `Selection.modify` (`forwardDeletion`, `$extendSelectionForDeletion`, `$modifySelectionAroundDecoratorsAndBlocks`, `$collapseAtStart`, `$removeSegment`), `$insertNodes`, `$selectAll`; `$splitTextPointCaretSlice` and `$isBlockFullySelected`; `ParagraphNode.insertNewAfter`, `canInsertTextBefore`/`After` | `selection_editing`, `selection_insert_text`, `selection_insert_nodes`, `selection_format`, `selection_deletion` | `scripts/selection`, `fuzz/core`; unit tests against JS's output for paths no vector covers | | Update cycle | `LexicalUpdates.ts` (`$beginUpdate`, nested and queued updates, `$applyAllTransforms`, `$commitPendingUpdates`, `$discardPendingUpdate`, listeners, `triggerCommandListeners`, `updateEditorSync`), `LexicalNormalization.ts` (`$normalizeTextNode`), `LexicalGC.ts` (nodes) | `updates`, `normalization`, `gc` | `scripts/updates`, `scripts/listeners`, `scripts/transforms`, `scripts/commands` | | Editor state | `LexicalEditorState.ts` (`toJSON`, `read`, `isEmpty`, `_parsed`) | `editor_state` | `serialization/core` | | Parsing | `parseEditorState`, `$parseSerializedNode` (`LexicalUpdates.ts`) | `updates` | `serialization/core`, `property/documents` | @@ -33,13 +37,19 @@ touches: - **NodeState** (`"$"`) and **slots** (`"$slots"`): a document that carries either fails to parse with an error naming it, rather than losing them. -- `RangeSelection` editing (`insertText`, `insertParagraph`, - `deleteCharacter`, `formatText`, ...), `$selectAll`, `$insertNodes`, and - ordering points on different nodes (`isBefore`, which needs carets): a - case that reaches these fails with an error naming the caret port rather - than guessing. `scripts/selection` and `scripts/caret` cases, and the one - case each of `scripts/listeners` and `scripts/transforms` that types - text, are counted as waiting on them. +- Deletion past the model: JS's `deleteCharacter`, `deleteWord` and + `deleteLine` extend the selection with the DOM's `Selection.modify`. A + headless JS editor has no window and throws (`window object not found`), + and so does this port; the vectors record that. `RangeSelection.modify`, + `extract`, and `$selectAll(selection)`'s scoping aren't ported, nor + `insertNodes`' CASE 3a (blocks with no block ancestor, which needs + `$insertNodeToNearestRootAtCaret`). +- The caret utilities that split or insert (`$splitTextPointCaret`, + `$splitAtPointCaretNext`, `$insertNodeToNearestRootAtCaret`), and the + slot-aware caret comparisons (`$comparePointCaretNextAcrossSlots`, + `$getCommonAncestorAcrossSlots`). Without slots, NodeState or the text + edit log, `$removeTextFromCaretRange` never meets a slot host and carries + no NodeState or deletion records. - Composition, the DOM reconciler, mutation listeners, selection-change notifications and `$onUpdate` callbacks (none of which a headless editor runs, except `$onUpdate`), and `RootNode.__cachedText`. diff --git a/rust/conformance/src/lib.rs b/rust/conformance/src/lib.rs index 0baa0a84133..372a64de4c5 100644 --- a/rust/conformance/src/lib.rs +++ b/rust/conformance/src/lib.rs @@ -9,6 +9,7 @@ //! and the rest still run. mod case; +pub mod scripts; mod suites; pub use case::{Case, Outcome}; diff --git a/rust/conformance/src/scripts.rs b/rust/conformance/src/scripts.rs new file mode 100644 index 00000000000..72b52f7a9e1 --- /dev/null +++ b/rust/conformance/src/scripts.rs @@ -0,0 +1,750 @@ +//! `scripts/*.json`: editor sessions in the script language of +//! `packages/lexical-conformance/src/ops.ts`, interpreted here as `ops.ts` +//! interprets them for JS. Each step is one discrete update or one command +//! dispatch; after each, the editor state, the selection, the text and a +//! log of what listeners, transforms and command handlers saw must match +//! what JS recorded. +//! +//! A script that uses an action the port can't run yet (selection editing) +//! is counted as waiting on that part of `lexical`, never as passing. + +use crate::case::{Case, Outcome, same}; +use lexical::json::{Object, Value}; +use lexical::{ + COMMAND_PRIORITY_EDITOR, CaretDirection, CreateEditorArgs, Direction, Editor, LexicalCommand, + LexicalError, NodeRef, PointCaret, PointType, RangeSelection, SelectionRef, SliceMode, + TextOffset, TextPointCaretSlice, UpdateOptions, Which, caret_range_from_selection, + create_command, create_editor, create_node_selection, create_range_selection, get_caret_range, + get_child_caret, get_node_by_key, get_root, get_selection, get_sibling_caret, + get_text_point_caret, insert_nodes, normalize_caret, parse_serialized_node, + remove_text_from_caret_range, reset_random_key, select_all, set_selection, + set_selection_from_caret_range, utf16, +}; +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +/// What `ops.ts`'s `Context` holds besides the editor. +#[derive(Default)] +struct Ctx { + log: RefCell>, + commands: RefCell>>, +} + +impl Ctx { + fn log(&self, entry: Vec) { + self.log.borrow_mut().push(Value::Array(entry)); + } + + /// `command(ctx, name)`: one command per name. + fn command(&self, name: &str) -> LexicalCommand { + self.commands + .borrow_mut() + .entry(name.to_owned()) + .or_insert_with(|| create_command(name)) + .clone() + } +} + +/// The part of `lexical` an action needs that isn't ported yet, if any. +fn unported_action(action: &Value) -> Option<&'static str> { + match action.get("do").and_then(Value::as_str) { + Some( + "append" + | "select" + | "clearSelection" + | "node" + | "dispatch" + | "logCaretRange" + | "removeTextFromCaretRange" + | "logNormalizeCaret", + ) => None, + Some("selection" | "selectAll" | "selectNodes" | "insertNodes") => None, + _ => Some("unknown"), + } +} + +fn unported(script: &Value) -> Option<&'static str> { + let actions = |v: Option<&Value>| -> Vec { + v.and_then(Value::as_array).cloned().unwrap_or_default() + }; + let steps = actions(script.get("steps")); + let handlers = actions(script.get("handlers")); + steps + .iter() + .flat_map(|s| actions(s.get("update"))) + .chain(handlers.iter().flat_map(|h| actions(h.get("actions")))) + .find_map(|a| unported_action(&a)) +} + +fn err(message: impl Into) -> LexicalError { + LexicalError::new(message) +} + +/// `$node(key)`. +fn node(key: &str) -> Result { + get_node_by_key(key).ok_or_else(|| err(format!("no node {key}"))) +} + +fn text(node: NodeRef) -> Result { + if node.is_text() { + Ok(node) + } else { + Err(err(format!("{} is not a text node", node.get_key()))) + } +} + +fn element(node: NodeRef) -> Result { + if node.is_element() { + Ok(node) + } else { + Err(err(format!("{} is not an element", node.get_key()))) + } +} + +/// `$parse(nodes)`. +fn parse(nodes: &Value) -> Result, LexicalError> { + nodes + .as_array() + .ok_or_else(|| err("expected an array of nodes"))? + .iter() + .map(parse_serialized_node) + .collect() +} + +/// `arg(args, i)`: the argument, or `null`. +fn arg(args: Option<&Value>, i: usize) -> Value { + args.and_then(Value::as_array) + .and_then(|a| a.get(i)) + .cloned() + .unwrap_or(Value::Null) +} + +fn number(v: &Value) -> Result { + v.as_f64() + .ok_or_else(|| err(format!("expected a number, got {v}"))) +} + +fn string(v: &Value) -> Result { + v.as_str() + .map(str::to_owned) + .ok_or_else(|| err(format!("expected a string, got {v}"))) +} + +/// An optional offset (`undefined` for `null`). +fn offset(v: &Value) -> Result, LexicalError> { + if v.is_null() { + Ok(None) + } else { + Ok(Some(number(v)? as usize)) + } +} + +fn flag(v: &Value, default: bool) -> bool { + v.as_bool().unwrap_or(default) +} + +fn point_type(v: &Value) -> Result { + match v.as_str() { + Some("text") => Ok(PointType::Text), + Some("element") => Ok(PointType::Element), + _ => Err(err(format!("bad point type {v}"))), + } +} + +/// `$sel()`: the range selection. +/// `SELECTION_METHODS[method](args)` in ops.ts. +fn run_selection_method(method: &str, args: Option<&Value>) -> Result<(), LexicalError> { + let a = |i| arg(args, i); + let range = || { + get_selection() + .filter(SelectionRef::is_range) + .ok_or_else(|| err("not a range selection")) + }; + let any = || get_selection().ok_or_else(|| err("no selection")); + match method { + "deleteCharacter" => range()?.delete_character(flag(&a(0), false))?, + "deleteLine" => range()?.delete_line(flag(&a(0), false))?, + "deleteWord" => range()?.delete_word(flag(&a(0), false))?, + "formatText" => range()?.format_text( + &string(&a(0))?, + match a(1) { + Value::Null => None, + v => Some(number(&v)?), + }, + )?, + "insertLineBreak" => range()?.insert_line_break(flag(&a(0), false))?, + "insertNodes" => any()?.insert_nodes(parse(&a(0))?)?, + "insertParagraph" => { + range()?.insert_paragraph()?; + } + "insertRawText" => any()?.insert_raw_text(&string(&a(0))?)?, + "insertText" => any()?.insert_text(&string(&a(0))?)?, + "removeText" => range()?.remove_text()?, + "setFormat" => range()?.set_format(number(&a(0))?), + "setStyle" => range()?.set_style(&string(&a(0))?), + "toggleFormat" => range()?.toggle_format(&string(&a(0))?), + _ => return Err(err(format!("unknown selection method {method}"))), + } + Ok(()) +} + +fn sel() -> Result { + get_selection() + .and_then(|s| s.range()) + .ok_or_else(|| err("not a range selection")) +} + +fn direction(v: &Value) -> Result { + match v.as_str() { + Some("next") => Ok(CaretDirection::Next), + Some("previous") => Ok(CaretDirection::Previous), + _ => Err(err(format!("bad direction {v}"))), + } +} + +/// `$caret(spec)`. +fn caret(spec: &Value) -> Result { + let key = string(&arg(Some(spec), 0))?; + let dir = direction(&arg(Some(spec), 1))?; + let n = node(&key)?; + match arg(Some(spec), 2).as_str() { + Some("text") => { + let n = text(n)?; + let offset = match arg(Some(spec), 3) { + v @ Value::String(_) => TextOffset::End(direction(&v)?), + v => TextOffset::Offset(number(&v)? as usize), + }; + get_text_point_caret(&n, dir, offset) + } + Some("child") => { + let n = element(n)?; + Ok(get_child_caret(&n, dir).expect("an element")) + } + _ => Ok(get_sibling_caret(&n, dir)), + } +} + +/// `caretSnapshot(caret)`. +fn caret_snapshot(caret: &PointCaret) -> Value { + let key = caret.origin().get_key().into(); + let dir = caret.direction().as_str().into(); + match caret.offset() { + Some(offset) => Value::Array(vec!["text".into(), key, dir, (offset as f64).into()]), + None => Value::Array(vec![caret.caret_type().into(), key, dir]), + } +} + +/// A text slice in the log: `[key, offset, distance]`. +fn slice_snapshot(slice: &Option) -> Value { + match slice { + None => Value::Null, + Some(s) => Value::Array(vec![ + s.caret.origin().get_key().into(), + (s.caret.offset().unwrap_or(0) as f64).into(), + (s.distance as f64).into(), + ]), + } +} + +/// `NODE_METHODS[method](node, args, ctx)`. +fn run_node_method( + n: NodeRef, + method: &str, + args: Option<&Value>, + ctx: &Ctx, +) -> Result<(), LexicalError> { + let a = |i| arg(args, i); + match method { + "append" => element(n)?.append(&parse(&a(0))?)?, + "clear" => element(n)?.clear()?, + "insertAfter" => { + n.insert_after(&parse_serialized_node(&a(0))?, flag(&a(1), true))?; + } + "insertBefore" => { + n.insert_before(&parse_serialized_node(&a(0))?, flag(&a(1), true))?; + } + "markDirty" => n.mark_dirty(), + "mergeWithSibling" => { + let target = text(node(&string(&a(0))?)?)?; + text(n)?.merge_with_sibling(&target)?; + } + "remove" => n.remove(flag(&a(0), false))?, + "replace" => { + n.replace(&parse_serialized_node(&a(0))?, flag(&a(1), false))?; + } + "select" => { + n.select(offset(&a(0))?, offset(&a(1))?)?; + } + "selectEnd" => { + n.select_end()?; + } + "selectNext" => { + n.select_next(offset(&a(0))?, offset(&a(1))?)?; + } + "selectPrevious" => { + n.select_previous(offset(&a(0))?, offset(&a(1))?)?; + } + "selectStart" => { + n.select_start()?; + } + "setDetail" => { + let n = text(n)?; + match a(0) { + Value::String(name) => n.set_detail_type(&name)?, + v => n.set_detail(number(&v)?)?, + } + } + "setDirection" => element(n)?.set_direction(match a(0).as_str() { + Some("ltr") => Some(Direction::Ltr), + Some("rtl") => Some(Direction::Rtl), + _ => None, + }), + "setFormat" if n.is_text() => match a(0) { + Value::String(name) => n.set_format_type(&name), + v => n.set_format(number(&v)?), + }, + "setFormat" => element(n)?.set_element_format(a(0).as_str().unwrap_or("")), + "setIndent" => element(n)?.set_indent(number(&a(0))?), + "setMode" => text(n)?.set_mode(&string(&a(0))?)?, + "setStyle" if n.is_text() => n.set_style(&string(&a(0))?), + "setStyle" => element(n)?.set_element_style(a(0).as_str().unwrap_or("")), + "setTextContent" => text(n)?.set_text_content(&string(&a(0))?), + "setTextFormat" => element(n)?.set_text_format(number(&a(0))?), + "setTextStyle" => element(n)?.set_text_style(&string(&a(0))?), + "splice" => element(n)?.splice( + number(&a(0))? as usize, + number(&a(1))? as usize, + &parse(&a(2))?, + )?, + "spliceText" => { + text(n)?.splice_text( + number(&a(0))? as i64, + number(&a(1))? as i64, + &string(&a(2))?, + flag(&a(3), false), + )?; + } + "splitText" => { + let offsets: Vec = args + .and_then(Value::as_array) + .map(|a| a.iter().map(|v| number(v).map(|n| n as i64)).collect()) + .transpose()? + .unwrap_or_default(); + let parts = text(n)?.split_text(&offsets)?; + ctx.log(vec![ + "splitText".into(), + Value::Array(parts.iter().map(|p| p.get_key().into()).collect()), + ]); + } + "toggleDirectionless" => text(n)?.toggle_directionless(), + "toggleFormat" => text(n)?.toggle_format(&string(&a(0))?), + "toggleUnmergeable" => text(n)?.toggle_unmergeable(), + _ => return Err(err(format!("unknown node method {method}"))), + } + Ok(()) +} + +/// `$runAction(action, ctx)`. +fn run_action(action: &Value, ctx: &Ctx, editor: &Editor) -> Result<(), LexicalError> { + match action.get("do").and_then(Value::as_str) { + Some("append") => { + let key = action.get("key").and_then(Value::as_str).unwrap_or(""); + element(node(key)?)?.append(&parse(action.get("nodes").unwrap_or(&Value::Null))?) + } + Some("select") => { + let selection = create_range_selection(); + let anchor = action.get("anchor").cloned().unwrap_or(Value::Null); + let focus = action + .get("focus") + .cloned() + .unwrap_or_else(|| anchor.clone()); + for (which, spec) in [(Which::Anchor, &anchor), (Which::Focus, &focus)] { + selection.point_set( + which, + &string(&arg(Some(spec), 0))?, + number(&arg(Some(spec), 1))? as usize, + point_type(&arg(Some(spec), 2))?, + )?; + } + set_selection(Some(selection)); + Ok(()) + } + Some("selectNodes") => { + let selection = create_node_selection(); + for key in action + .get("keys") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + selection.add(&string(key)?); + } + set_selection(Some(selection)); + Ok(()) + } + Some("selectAll") => select_all().map(|_| ()), + Some("insertNodes") => insert_nodes(parse(action.get("nodes").unwrap_or(&Value::Null))?), + Some("selection") => run_selection_method( + action.get("method").and_then(Value::as_str).unwrap_or(""), + action.get("args"), + ), + Some("clearSelection") => { + set_selection(None); + Ok(()) + } + Some("node") => { + let key = action.get("key").and_then(Value::as_str).unwrap_or(""); + let method = action.get("method").and_then(Value::as_str).unwrap_or(""); + run_node_method(node(key)?, method, action.get("args"), ctx) + } + Some("logCaretRange") => { + let range = match (action.get("anchor"), action.get("focus")) { + (Some(anchor), Some(focus)) => get_caret_range(caret(anchor)?, caret(focus)?)?, + _ => caret_range_from_selection(&sel()?)?, + }; + let [anchor_slice, focus_slice] = range.get_text_slices(); + ctx.log(vec![ + "caretRange".into(), + caret_snapshot(&range.anchor), + caret_snapshot(&range.focus), + Value::Array(range.iter().map(|c| caret_snapshot(&c)).collect()), + Value::Array(vec![ + slice_snapshot(&anchor_slice), + slice_snapshot(&focus_slice), + ]), + ]); + Ok(()) + } + Some("removeTextFromCaretRange") => { + let range = remove_text_from_caret_range( + &caret_range_from_selection(&sel()?)?, + SliceMode::RemoveEmptySlices, + )?; + ctx.log(vec![ + "removeTextFromCaretRange".into(), + caret_snapshot(&range.anchor), + caret_snapshot(&range.focus), + ]); + set_selection_from_caret_range(&range)?; + Ok(()) + } + Some("logNormalizeCaret") => { + let spec = action.get("caret").unwrap_or(&Value::Null); + ctx.log(vec![ + "normalizeCaret".into(), + caret_snapshot(&normalize_caret(&caret(spec)?)), + ]); + Ok(()) + } + Some("dispatch") => { + let name = action.get("command").and_then(Value::as_str).unwrap_or(""); + let payload = action.get("payload").cloned().unwrap_or(Value::Null); + let handled = editor.dispatch_command(&ctx.command(name), payload)?; + ctx.log(vec!["dispatched".into(), name.into(), handled.into()]); + Ok(()) + } + other => Err(err(format!("unsupported action {other:?}"))), + } +} + +/// `$selectionSnapshot()`. +fn selection_snapshot() -> Value { + let Some(selection) = get_selection() else { + return Value::Null; + }; + let point = |which| { + let p = selection.point(which).expect("a range selection"); + Value::Array(vec![ + p.key.as_str().into(), + (p.offset as f64).into(), + p.point_type.as_str().into(), + ]) + }; + match selection.range() { + Some(range) => Value::Array(vec![ + "range".into(), + point(Which::Anchor), + point(Which::Focus), + range.format.into(), + range.style.as_str().into(), + ]), + None => { + let keys = selection.node_keys(); + Value::Array(vec![ + "node".into(), + Value::Array(keys.into_iter().map(Value::from).collect()), + ]) + } + } +} + +/// `TRANSFORMS[name](ctx)`. +fn register_transform(editor: &Editor, name: &'static str, ctx: &Rc) -> Result<(), String> { + let ctx = ctx.clone(); + let log = move |node: &NodeRef| { + ctx.log(vec!["transform".into(), name.into(), node.get_key().into()]); + }; + // Registered for the whole session; dropping the unregister function + // doesn't call it. + let _unregister = match name { + "paragraph:log" => editor.register_node_transform("paragraph", move |node| { + log(node); + Ok(()) + }), + "paragraph:remove-empty" => editor.register_node_transform("paragraph", move |node| { + log(node); + if node.is_empty() && node.get_next_sibling().is_some() { + node.remove(false)?; + } + Ok(()) + }), + "root:log" => editor.register_node_transform("root", move |node| { + log(node); + Ok(()) + }), + "text:bold-hash" => editor.register_node_transform("text", move |node| { + log(node); + if node.is_simple_text() + && node.get_text_content().starts_with('#') + && !node.has_format("bold") + { + node.toggle_format("bold"); + } + Ok(()) + }), + "text:split-hash" => editor.register_node_transform("text", move |node| { + log(node); + let content = node.get_text_content(); + // `indexOf`, in UTF-16 code units. + let index = content.find('#').map(|i| utf16::len(&content[..i])); + if node.is_simple_text() + && let Some(index) = index.filter(|&i| i > 0) + { + let parts = node.split_text(&[index as i64])?; + parts[1].toggle_unmergeable(); + } + Ok(()) + }), + "text:upper-a" => editor.register_node_transform("text", move |node| { + log(node); + let content = node.get_text_content(); + if content.contains('a') { + node.set_text_content(&content.replace('a', "A")); + } + Ok(()) + }), + _ => return Err(format!("unknown transform {name}")), + }; + Ok(()) +} + +/// The transform names `ops.ts` defines, as `'static` strings. +fn transform_name(name: &str) -> Option<&'static str> { + [ + "paragraph:log", + "paragraph:remove-empty", + "root:log", + "text:bold-hash", + "text:split-hash", + "text:upper-a", + ] + .into_iter() + .find(|n| *n == name) +} + +/// A script's editor, set up with its transforms, command handlers and +/// listeners, that runs steps one at a time (`ops.ts`'s `Session`). The +/// conformance suite replays the vectors with it; the playground drives it +/// step by step alongside JS Lexical. +pub struct Session { + editor: Editor, + ctx: Rc, +} + +/// One step's result. +pub struct StepResult { + /// What `ops.ts`'s `session.run(step, snapshot)` returns, in its key + /// order, so `json.to_string()` is JS's `JSON.stringify` of it. + pub json: Value, + /// Why the step failed, when `json` has `error: true`. + pub error: Option, +} + +impl Session { + /// `startSession(script)`: a new headless editor, with keys restarted. + pub fn start(script: &Value) -> Result { + reset_random_key(); + let editor = create_editor(CreateEditorArgs::default()); + let ctx = Rc::new(Ctx::default()); + for name in script + .get("transforms") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let name = name.as_str().unwrap_or(""); + let name = transform_name(name).ok_or_else(|| format!("unknown transform {name}"))?; + register_transform(&editor, name, &ctx)?; + } + for handler in script + .get("handlers") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let id = handler.str("id")?.to_owned(); + let command = ctx.command(handler.str("command")?); + let priority = handler + .get("priority") + .and_then(Value::as_f64) + .map_or(COMMAND_PRIORITY_EDITOR, |p| p as u8); + let result = handler + .get("result") + .and_then(Value::as_bool) + .unwrap_or(false); + let actions = handler + .get("actions") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let ctx = ctx.clone(); + let _unregister = editor.register_command( + &command, + move |payload: &Value, editor: &Editor| { + ctx.log(vec!["handler".into(), id.as_str().into(), payload.clone()]); + for action in &actions { + run_action(action, &ctx, editor)?; + } + Ok(result) + }, + priority, + ); + } + if script.get("listen").and_then(Value::as_bool) == Some(true) { + let log = ctx.clone(); + let _unregister = editor.register_update_listener(move |payload| { + let elements = payload + .dirty_elements + .iter() + .map(|(k, v)| Value::Array(vec![k.as_str().into(), (*v).into()])) + .collect(); + let leaves = payload + .dirty_leaves + .iter() + .map(|k| k.as_str().into()) + .collect(); + let mut tags = payload.tags.clone(); + tags.sort(); + log.log(vec![ + "update".into(), + Value::Array(elements), + Value::Array(leaves), + Value::Array(tags.into_iter().map(Value::from).collect()), + ]); + }); + // Mutation listeners only hear from the DOM reconciler, which a + // headless editor doesn't run, so JS logs no `mutation` entries. + let log = ctx.clone(); + let _unregister = editor.register_text_content_listener(move |text| { + log.log(vec!["textContent".into(), text.into()]); + }); + } + Ok(Session { editor, ctx }) + } + + /// The session's editor. + pub fn editor(&self) -> &Editor { + &self.editor + } + + /// `session.run(step, snapshot)`: runs one step and records the log, + /// the selection and the text, and with `snapshot` the state in both + /// forms. + pub fn run(&self, step: &Value, snapshot: bool) -> Result { + let (editor, ctx) = (&self.editor, &self.ctx); + ctx.log.borrow_mut().clear(); + let outcome = if let Some(actions) = step.get("update").and_then(Value::as_array) { + let tags = step + .get("tag") + .and_then(Value::as_array) + .map(|t| { + t.iter() + .filter_map(|t| t.as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default(); + let (actions, update_ctx, update_editor) = + (actions.clone(), ctx.clone(), editor.clone()); + editor.update( + move || { + for action in &actions { + run_action(action, &update_ctx, &update_editor)?; + } + Ok(()) + }, + UpdateOptions { + discrete: true, + tags, + ..UpdateOptions::default() + }, + ) + } else { + let name = step.str("dispatch")?; + let payload = step.get("payload").cloned().unwrap_or(Value::Null); + editor + .dispatch_command(&ctx.command(name), payload) + .map(|handled| ctx.log(vec!["dispatched".into(), name.into(), handled.into()])) + }; + // A dispatch outside an update commits at the next read. + let (selection, text) = editor + .read(|| (selection_snapshot(), get_root().get_text_content())) + .map_err(|e| format!("committing: {e}"))?; + let mut json = Object::new(); + json.insert("log", Value::Array(ctx.log.borrow().clone())); + json.insert("selection", selection); + json.insert("text", text.into()); + if snapshot { + let state = editor.get_editor_state(); + json.insert("state", state.to_json(false).to_string().into()); + json.insert("compact", state.to_json(true).to_string().into()); + } + let error = outcome.err(); + if error.is_some() { + json.insert("error", true.into()); + } + Ok(StepResult { + json: Value::Object(json), + error, + }) + } +} + +/// `scripts/*.json`: each step's result matches what JS recorded. +pub fn check(case: &Value, _file: &Value) -> Result { + if let Some(part) = unported(case) { + return Ok(Outcome::NotPorted(part)); + } + let session = Session::start(case)?; + let steps = case.arr("steps")?; + let results = case.arr("results")?; + same("steps recorded", results.len(), steps.len())?; + for (i, (step, expected)) in steps.iter().zip(results).enumerate() { + let at = |what: &str| format!("step {i} {what}"); + let snapshot = expected.get("state").is_some(); + let StepResult { json, error } = session.run(step, snapshot).map_err(|e| at(&e))?; + same( + at("error"), + error.is_some(), + expected.get("error").is_some(), + ) + .map_err(|e| match &error { + Some(error) => format!("{e} ({error})"), + None => e, + })?; + for field in ["log", "selection", "text", "state", "compact"] { + same(at(field), json.get(field), expected.get(field))?; + } + } + Ok(Outcome::Pass) +} diff --git a/rust/conformance/src/suites/mod.rs b/rust/conformance/src/suites/mod.rs index 87d531d3d0b..3229b104ccf 100644 --- a/rust/conformance/src/suites/mod.rs +++ b/rust/conformance/src/suites/mod.rs @@ -3,7 +3,6 @@ mod meta; mod property; -mod scripts; mod serialization; use crate::Suite; @@ -23,32 +22,37 @@ pub fn all() -> Vec { Suite { file: "scripts/updates", variant: None, - check: scripts::check, + check: crate::scripts::check, }, Suite { file: "scripts/transforms", variant: None, - check: scripts::check, + check: crate::scripts::check, }, Suite { file: "scripts/listeners", variant: None, - check: scripts::check, + check: crate::scripts::check, }, Suite { file: "scripts/commands", variant: None, - check: scripts::check, + check: crate::scripts::check, }, Suite { file: "scripts/selection", variant: None, - check: scripts::check, + check: crate::scripts::check, }, Suite { file: "scripts/caret", variant: None, - check: scripts::check, + check: crate::scripts::check, + }, + Suite { + file: "fuzz/core", + variant: None, + check: crate::scripts::check, }, Suite { file: "property/documents", diff --git a/rust/conformance/src/suites/scripts.rs b/rust/conformance/src/suites/scripts.rs deleted file mode 100644 index 07d81a3af90..00000000000 --- a/rust/conformance/src/suites/scripts.rs +++ /dev/null @@ -1,564 +0,0 @@ -//! `scripts/*.json`: editor sessions in the script language of -//! `packages/lexical-conformance/src/ops.ts`, interpreted here as `ops.ts` -//! interprets them for JS. Each step is one discrete update or one command -//! dispatch; after each, the editor state, the selection, the text and a -//! log of what listeners, transforms and command handlers saw must match -//! what JS recorded. -//! -//! A script that uses an action the port can't run yet (selection editing, -//! carets) is counted as waiting on that part of `lexical`, never as -//! passing. - -use crate::case::{Case, Outcome, same}; -use lexical::json::Value; -use lexical::{ - COMMAND_PRIORITY_EDITOR, CreateEditorArgs, Direction, Editor, LexicalCommand, LexicalError, - NodeRef, PointType, UpdateOptions, Which, create_command, create_editor, - create_range_selection, get_node_by_key, get_root, get_selection, parse_serialized_node, - reset_random_key, set_selection, utf16, -}; -use std::cell::RefCell; -use std::collections::HashMap; -use std::rc::Rc; - -/// What `ops.ts`'s `Context` holds besides the editor. -#[derive(Default)] -struct Ctx { - log: RefCell>, - commands: RefCell>>, -} - -impl Ctx { - fn log(&self, entry: Vec) { - self.log.borrow_mut().push(Value::Array(entry)); - } - - /// `command(ctx, name)`: one command per name. - fn command(&self, name: &str) -> LexicalCommand { - self.commands - .borrow_mut() - .entry(name.to_owned()) - .or_insert_with(|| create_command(name)) - .clone() - } -} - -/// The part of `lexical` an action needs that isn't ported yet, if any. -fn unported_action(action: &Value) -> Option<&'static str> { - match action.get("do").and_then(Value::as_str) { - Some("append" | "select" | "clearSelection" | "node" | "dispatch") => None, - Some("selection" | "selectAll" | "selectNodes" | "insertNodes") => { - Some("lexical (selection editing)") - } - Some("logCaretRange" | "removeTextFromCaretRange" | "logNormalizeCaret") => { - Some("lexical (carets)") - } - _ => Some("unknown"), - } -} - -fn unported(script: &Value) -> Option<&'static str> { - let actions = |v: Option<&Value>| -> Vec { - v.and_then(Value::as_array).cloned().unwrap_or_default() - }; - let steps = actions(script.get("steps")); - let handlers = actions(script.get("handlers")); - steps - .iter() - .flat_map(|s| actions(s.get("update"))) - .chain(handlers.iter().flat_map(|h| actions(h.get("actions")))) - .find_map(|a| unported_action(&a)) -} - -fn err(message: impl Into) -> LexicalError { - LexicalError::new(message) -} - -/// `$node(key)`. -fn node(key: &str) -> Result { - get_node_by_key(key).ok_or_else(|| err(format!("no node {key}"))) -} - -fn text(node: NodeRef) -> Result { - if node.is_text() { - Ok(node) - } else { - Err(err(format!("{} is not a text node", node.get_key()))) - } -} - -fn element(node: NodeRef) -> Result { - if node.is_element() { - Ok(node) - } else { - Err(err(format!("{} is not an element", node.get_key()))) - } -} - -/// `$parse(nodes)`. -fn parse(nodes: &Value) -> Result, LexicalError> { - nodes - .as_array() - .ok_or_else(|| err("expected an array of nodes"))? - .iter() - .map(parse_serialized_node) - .collect() -} - -/// `arg(args, i)`: the argument, or `null`. -fn arg(args: Option<&Value>, i: usize) -> Value { - args.and_then(Value::as_array) - .and_then(|a| a.get(i)) - .cloned() - .unwrap_or(Value::Null) -} - -fn number(v: &Value) -> Result { - v.as_f64() - .ok_or_else(|| err(format!("expected a number, got {v}"))) -} - -fn string(v: &Value) -> Result { - v.as_str() - .map(str::to_owned) - .ok_or_else(|| err(format!("expected a string, got {v}"))) -} - -/// An optional offset (`undefined` for `null`). -fn offset(v: &Value) -> Result, LexicalError> { - if v.is_null() { - Ok(None) - } else { - Ok(Some(number(v)? as usize)) - } -} - -fn flag(v: &Value, default: bool) -> bool { - v.as_bool().unwrap_or(default) -} - -fn point_type(v: &Value) -> Result { - match v.as_str() { - Some("text") => Ok(PointType::Text), - Some("element") => Ok(PointType::Element), - _ => Err(err(format!("bad point type {v}"))), - } -} - -/// `NODE_METHODS[method](node, args, ctx)`. -fn run_node_method( - n: NodeRef, - method: &str, - args: Option<&Value>, - ctx: &Ctx, -) -> Result<(), LexicalError> { - let a = |i| arg(args, i); - match method { - "append" => element(n)?.append(&parse(&a(0))?)?, - "clear" => element(n)?.clear()?, - "insertAfter" => { - n.insert_after(&parse_serialized_node(&a(0))?, flag(&a(1), true))?; - } - "insertBefore" => { - n.insert_before(&parse_serialized_node(&a(0))?, flag(&a(1), true))?; - } - "markDirty" => n.mark_dirty(), - "mergeWithSibling" => { - let target = text(node(&string(&a(0))?)?)?; - text(n)?.merge_with_sibling(&target)?; - } - "remove" => n.remove(flag(&a(0), false))?, - "replace" => { - n.replace(&parse_serialized_node(&a(0))?, flag(&a(1), false))?; - } - "select" => { - n.select(offset(&a(0))?, offset(&a(1))?)?; - } - "selectEnd" => { - n.select_end()?; - } - "selectNext" => { - n.select_next(offset(&a(0))?, offset(&a(1))?)?; - } - "selectPrevious" => { - n.select_previous(offset(&a(0))?, offset(&a(1))?)?; - } - "selectStart" => { - n.select_start()?; - } - "setDetail" => { - let n = text(n)?; - match a(0) { - Value::String(name) => n.set_detail_type(&name)?, - v => n.set_detail(number(&v)?)?, - } - } - "setDirection" => element(n)?.set_direction(match a(0).as_str() { - Some("ltr") => Some(Direction::Ltr), - Some("rtl") => Some(Direction::Rtl), - _ => None, - }), - "setFormat" if n.is_text() => match a(0) { - Value::String(name) => n.set_format_type(&name), - v => n.set_format(number(&v)?), - }, - "setFormat" => element(n)?.set_element_format(a(0).as_str().unwrap_or("")), - "setIndent" => element(n)?.set_indent(number(&a(0))?), - "setMode" => text(n)?.set_mode(&string(&a(0))?)?, - "setStyle" if n.is_text() => n.set_style(&string(&a(0))?), - "setStyle" => element(n)?.set_element_style(a(0).as_str().unwrap_or("")), - "setTextContent" => text(n)?.set_text_content(&string(&a(0))?), - "setTextFormat" => element(n)?.set_text_format(number(&a(0))?), - "setTextStyle" => element(n)?.set_text_style(&string(&a(0))?), - "splice" => element(n)?.splice( - number(&a(0))? as usize, - number(&a(1))? as usize, - &parse(&a(2))?, - )?, - "spliceText" => { - text(n)?.splice_text( - number(&a(0))? as i64, - number(&a(1))? as i64, - &string(&a(2))?, - flag(&a(3), false), - )?; - } - "splitText" => { - let offsets: Vec = args - .and_then(Value::as_array) - .map(|a| a.iter().map(|v| number(v).map(|n| n as i64)).collect()) - .transpose()? - .unwrap_or_default(); - let parts = text(n)?.split_text(&offsets)?; - ctx.log(vec![ - "splitText".into(), - Value::Array(parts.iter().map(|p| p.get_key().into()).collect()), - ]); - } - "toggleDirectionless" => text(n)?.toggle_directionless(), - "toggleFormat" => text(n)?.toggle_format(&string(&a(0))?), - "toggleUnmergeable" => text(n)?.toggle_unmergeable(), - _ => return Err(err(format!("unknown node method {method}"))), - } - Ok(()) -} - -/// `$runAction(action, ctx)`. -fn run_action(action: &Value, ctx: &Ctx, editor: &Editor) -> Result<(), LexicalError> { - match action.get("do").and_then(Value::as_str) { - Some("append") => { - let key = action.get("key").and_then(Value::as_str).unwrap_or(""); - element(node(key)?)?.append(&parse(action.get("nodes").unwrap_or(&Value::Null))?) - } - Some("select") => { - let selection = create_range_selection(); - let anchor = action.get("anchor").cloned().unwrap_or(Value::Null); - let focus = action - .get("focus") - .cloned() - .unwrap_or_else(|| anchor.clone()); - for (which, spec) in [(Which::Anchor, &anchor), (Which::Focus, &focus)] { - selection.point_set( - which, - &string(&arg(Some(spec), 0))?, - number(&arg(Some(spec), 1))? as usize, - point_type(&arg(Some(spec), 2))?, - )?; - } - set_selection(Some(selection)); - Ok(()) - } - Some("clearSelection") => { - set_selection(None); - Ok(()) - } - Some("node") => { - let key = action.get("key").and_then(Value::as_str).unwrap_or(""); - let method = action.get("method").and_then(Value::as_str).unwrap_or(""); - run_node_method(node(key)?, method, action.get("args"), ctx) - } - Some("dispatch") => { - let name = action.get("command").and_then(Value::as_str).unwrap_or(""); - let payload = action.get("payload").cloned().unwrap_or(Value::Null); - let handled = editor.dispatch_command(&ctx.command(name), payload)?; - ctx.log(vec!["dispatched".into(), name.into(), handled.into()]); - Ok(()) - } - other => Err(err(format!("unsupported action {other:?}"))), - } -} - -/// `$selectionSnapshot()`. -fn selection_snapshot() -> Value { - let Some(selection) = get_selection() else { - return Value::Null; - }; - let point = |which| { - let p = selection.point(which).expect("a range selection"); - Value::Array(vec![ - p.key.as_str().into(), - (p.offset as f64).into(), - p.point_type.as_str().into(), - ]) - }; - match selection.range() { - Some(range) => Value::Array(vec![ - "range".into(), - point(Which::Anchor), - point(Which::Focus), - range.format.into(), - range.style.as_str().into(), - ]), - None => { - let keys = selection.node_keys(); - Value::Array(vec![ - "node".into(), - Value::Array(keys.into_iter().map(Value::from).collect()), - ]) - } - } -} - -/// `TRANSFORMS[name](ctx)`. -fn register_transform(editor: &Editor, name: &'static str, ctx: &Rc) -> Result<(), String> { - let ctx = ctx.clone(); - let log = move |node: &NodeRef| { - ctx.log(vec!["transform".into(), name.into(), node.get_key().into()]); - }; - // Registered for the whole session; dropping the unregister function - // doesn't call it. - let _unregister = match name { - "paragraph:log" => editor.register_node_transform("paragraph", move |node| { - log(node); - Ok(()) - }), - "paragraph:remove-empty" => editor.register_node_transform("paragraph", move |node| { - log(node); - if node.is_empty() && node.get_next_sibling().is_some() { - node.remove(false)?; - } - Ok(()) - }), - "root:log" => editor.register_node_transform("root", move |node| { - log(node); - Ok(()) - }), - "text:bold-hash" => editor.register_node_transform("text", move |node| { - log(node); - if node.is_simple_text() - && node.get_text_content().starts_with('#') - && !node.has_format("bold") - { - node.toggle_format("bold"); - } - Ok(()) - }), - "text:split-hash" => editor.register_node_transform("text", move |node| { - log(node); - let content = node.get_text_content(); - // `indexOf`, in UTF-16 code units. - let index = content.find('#').map(|i| utf16::len(&content[..i])); - if node.is_simple_text() - && let Some(index) = index.filter(|&i| i > 0) - { - let parts = node.split_text(&[index as i64])?; - parts[1].toggle_unmergeable(); - } - Ok(()) - }), - "text:upper-a" => editor.register_node_transform("text", move |node| { - log(node); - let content = node.get_text_content(); - if content.contains('a') { - node.set_text_content(&content.replace('a', "A")); - } - Ok(()) - }), - _ => return Err(format!("unknown transform {name}")), - }; - Ok(()) -} - -/// The transform names `ops.ts` defines, as `'static` strings. -fn transform_name(name: &str) -> Option<&'static str> { - [ - "paragraph:log", - "paragraph:remove-empty", - "root:log", - "text:bold-hash", - "text:split-hash", - "text:upper-a", - ] - .into_iter() - .find(|n| *n == name) -} - -/// `startSession(script)`. -fn start_session(script: &Value) -> Result<(Editor, Rc), String> { - reset_random_key(); - let editor = create_editor(CreateEditorArgs::default()); - let ctx = Rc::new(Ctx::default()); - for name in script - .get("transforms") - .and_then(Value::as_array) - .into_iter() - .flatten() - { - let name = name.as_str().unwrap_or(""); - let name = transform_name(name).ok_or_else(|| format!("unknown transform {name}"))?; - register_transform(&editor, name, &ctx)?; - } - for handler in script - .get("handlers") - .and_then(Value::as_array) - .into_iter() - .flatten() - { - let id = handler.str("id")?.to_owned(); - let command = ctx.command(handler.str("command")?); - let priority = handler - .get("priority") - .and_then(Value::as_f64) - .map_or(COMMAND_PRIORITY_EDITOR, |p| p as u8); - let result = handler - .get("result") - .and_then(Value::as_bool) - .unwrap_or(false); - let actions = handler - .get("actions") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - let ctx = ctx.clone(); - let _unregister = editor.register_command( - &command, - move |payload: &Value, editor: &Editor| { - ctx.log(vec!["handler".into(), id.as_str().into(), payload.clone()]); - for action in &actions { - run_action(action, &ctx, editor)?; - } - Ok(result) - }, - priority, - ); - } - if script.get("listen").and_then(Value::as_bool) == Some(true) { - let log = ctx.clone(); - let _unregister = editor.register_update_listener(move |payload| { - let elements = payload - .dirty_elements - .iter() - .map(|(k, v)| Value::Array(vec![k.as_str().into(), (*v).into()])) - .collect(); - let leaves = payload - .dirty_leaves - .iter() - .map(|k| k.as_str().into()) - .collect(); - let mut tags = payload.tags.clone(); - tags.sort(); - log.log(vec![ - "update".into(), - Value::Array(elements), - Value::Array(leaves), - Value::Array(tags.into_iter().map(Value::from).collect()), - ]); - }); - // Mutation listeners only hear from the DOM reconciler, which a - // headless editor doesn't run, so JS logs no `mutation` entries. - let log = ctx.clone(); - let _unregister = editor.register_text_content_listener(move |text| { - log.log(vec!["textContent".into(), text.into()]); - }); - } - Ok((editor, ctx)) -} - -/// `session.run(step, snapshot)`, compared with what JS recorded. -fn run_step( - editor: &Editor, - ctx: &Rc, - step: &Value, - expected: &Value, - i: usize, -) -> Result<(), String> { - ctx.log.borrow_mut().clear(); - let outcome = if let Some(actions) = step.get("update").and_then(Value::as_array) { - let tags = step - .get("tag") - .and_then(Value::as_array) - .map(|t| { - t.iter() - .filter_map(|t| t.as_str().map(str::to_owned)) - .collect() - }) - .unwrap_or_default(); - let (actions, update_ctx, update_editor) = (actions.clone(), ctx.clone(), editor.clone()); - editor.update( - move || { - for action in &actions { - run_action(action, &update_ctx, &update_editor)?; - } - Ok(()) - }, - UpdateOptions { - discrete: true, - tags, - ..UpdateOptions::default() - }, - ) - } else { - let name = step.str("dispatch")?; - let payload = step.get("payload").cloned().unwrap_or(Value::Null); - editor - .dispatch_command(&ctx.command(name), payload) - .map(|handled| ctx.log(vec!["dispatched".into(), name.into(), handled.into()])) - }; - // A dispatch outside an update commits at the next read. - let (selection, text) = editor - .read(|| (selection_snapshot(), get_root().get_text_content())) - .map_err(|e| format!("step {i}: committing: {e}"))?; - let log = Value::Array(ctx.log.borrow().clone()); - - let at = |what: &str| format!("step {i} {what}"); - same( - at("error"), - outcome.as_ref().err().map(|e| e.to_string()).is_some(), - expected.get("error").is_some(), - ) - .map_err(|e| match &outcome { - Err(error) => format!("{e} ({error})"), - Ok(()) => e, - })?; - same(at("log"), &log, expected.field("log")?)?; - same(at("selection"), &selection, expected.field("selection")?)?; - same(at("text"), text.as_str(), expected.str("text")?)?; - if let Some(state) = expected.get("state").and_then(Value::as_str) { - let current = editor.get_editor_state(); - same( - at("state"), - current.to_json(false).to_string().as_str(), - state, - )?; - same( - at("compact"), - current.to_json(true).to_string().as_str(), - expected.str("compact")?, - )?; - } - Ok(()) -} - -pub fn check(case: &Value, _file: &Value) -> Result { - if let Some(part) = unported(case) { - return Ok(Outcome::NotPorted(part)); - } - let (editor, ctx) = start_session(case)?; - let steps = case.arr("steps")?; - let results = case.arr("results")?; - same("steps recorded", results.len(), steps.len())?; - for (i, (step, expected)) in steps.iter().zip(results).enumerate() { - run_step(&editor, &ctx, step, expected, i)?; - } - Ok(Outcome::Pass) -} diff --git a/rust/lexical-editor/src/caret.rs b/rust/lexical-editor/src/caret.rs new file mode 100644 index 00000000000..82d075c457e --- /dev/null +++ b/rust/lexical-editor/src/caret.rs @@ -0,0 +1,888 @@ +//! `caret/LexicalCaret.ts`: carets, caret ranges, text slices and the +//! document order of carets. +//! +//! JS has one class per caret type and direction (`SiblingCaretNext`, +//! `ChildCaretLast`, `TextPointCaretPrevious`, ...). Here one [`PointCaret`] +//! value carries its [`CaretKind`], the JS `type` (with the offset of a text +//! point caret), and its [`CaretDirection`]; [`SiblingCaret`], +//! [`ChildCaret`], [`TextPointCaret`] and [`NodeCaret`] are aliases of it +//! that say which kinds a function takes or returns, as the JS types do. +//! +//! A caret's origin is a [`NodeRef`], which always resolves the latest +//! version of the node, so `getLatest()` returns the same caret. Slots are +//! not ported, so no node is a slot value and `$filterByMode` only stops at +//! roots. + +use crate::LexicalError; +use crate::error::invariant; +use crate::node::{NodeKey, NodeRef}; +use crate::nodes::element::is_root_or_shadow_root; +use crate::utf16; +use crate::utils::get_root; +use std::cmp::Ordering; +use std::collections::HashMap; + +/// `CaretDirection`: `'next'` points towards the end of the document, +/// `'previous'` towards the beginning. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CaretDirection { + Next, + Previous, +} + +impl CaretDirection { + /// The JS name: `"next"` or `"previous"`. + pub fn as_str(self) -> &'static str { + match self { + CaretDirection::Next => "next", + CaretDirection::Previous => "previous", + } + } +} + +/// `flipDirection(direction)`. +pub fn flip_direction(direction: CaretDirection) -> CaretDirection { + match direction { + CaretDirection::Next => CaretDirection::Previous, + CaretDirection::Previous => CaretDirection::Next, + } +} + +/// `RootMode`: whether a traversal up the tree stops at the root only +/// (`'root'`), or at the root and any shadow root (`'shadowRoot'`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RootMode { + #[default] + Root, + ShadowRoot, +} + +/// A caret's JS `type`, with the offset a text point caret carries. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CaretKind { + /// `'sibling'`: from the origin towards its next or previous sibling. + Sibling, + /// `'child'`: from an element origin towards its first (`next`) or + /// last (`previous`) child. + Child, + /// `'text'`: at `offset` (UTF-16 code units) in a text node origin. + Text { offset: usize }, +} + +/// `PointCaret`: a [`NodeCaret`] or a [`TextPointCaret`]. +/// +/// Two carets are `==` when JS's `isSamePointCaret` holds: same kind, +/// direction, origin (by key) and offset. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct PointCaret { + origin: NodeRef, + direction: CaretDirection, + kind: CaretKind, +} + +/// `SiblingCaret`: a [`PointCaret`] whose kind is [`CaretKind::Sibling`]. +pub type SiblingCaret = PointCaret; +/// `ChildCaret`: a [`PointCaret`] whose kind is [`CaretKind::Child`]. +pub type ChildCaret = PointCaret; +/// `TextPointCaret`: a [`PointCaret`] whose kind is [`CaretKind::Text`]. +pub type TextPointCaret = PointCaret; +/// `NodeCaret`: a sibling or child caret. +pub type NodeCaret = PointCaret; + +/// `$filterByMode(node, mode)`: `None` at a root according to `mode`. +fn filter_by_mode(node: Option, mode: RootMode) -> Option { + let node = node?; + let is_boundary = match mode { + RootMode::Root => node.is_root(), + RootMode::ShadowRoot => is_root_or_shadow_root(&*node.get_latest()), + }; + // A slot value is a boundary too, once slots are ported. + (!is_boundary).then_some(node) +} + +impl PointCaret { + /// `caret.origin`. + pub fn origin(&self) -> &NodeRef { + &self.origin + } + + /// `caret.direction`. + pub fn direction(&self) -> CaretDirection { + self.direction + } + + pub fn kind(&self) -> CaretKind { + self.kind + } + + /// `caret.type`: `"sibling"`, `"child"` or `"text"`. + pub fn caret_type(&self) -> &'static str { + match self.kind { + CaretKind::Sibling => "sibling", + CaretKind::Child => "child", + CaretKind::Text { .. } => "text", + } + } + + /// `caret.offset` of a text point caret. + pub fn offset(&self) -> Option { + match self.kind { + CaretKind::Text { offset } => Some(offset), + _ => None, + } + } + + /// `$isTextPointCaret(caret)`. + pub fn is_text_point_caret(&self) -> bool { + matches!(self.kind, CaretKind::Text { .. }) + } + + /// `$isSiblingCaret(caret)`: a sibling caret, not a text point caret. + pub fn is_sibling_caret(&self) -> bool { + self.kind == CaretKind::Sibling + } + + /// `$isChildCaret(caret)`. + pub fn is_child_caret(&self) -> bool { + self.kind == CaretKind::Child + } + + /// `getLatest()`: the origin always resolves its latest version, so + /// this is the same caret. + pub fn get_latest(&self) -> PointCaret { + self.clone() + } + + /// `getNodeAtCaret()`: the node next to the origin in the caret's + /// direction (the first or last child for a child caret). + pub fn get_node_at_caret(&self) -> Option { + match (self.kind, self.direction) { + (CaretKind::Child, CaretDirection::Next) => self.origin.get_first_child(), + (CaretKind::Child, CaretDirection::Previous) => self.origin.get_last_child(), + (_, CaretDirection::Next) => self.origin.get_next_sibling(), + (_, CaretDirection::Previous) => self.origin.get_previous_sibling(), + } + } + + /// `getParentAtCaret()`: the origin for a child caret, the origin's + /// parent otherwise. + pub fn get_parent_at_caret(&self) -> Option { + match self.kind { + CaretKind::Child => Some(self.origin.clone()), + _ => self.origin.get_parent(), + } + } + + /// `getAdjacentCaret()`: the sibling caret of the node at the caret. + pub fn get_adjacent_caret(&self) -> Option { + self.get_node_at_caret() + .map(|node| get_sibling_caret(&node, self.direction)) + } + + /// `getSiblingCaret()`: a sibling caret with the same origin and + /// direction. + pub fn get_sibling_caret(&self) -> SiblingCaret { + match self.kind { + CaretKind::Sibling => self.clone(), + _ => get_sibling_caret(&self.origin, self.direction), + } + } + + /// `getChildCaret()`: this caret for a child caret, the child caret of + /// an element origin for a sibling caret, `None` otherwise. + pub fn get_child_caret(&self) -> Option { + match self.kind { + CaretKind::Child => Some(self.clone()), + CaretKind::Sibling => get_child_caret(&self.origin, self.direction), + CaretKind::Text { .. } => None, + } + } + + /// `getParentCaret(mode)`: the sibling caret of the parent at the caret + /// in the same direction, `None` at a root according to `mode`. + pub fn get_parent_caret(&self, mode: RootMode) -> Option { + filter_by_mode(self.get_parent_at_caret(), mode) + .map(|parent| get_sibling_caret(&parent, self.direction)) + } + + /// `getFlipped()`: the same position facing the other way, so that + /// flipping twice is the identity. + pub fn get_flipped(&self) -> Result { + let dir = flip_direction(self.direction); + Ok(match self.kind { + CaretKind::Text { offset } => PointCaret { + origin: self.origin.clone(), + direction: dir, + kind: CaretKind::Text { offset }, + }, + CaretKind::Child => match self.get_node_at_caret() { + Some(node) => get_sibling_caret(&node, dir), + None => new_child_caret(&self.origin, dir), + }, + CaretKind::Sibling => match self.get_node_at_caret() { + Some(node) => get_sibling_caret(&node, dir), + None => new_child_caret(&self.origin.get_parent_or_throw()?, dir), + }, + }) + } + + /// `isSameNodeCaret(other)`: same direction and origin, where a sibling + /// caret and a text point caret count as the same. + pub fn is_same_node_caret(&self, other: &PointCaret) -> bool { + let same_kind = match self.kind { + CaretKind::Child => other.is_child_caret(), + _ => !other.is_child_caret(), + }; + same_kind && self.direction == other.direction && self.origin == other.origin + } + + /// `isSamePointCaret(other)`: the same caret (also `==`). + pub fn is_same_point_caret(&self, other: &PointCaret) -> bool { + self == other + } + + /// `[Symbol.iterator]()`: the sibling carets of the nodes after the + /// caret, in its direction. + pub fn iter(&self) -> impl Iterator + use<> { + make_stepwise_iterator( + self.get_adjacent_caret(), + |caret: &Option| caret.as_ref().is_some_and(PointCaret::is_sibling_caret), + |caret| caret.and_then(|c| c.get_adjacent_caret()), + |caret| caret.clone().expect("checked by has_next"), + ) + } + + /// `remove()`: removes the node at the caret, if any. + pub fn remove(&self) -> Result<(), LexicalError> { + if let Some(node) = self.get_node_at_caret() { + node.remove(false)?; + } + Ok(()) + } + + /// `insert(node)`: inserts `node` next to the origin in the caret's + /// direction (before the node at the caret). + pub fn insert(&self, node: &NodeRef) -> Result<(), LexicalError> { + match (self.kind, self.direction) { + (CaretKind::Child, CaretDirection::Next) => { + self.origin.splice(0, 0, std::slice::from_ref(node)) + } + (CaretKind::Child, CaretDirection::Previous) => self.origin.splice( + self.origin.get_children_size(), + 0, + std::slice::from_ref(node), + ), + (_, CaretDirection::Next) => self.origin.insert_after(node, true).map(drop), + (_, CaretDirection::Previous) => self.origin.insert_before(node, true).map(drop), + } + } + + /// `replaceOrInsert(node, includeChildren)`: replaces the node at the + /// caret with `node`, or inserts it if there is none. + pub fn replace_or_insert( + &self, + node: &NodeRef, + include_children: bool, + ) -> Result<(), LexicalError> { + let target = self.get_node_at_caret(); + if node == &self.origin || Some(node) == target.as_ref() { + // do nothing + } else if let Some(target) = target { + target.replace(node, include_children)?; + } else { + self.insert(node)?; + } + Ok(()) + } + + /// `splice(deleteCount, nodes, nodesDirection)`: replaces the + /// `delete_count` nodes after the caret with `nodes`, which are in + /// `nodes_direction` order (JS's default is `next`). + pub fn splice( + &self, + delete_count: usize, + nodes: &[NodeRef], + nodes_direction: CaretDirection, + ) -> Result<(), LexicalError> { + let mut node_iter = nodes.to_vec(); + if nodes_direction != self.direction { + node_iter.reverse(); + } + let mut caret = self.clone(); + let parent = self.get_parent_at_caret(); + // Insertion-ordered, like JS's Map. + let mut nodes_to_remove: Vec = Vec::new(); + // Find all of the nodes we expect to remove first, so we don't have + // to worry about the cases where there is overlap between the nodes + // to insert and the nodes to remove + let mut remove_caret = caret.get_adjacent_caret(); + while let Some(rc) = remove_caret.filter(|_| nodes_to_remove.len() < delete_count) { + crate::utils::get_writable(rc.origin.get_key()); + if !nodes_to_remove.contains(&rc.origin) { + nodes_to_remove.push(rc.origin.clone()); + } + remove_caret = rc.get_adjacent_caret(); + } + for node in &node_iter { + if !nodes_to_remove.is_empty() { + let Some(target) = caret.get_node_at_caret() else { + let keys: Vec<&str> = nodes_to_remove.iter().map(NodeRef::get_key).collect(); + return Err(LexicalError::new(format!( + "NodeCaret.splice: Underflow of expected nodesToRemove during splice (keys: {})", + keys.join(" ") + ))); + }; + nodes_to_remove.retain(|n| n != &target && n != node); + if &target == node || &caret.origin == node { + // do nothing, it's already in the right place + } else { + if node + .get_parent() + .is_some_and(|p| Some(&p) == parent.as_ref()) + { + // It's a sibling somewhere else in this node, so + // unparent it first + node.remove(false)?; + } + target.replace(node, false)?; + } + } else { + caret.insert(node)?; + } + caret = get_sibling_caret(node, self.direction); + } + for node in &nodes_to_remove { + node.remove(false)?; + } + Ok(()) + } +} + +fn new_child_caret(origin: &NodeRef, direction: CaretDirection) -> ChildCaret { + PointCaret { + origin: origin.clone(), + direction, + kind: CaretKind::Child, + } +} + +/// `$getSiblingCaret(origin, direction)`: a caret pointing from `origin` to +/// its next or previous sibling. JS returns `null` for a `null` origin; +/// here that is `Option::map`. +pub fn get_sibling_caret(origin: &NodeRef, direction: CaretDirection) -> SiblingCaret { + PointCaret { + origin: origin.clone(), + direction, + kind: CaretKind::Sibling, + } +} + +/// An offset into a text node: a number of UTF-16 code units, or the end +/// in a direction (`'next'` is the end, `'previous'` the start), as JS's +/// `number | CaretDirection`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TextOffset { + Offset(usize), + End(CaretDirection), +} + +impl From for TextOffset { + fn from(offset: usize) -> Self { + TextOffset::Offset(offset) + } +} + +impl From for TextOffset { + fn from(direction: CaretDirection) -> Self { + TextOffset::End(direction) + } +} + +/// `$getTextPointCaret(origin, direction, offset)`: a caret at `offset` in +/// the text node `origin`. An offset past the end is an error, as in JS's +/// development build. +pub fn get_text_point_caret( + origin: &NodeRef, + direction: CaretDirection, + offset: impl Into, +) -> Result { + Ok(PointCaret { + origin: origin.clone(), + direction, + kind: CaretKind::Text { + offset: get_text_node_offset(origin, offset.into(), false)?, + }, + }) +} + +/// [`get_text_point_caret`] at an end of the text, which can't fail. +pub(crate) fn get_text_point_caret_at_end( + origin: &NodeRef, + direction: CaretDirection, + end: CaretDirection, +) -> TextPointCaret { + get_text_point_caret(origin, direction, end).expect("an end is always a valid offset") +} + +/// `$getTextNodeOffset(origin, offset, mode)`: the numeric offset of +/// `offset` in `origin`. Out of bounds it is an error (JS's development +/// build), or clamped when `clamp` (`mode` `'clamp'`). +pub fn get_text_node_offset( + origin: &NodeRef, + offset: TextOffset, + clamp: bool, +) -> Result { + let size = origin.get_text_content_size(); + let numeric_offset = match offset { + TextOffset::End(CaretDirection::Next) => size, + TextOffset::End(CaretDirection::Previous) => 0, + TextOffset::Offset(offset) => offset, + }; + if numeric_offset > size { + invariant!( + clamp, + "$getTextNodeOffset: invalid offset {numeric_offset} for size {size} at key {}", + origin.get_key() + ); + return Ok(size); + } + Ok(numeric_offset) +} + +/// `TextPointCaretSlice`: a text point caret and a signed distance from its +/// offset. A negative distance selects the text before the offset, a +/// positive one the text after it, whatever the caret's direction. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TextPointCaretSlice { + pub caret: TextPointCaret, + pub distance: isize, +} + +impl TextPointCaretSlice { + fn offset(&self) -> usize { + self.caret.offset().expect("a text point caret") + } + + /// `getSliceIndices()`: the start and end of the slice in the text. + pub fn get_slice_indices(&self) -> (usize, usize) { + let offset = self.offset(); + let offset_b = offset.saturating_add_signed(self.distance); + if offset_b < offset { + (offset_b, offset) + } else { + (offset, offset_b) + } + } + + /// `getTextContent()`: the text of the slice. + pub fn get_text_content(&self) -> String { + let (start, end) = self.get_slice_indices(); + utf16::slice(&self.caret.origin.get_text_content(), start, end) + } + + /// `getTextContentSize()`. + pub fn get_text_content_size(&self) -> usize { + self.distance.unsigned_abs() + } + + /// `removeTextSlice()`: removes the slice's text, and returns the caret + /// at its start, in the same direction. + pub fn remove_text_slice(&self) -> Result { + let origin = &self.caret.origin; + let (start, end) = self.get_slice_indices(); + // `$setTextContentSpliced`: text edits aren't recorded in this port. + let content = origin.get_text_content(); + let next = utf16::slice(&content, 0, start) + &utf16::slice_from(&content, end); + origin.set_text_content(&next); + get_text_point_caret(origin, self.caret.direction, start) + } +} + +/// `$getTextPointCaretSlice(caret, distance)`. +pub fn get_text_point_caret_slice(caret: TextPointCaret, distance: isize) -> TextPointCaretSlice { + debug_assert!(caret.is_text_point_caret()); + TextPointCaretSlice { caret, distance } +} + +/// `$getChildCaret(origin, direction)`: a caret pointing to the first +/// (`next`) or last (`previous`) child of `origin`, or `None` if it isn't +/// an element. +pub fn get_child_caret(origin: &NodeRef, direction: CaretDirection) -> Option { + origin + .is_element() + .then(|| new_child_caret(origin, direction)) +} + +/// `$getChildCaretOrSelf(caret)`: the caret's child caret if it has one, +/// otherwise the caret. +pub fn get_child_caret_or_self(caret: Option) -> Option { + caret.map(|c| c.get_child_caret().unwrap_or(c)) +} + +/// `$getAdjacentChildCaret(caret)`: the adjacent caret, or its child caret +/// when its origin is an element. With `getParentCaret` this walks the +/// whole tree depth first. +pub fn get_adjacent_child_caret(caret: Option<&NodeCaret>) -> Option { + get_child_caret_or_self(caret?.get_adjacent_caret()) +} + +/// `CaretRange`: a range selection as a pair of carets in one direction. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CaretRange { + pub anchor: PointCaret, + pub focus: PointCaret, + direction: CaretDirection, +} + +impl CaretRange { + /// `range.direction`. + pub fn direction(&self) -> CaretDirection { + self.direction + } + + /// `getLatest()`: the same range, as carets always resolve the latest + /// nodes. + pub fn get_latest(&self) -> CaretRange { + self.clone() + } + + /// `isCollapsed()`: whether anchor and focus are the same caret. + pub fn is_collapsed(&self) -> bool { + self.anchor.is_same_point_caret(&self.focus) + } + + /// `getTextSlices()`: the slices of the text point carets at the anchor + /// and the focus. When both are on one text node, the first holds the + /// whole slice and the second is `None`. + pub fn get_text_slices(&self) -> [Option; 2] { + let anchor = self.anchor.get_latest(); + let focus = self.focus.get_latest(); + if let (Some(anchor_offset), Some(focus_offset)) = (anchor.offset(), focus.offset()) + && anchor.is_same_node_caret(&focus) + { + let distance = focus_offset as isize - anchor_offset as isize; + return [Some(get_text_point_caret_slice(anchor, distance)), None]; + } + [ + anchor + .is_text_point_caret() + .then(|| get_slice_from_text_point_caret(anchor, false)), + focus + .is_text_point_caret() + .then(|| get_slice_from_text_point_caret(focus, true)), + ] + } + + /// `iterNodeCarets(rootMode)`: the carets between anchor and focus, in + /// pre-order, without the text slices at either end. An element is + /// yielded as a child caret on enter and a sibling caret on leave. + pub fn iter_node_carets(&self, root_mode: RootMode) -> impl Iterator + use<> { + let anchor = if self.anchor.is_text_point_caret() { + self.anchor.get_sibling_caret() + } else { + self.anchor.get_latest() + }; + let focus = self.focus.get_latest(); + let is_text_focus = focus.is_text_point_caret(); + let step = { + let focus = focus.clone(); + move |state: &NodeCaret| -> Option { + if state.is_same_node_caret(&focus) { + None + } else { + get_adjacent_child_caret(Some(state)) + .or_else(|| state.get_parent_caret(root_mode)) + } + } + }; + let initial = if anchor.is_same_node_caret(&focus) { + None + } else { + step(&anchor) + }; + make_stepwise_iterator( + initial, + move |state: &Option| { + state + .as_ref() + .is_some_and(|s| !(is_text_focus && focus.is_same_node_caret(s))) + }, + move |state| state.and_then(|s| step(&s)), + |state| state.clone().expect("checked by has_next"), + ) + } + + /// `[Symbol.iterator]()`: `iterNodeCarets('root')`. + pub fn iter(&self) -> impl Iterator + use<> { + self.iter_node_carets(RootMode::Root) + } +} + +/// `$getSliceFromTextPointCaret(caret, anchorOrFocus)`: the slice from the +/// caret to the end of its text the range covers. +fn get_slice_from_text_point_caret(caret: TextPointCaret, is_focus: bool) -> TextPointCaretSlice { + let direction = caret.direction; + let end = if is_focus { + flip_direction(direction) + } else { + direction + }; + let offset_b = get_text_node_offset(&caret.origin, end.into(), false) + .expect("an end is always a valid offset"); + let offset = caret.offset().expect("a text point caret"); + get_text_point_caret_slice(caret, offset_b as isize - offset as isize) +} + +/// `$getCaretInDirection(caret, direction)`: the caret if it is in +/// `direction`, otherwise `caret.getFlipped()`. +pub fn get_caret_in_direction( + caret: &PointCaret, + direction: CaretDirection, +) -> Result { + if caret.direction == direction { + Ok(caret.clone()) + } else { + caret.get_flipped() + } +} + +/// `$extendCaretToRange(anchor)`: a range from `anchor` to the end of the +/// document in its direction. +pub fn extend_caret_to_range(anchor: &PointCaret) -> Result { + let root_caret = new_child_caret(&get_root(), flip_direction(anchor.direction)); + get_caret_range( + anchor.clone(), + get_caret_in_direction(&root_caret, anchor.direction)?, + ) +} + +/// `$getCollapsedCaretRange(anchor)`. +pub fn get_collapsed_caret_range(anchor: PointCaret) -> CaretRange { + CaretRange { + anchor: anchor.clone(), + focus: anchor.clone(), + direction: anchor.direction, + } +} + +/// `$getCaretRange(anchor, focus)`: a range between carets in the same +/// direction, where the anchor points towards the focus. +pub fn get_caret_range(anchor: PointCaret, focus: PointCaret) -> Result { + invariant!( + anchor.direction == focus.direction, + "$getCaretRange: anchor and focus must be in the same direction" + ); + let direction = anchor.direction; + Ok(CaretRange { + anchor, + focus, + direction, + }) +} + +/// `makeStepwiseIterator({initial, hasNext, step, map})`: yields +/// `map(state)` while `has_next(state)`, stepping the state after each. +pub fn make_stepwise_iterator( + initial: State, + has_next: impl FnMut(&State) -> bool, + step: impl FnMut(State) -> State, + map: impl FnMut(&State) -> Value, +) -> impl Iterator { + StepwiseIterator { + state: Some(initial), + has_next, + step, + map, + } +} + +struct StepwiseIterator { + // Only `None` while stepping. + state: Option, + has_next: H, + step: S, + map: M, +} + +impl Iterator for StepwiseIterator +where + H: FnMut(&State) -> bool, + S: FnMut(State) -> State, + M: FnMut(&State) -> Value, +{ + type Item = Value; + + fn next(&mut self) -> Option { + let state = self.state.as_ref()?; + if !(self.has_next)(state) { + return None; + } + let value = (self.map)(state); + let state = self.state.take().expect("checked above"); + self.state = Some((self.step)(state)); + Some(value) + } +} + +/// `$comparePointCaretNext(a, b)`: the total order of `next` carets in +/// which a [`CaretRange`] iterates them. For one origin, a child caret and +/// a text point caret come before the sibling caret. Carets whose origins +/// have no common ancestor are an error. +pub fn compare_point_caret_next(a: &PointCaret, b: &PointCaret) -> Result { + let compare = get_common_ancestor(&a.origin, &b.origin); + let Some(compare) = compare else { + return Err(LexicalError::new(format!( + "$comparePointCaretNext: a (key {}) and b (key {}) do not have a common ancestor", + a.origin.get_key(), + b.origin.get_key() + ))); + }; + Ok(match compare { + CommonAncestorResult::Same { .. } => compare_same_origin_carets(a, b), + CommonAncestorResult::Ancestor { .. } => { + if a.is_child_caret() { + Ordering::Less + } else { + Ordering::Greater + } + } + CommonAncestorResult::Descendant { .. } => { + if b.is_child_caret() { + Ordering::Greater + } else { + Ordering::Less + } + } + CommonAncestorResult::Branch { a, b, .. } => { + get_common_ancestor_result_branch_order(&a, &b) + } + }) +} + +/// `$compareSameOriginCarets(a, b)`. +fn compare_same_origin_carets(a: &PointCaret, b: &PointCaret) -> Ordering { + match (a.kind, b.kind) { + (CaretKind::Text { offset: x }, CaretKind::Text { offset: y }) => x.cmp(&y), + (x, y) if x == y => Ordering::Equal, + (CaretKind::Text { .. }, _) => Ordering::Less, + (_, CaretKind::Text { .. }) => Ordering::Greater, + (CaretKind::Child, _) => Ordering::Less, + _ => Ordering::Greater, + } +} + +/// `$getCommonAncestorResultBranchOrder(compare)`: `Less` if `a` (a sibling +/// of `b`) comes first, `Greater` otherwise. +pub fn get_common_ancestor_result_branch_order(a: &NodeRef, b: &NodeRef) -> Ordering { + let mut na = Some(a.clone()); + let mut nb = Some(b.clone()); + while let (Some(x), Some(y)) = (&na, &nb) { + if x == b { + return Ordering::Less; + } else if y == a { + return Ordering::Greater; + } + na = x.get_next_sibling(); + nb = y.get_next_sibling(); + } + if na.is_none() { + Ordering::Greater + } else { + Ordering::Less + } +} + +/// `CommonAncestorResult`: how two nodes relate through their common +/// ancestor. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CommonAncestorResult { + /// The two nodes are the same. + Same { common_ancestor: NodeRef }, + /// `a` is an ancestor of `b`. + Ancestor { common_ancestor: NodeRef }, + /// `a` is a descendant of `b`. + Descendant { common_ancestor: NodeRef }, + /// `a` and `b` are on different branches; `a` and `b` here are their + /// ancestors (or themselves) that are children of `common_ancestor`. + Branch { + common_ancestor: NodeRef, + a: NodeRef, + b: NodeRef, + }, +} + +impl CommonAncestorResult { + /// `result.commonAncestor`. + pub fn common_ancestor(&self) -> &NodeRef { + match self { + CommonAncestorResult::Same { common_ancestor } + | CommonAncestorResult::Ancestor { common_ancestor } + | CommonAncestorResult::Descendant { common_ancestor } + | CommonAncestorResult::Branch { + common_ancestor, .. + } => common_ancestor, + } + } +} + +/// `$initialElementTuple(node)`: the node and no child for an element, its +/// parent and itself otherwise. +fn initial_element_tuple(node: &NodeRef) -> (Option, Option) { + if node.is_element() { + (Some(node.clone()), None) + } else { + (node.get_parent(), Some(node.clone())) + } +} + +/// `$getCommonAncestor(a, b)`: how `a` and `b` relate, or `None` if they +/// have no common ancestor. +pub fn get_common_ancestor(a: &NodeRef, b: &NodeRef) -> Option { + if a == b { + return Some(CommonAncestorResult::Same { + common_ancestor: a.clone(), + }); + } + // Map of parent -> child entries based on a and its ancestors + let mut a_map: HashMap> = HashMap::new(); + let (mut parent, mut child) = initial_element_tuple(a); + while let Some(p) = parent { + parent = p.get_parent(); + a_map.insert(p.get_key().to_owned(), child); + child = Some(p); + } + let (mut parent, mut child) = initial_element_tuple(b); + while let Some(p) = parent { + if let Some(a_child) = a_map.get(p.get_key()) { + return Some(match (a_child, child) { + (None, _) => { + // a is the ancestor + assert!(&p == a, "$originComparison: ancestor logic error"); + CommonAncestorResult::Ancestor { common_ancestor: p } + } + (Some(_), None) => { + // b is the ancestor + assert!(&p == b, "$originComparison: descendant logic error"); + CommonAncestorResult::Descendant { common_ancestor: p } + } + (Some(a_child), Some(child)) => { + assert!( + (a_child.is_element() || a_child == a) + && (child.is_element() || &child == b) + && a_child.get_parent().as_ref() == Some(&p) + && child.get_parent().as_ref() == Some(&p), + "$originComparison: branch logic error" + ); + CommonAncestorResult::Branch { + common_ancestor: p, + a: a_child.clone(), + b: child, + } + } + }); + } + parent = p.get_parent(); + child = Some(p); + } + None +} diff --git a/rust/lexical-editor/src/caret_utils.rs b/rust/lexical-editor/src/caret_utils.rs new file mode 100644 index 00000000000..e20ebcaa7b9 --- /dev/null +++ b/rust/lexical-editor/src/caret_utils.rs @@ -0,0 +1,569 @@ +//! `caret/LexicalCaretUtils.ts`: carets from selection points and back, +//! normalization, and removing the text and nodes of a caret range. +//! +//! Slots, NodeState and the text edit log are not ported, so the parts of +//! `$removeTextFromCaretRange` that keep a slot host, carry NodeState to a +//! replacement node or record deletions do nothing here. + +use crate::LexicalError; +use crate::caret::{ + CaretDirection, CaretRange, CommonAncestorResult, NodeCaret, PointCaret, RootMode, + SiblingCaret, TextOffset, TextPointCaretSlice, compare_point_caret_next, flip_direction, + get_adjacent_child_caret, get_caret_in_direction, get_caret_range, get_child_caret, + get_collapsed_caret_range, get_common_ancestor, get_sibling_caret, get_text_node_offset, + get_text_point_caret, get_text_point_caret_at_end, +}; +use crate::error::invariant; +use crate::node::{NodeKey, NodeRef}; +use crate::nodes::element::is_root_or_shadow_root; +use crate::nodes::text::create_text_node; +use crate::selection::{ + Point, PointType, RangeSelection, SelectionRef, Which, create_range_selection, get_selection, + set_selection, +}; +use crate::utils::{ + get_node_by_key_or_throw, get_root, is_block, is_shadow_root_node, remove_from_parent, + restore_empty_container_paragraph, +}; +use std::collections::HashSet; + +/// `$caretFromPoint(point, direction)`: the caret at a selection point. +pub fn caret_from_point( + point: &Point, + direction: CaretDirection, +) -> Result { + let node = get_node_by_key_or_throw(&point.key)?; + if point.point_type == PointType::Text { + invariant!( + node.is_text(), + "$caretFromPoint: Node with type {} and key {} that does not inherit from TextNode encountered for text point", + node.get_type(), + point.key + ); + return get_text_point_caret(&node, direction, point.offset); + } + invariant!( + node.is_element(), + "$caretFromPoint: Node with type {} and key {} that does not inherit from ElementNode encountered for element point", + node.get_type(), + point.key + ); + Ok(get_child_caret_at_index(&node, point.offset, direction)) +} + +/// `$setPointFromCaret(point, caret)`: sets the point `which` of a range +/// selection to the caret's position. +pub fn set_point_from_caret( + selection: &SelectionRef, + which: Which, + caret: &PointCaret, +) -> Result<(), LexicalError> { + let origin = caret.origin(); + let direction = caret.direction(); + let is_next = direction == CaretDirection::Next; + if let Some(offset) = caret.offset() { + selection.point_set(which, origin.get_key(), offset, PointType::Text) + } else if caret.is_sibling_caret() { + if origin.is_text() { + let offset = get_text_node_offset(origin, direction.into(), false)?; + selection.point_set(which, origin.get_key(), offset, PointType::Text) + } else { + let parent = origin.get_parent_or_throw()?; + let index = origin + .get_index_within_parent() + .expect("a child of its parent"); + selection.point_set( + which, + parent.get_key(), + index + usize::from(is_next), + PointType::Element, + ) + } + } else { + assert!( + caret.is_child_caret() && origin.is_element(), + "$setPointFromCaret: exhaustiveness check" + ); + let offset = if is_next { + 0 + } else { + origin.get_children_size() + }; + selection.point_set(which, origin.get_key(), offset, PointType::Element) + } +} + +/// `$setSelectionFromCaretRange(caretRange)`: sets the editor's range +/// selection (the current one, or a new one) to the range, and returns it. +pub fn set_selection_from_caret_range( + caret_range: &CaretRange, +) -> Result { + let selection = get_selection() + .filter(SelectionRef::is_range) + .unwrap_or_else(create_range_selection); + update_range_selection_from_caret_range(&selection, caret_range)?; + set_selection(Some(selection.clone())); + Ok(selection) +} + +/// `$updateRangeSelectionFromCaretRange(selection, caretRange)`. +pub fn update_range_selection_from_caret_range( + selection: &SelectionRef, + caret_range: &CaretRange, +) -> Result<(), LexicalError> { + set_point_from_caret(selection, Which::Anchor, &caret_range.anchor)?; + set_point_from_caret(selection, Which::Focus, &caret_range.focus) +} + +/// `$caretRangeFromSelection(selection)`: the carets of a range selection, +/// in the `previous` direction when the focus is before the anchor. +pub fn caret_range_from_selection(selection: &RangeSelection) -> Result { + let anchor_caret = caret_from_point(&selection.anchor, CaretDirection::Next)?; + let focus_caret = caret_from_point(&selection.focus, CaretDirection::Next)?; + let direction = if compare_point_caret_next(&anchor_caret, &focus_caret)?.is_le() { + CaretDirection::Next + } else { + CaretDirection::Previous + }; + get_caret_range( + get_caret_in_direction(&anchor_caret, direction)?, + get_caret_in_direction(&focus_caret, direction)?, + ) +} + +/// `$rewindSiblingCaret(caret)`: the caret, in the same direction, whose +/// adjacent caret is `caret`. +pub fn rewind_sibling_caret(caret: &SiblingCaret) -> Result { + let direction = caret.direction(); + let origin = caret.origin(); + // Rotate the direction around the origin and get the adjacent node + let rewind_origin = get_sibling_caret(origin, flip_direction(direction)).get_node_at_caret(); + Ok(match rewind_origin { + Some(rewind_origin) => get_sibling_caret(&rewind_origin, direction), + None => get_child_caret(&origin.get_parent_or_throw()?, direction) + .expect("a parent is an element"), + }) +} + +/// `$getAnchorCandidates(anchor, rootMode)`: the anchor, the caret pointing +/// to it, and those pointing to each of its parents. +fn get_anchor_candidates( + anchor: &PointCaret, + root_mode: RootMode, +) -> Result, LexicalError> { + // These candidates will be the anchor itself, the pointer to the anchor + // (if different), and then any parents of that + let mut carets = vec![anchor.clone()]; + let mut parent = if anchor.is_child_caret() { + anchor.get_parent_caret(root_mode) + } else { + Some(anchor.get_sibling_caret()) + }; + while let Some(p) = parent { + carets.push(rewind_sibling_caret(&p)?); + parent = p.get_parent_caret(root_mode); + } + Ok(carets) +} + +/// `$isCaretAttached(caret)`. +fn is_caret_attached(caret: &PointCaret) -> bool { + caret.origin().is_attached() +} + +/// The `sliceMode` of [`remove_text_from_caret_range`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SliceMode { + /// `'removeEmptySlices'`: a text node whose text is all removed is + /// removed. + #[default] + RemoveEmptySlices, + /// `'preserveEmptyTextSliceCaret'`: the first such text node is kept, + /// empty, for an insertion. + PreserveEmptyTextSliceCaret, +} + +/// `$removeTextFromCaretRange(initialRange, sliceMode)`: removes the text +/// and nodes in the range, merging the rest of the later block into the +/// earlier one when the range spans blocks. Returns the collapsed range +/// where the removal was, in the initial range's direction. +pub fn remove_text_from_caret_range( + initial_range: &CaretRange, + slice_mode: SliceMode, +) -> Result { + if initial_range.is_collapsed() { + return Ok(initial_range.clone()); + } + // Always process removals in document order + let root_mode = RootMode::Root; + let next_direction = CaretDirection::Next; + let mut slice_state = slice_mode; + let range = get_caret_range_in_direction(initial_range, next_direction)?; + + // The nearest root or shadow root the removal happens inside, captured + // before anything is detached; see the repair below. + let mut range_container = Some(range.anchor.origin().clone()); + while let Some(container) = range_container + .as_ref() + .filter(|c| !is_root_or_shadow_root(&*c.get_latest())) + { + range_container = container.get_parent(); + } + // A child of the container, sampled while it still has one: it is what + // tells $restoreEmptyContainerParagraph whether this container holds + // blocks (a table cell) or structure (a table's rows). + let range_container_child = range_container + .as_ref() + .filter(|c| c.is_element()) + .and_then(NodeRef::get_first_child); + + let mut anchor_candidates = get_anchor_candidates(&range.anchor, root_mode)?; + let mut focus_candidates = get_anchor_candidates(&range.focus.get_flipped()?, root_mode)?; + + // Mark the start of each ElementNode + let mut seen_start: HashSet = HashSet::new(); + // Queue removals to avoid mutating the tree during iteration + let mut removed_nodes: Vec = Vec::new(); + for caret in range.iter_node_carets(root_mode) { + if caret.is_child_caret() { + seen_start.insert(caret.origin().get_key().to_owned()); + } else if caret.is_sibling_caret() { + let origin = caret.origin(); + if !origin.is_element() || seen_start.contains(origin.get_key()) { + removed_nodes.push(origin.clone()); + } + } + } + // Selection is rebuilt from anchor/focus candidates below, so detach + // without restoring it for every removed node. + let mut removed_parents: Vec = Vec::new(); + for node in &removed_nodes { + // Track parents not in seenStart — those in seenStart are traversal + // boundaries handled by block-merge logic below. + if let Some(parent) = node.get_parent() + && !seen_start.contains(parent.get_key()) + && !removed_parents.contains(&parent) + { + removed_parents.push(parent); + } + // `$recordDeleted(node)`: the text edit log isn't ported. + remove_from_parent(node); + } + // Remove inline wrappers (canBeEmpty=false) that became empty + for parent in &removed_parents { + if !parent.can_be_empty() + && !is_root_or_shadow_root(&*parent.get_latest()) + && parent.is_empty() + && parent.is_attached() + { + parent.remove(false)?; + } + } + + // Splice text at the anchor and/or origin. + // If the text is entirely selected then it is removed (unless it is the + // first slice and sliceMode is preserveEmptyTextSliceCaret). + // If it's a token with a non-empty selection then it is removed. + // Segmented nodes will be copied to a plain text node with the same + // format and style and set to normal mode. + for slice in range.get_text_slices().into_iter().flatten() { + let origin = slice.caret.origin().clone(); + let content_size = origin.get_text_content_size(); + let caret_before = rewind_sibling_caret(&get_sibling_caret(&origin, next_direction))?; + let mode = origin.text_data().get_mode(); + if (slice.distance.unsigned_abs() == content_size + && slice_state == SliceMode::RemoveEmptySlices) + || (mode == "token" && slice.distance != 0) + { + // anchorCandidates[1] should still be valid, it is caretBefore + caret_before.remove()?; + } else if slice.distance != 0 { + slice_state = SliceMode::RemoveEmptySlices; + let mut next_caret = slice.remove_text_slice()?; + let slice_origin = slice.caret.origin(); + if mode == "segmented" { + let src = next_caret.origin().clone(); + let data = src.text_data(); + let plain_text_node = create_text_node(data.get_text()); + plain_text_node.set_style(data.get_style()); + plain_text_node.set_format(data.get_format()); + plain_text_node.set_detail(data.get_detail())?; + // The characters left keep their NodeState, once NodeState + // is ported (`$carryNodeState`). + caret_before.replace_or_insert(&plain_text_node, false)?; + next_caret = get_text_point_caret( + &plain_text_node, + next_direction, + next_caret.offset().expect("a text point caret"), + )?; + } + if slice_origin == anchor_candidates[0].origin() { + anchor_candidates[0] = next_caret.clone(); + } + if slice_origin == focus_candidates[0].origin() { + focus_candidates[0] = next_caret.get_flipped()?; + } + } + } + + // Find the deepest anchor and focus candidates that are still attached + let anchor_candidate = get_attached_caret(anchor_candidates.iter()); + let focus_candidate = get_attached_caret(focus_candidates.iter()); + + // Merge blocks if necessary + let merge_targets = get_block_merge_targets( + anchor_candidate.as_ref(), + focus_candidate.as_ref(), + &seen_start, + ); + if let Some((anchor_block, focus_block)) = merge_targets { + // always merge blocks later in the document with blocks earlier in + // the document + get_child_caret(&anchor_block, CaretDirection::Previous) + .expect("a block is an element") + .splice(0, &focus_block.get_children(), CaretDirection::Next)?; + // remove empty parent node even if parent node is canBeEmpty + let mut parent = focus_block.get_parent(); + focus_block.remove(true)?; + while let Some(p) = parent.filter(NodeRef::is_empty) { + parent = p.get_parent(); + p.remove(true)?; + } + } else if let Some(focus_candidate) = &focus_candidate { + let focus_block = get_block_from_caret(focus_candidate); + let focus_block_parent = focus_block.as_ref().and_then(NodeRef::get_parent); + let topmost_shadow_root = focus_block + .as_ref() + .and_then(|b| b.get_parents().into_iter().rev().find(is_shadow_root_node)); + if let (Some(focus_block), Some(focus_block_parent)) = (&focus_block, &focus_block_parent) + && !focus_block_parent.is_root() + && focus_block.is_empty() + && seen_start.contains(focus_block.get_key()) + // A slot host keeps its block, once slots are ported. + && topmost_shadow_root.is_none_or(|r| seen_start.contains(r.get_key())) + { + focus_block.remove(true)?; + let mut parent = Some(focus_block_parent.clone()); + while let Some(p) = parent.filter(|p| !p.is_root() && p.is_empty()) { + let grandparent = p.get_parent(); + if let Some(g) = &grandparent + && g.is_root() + && g.get_children_size() <= 1 + // Only keep the root's last child when it is valid while + // empty. One that declares `canBeEmpty()` false is + // removed like any other emptied ancestor, and the empty + // root is repaired below. + && p.can_be_empty() + { + break; + } + parent = grandparent; + p.remove(true)?; + } + } + } + + // A range that covers every child of its container (a select-all over a + // document that is a single shadow root, or over everything inside one) + // removes them all and leaves nothing to put a caret in. Restore the + // empty paragraph the container would otherwise be missing, so that + // deleting all of a container's content behaves like deleting a document + // made of ordinary paragraphs. The range is always attached (the + // candidate walk above throws otherwise), so an empty container here is + // always this call's doing. `rangeContainer` is itself removed when the + // range covered it — the empty-ancestor walks above climb out of it — in + // which case the root is what needs the paragraph. + if restore_empty_container_paragraph(range_container.as_ref(), range_container_child.as_ref())? + .is_none() + && range_container.as_ref().is_some_and(|c| !c.is_attached()) + { + restore_empty_container_paragraph(Some(&get_root()), None)?; + } + + // note this caret can be in either direction + let best_candidate = get_attached_caret( + anchor_candidate + .iter() + .chain(&focus_candidate) + .chain(&anchor_candidates) + .chain(&focus_candidates), + ); + if let Some(best_candidate) = best_candidate { + let anchor = get_caret_in_direction(&best_candidate, initial_range.direction())?; + return Ok(get_collapsed_caret_range(anchor)); + } + let keys: Vec = anchor_candidates + .iter() + .map(|c| format!("\"{}\"", c.origin().get_key())) + .collect(); + Err(LexicalError::new(format!( + "$removeTextFromCaretRange: selection was lost, could not find a new anchor given candidates with keys: [{}]", + keys.join(",") + ))) +} + +/// `$getAttachedCaret(candidates)`: the first candidate still attached, +/// normalized. +fn get_attached_caret<'a>( + mut candidates: impl Iterator, +) -> Option { + candidates + .find(|c| is_caret_attached(c)) + .map(normalize_caret) +} + +/// `$getBlockFromCaret(caret)`. +fn get_block_from_caret(caret: &PointCaret) -> Option { + if caret.is_child_caret() { + let origin = caret.origin(); + is_block(origin).then(|| origin.clone()) + } else { + caret.get_parent_at_caret().filter(is_block) + } +} + +/// `$getBlockMergeTargets(anchor, focus, seenStart)`: the blocks closest to +/// the common ancestor of the anchor's and the focus's parents, if they are +/// distinct and no shadow root lies between them and their carets. +fn get_block_merge_targets( + anchor: Option<&PointCaret>, + focus: Option<&PointCaret>, + seen_start: &HashSet, +) -> Option<(NodeRef, NodeRef)> { + let anchor_parent = anchor?.get_parent_at_caret()?; + let focus_parent = focus?.get_parent_at_caret()?; + let common = get_common_ancestor(&anchor_parent, &focus_parent)?; + if !matches!(common, CommonAncestorResult::Branch { .. }) { + return None; + } + let get_block = |parent: &NodeRef, selected_only: bool| -> Option { + let mut block = None; + // Ascend to the common ancestor, retaining the outermost eligible + // block and rejecting any branch that crosses a shadow root. + let mut node = Some(parent.clone()); + while let Some(n) = node.filter(|n| n != common.common_ancestor()) { + if is_root_or_shadow_root(&*n.get_latest()) { + return None; + } + if (!selected_only || seen_start.contains(n.get_key())) && is_block(&n) { + block = Some(n.clone()); + } + node = n.get_parent(); + } + block + }; + let anchor_block = get_block(&anchor_parent, false)?; + let focus_block = get_block(&focus_parent, true)?; + // Removing a host must not silently discard its slots during a block + // merge, once slots are ported. + Some((anchor_block, focus_block)) +} + +/// `$getDeepestChildOrSelf(initialCaret)`: the deepest child caret under +/// the caret's origin, or the caret. +fn get_deepest_child_or_self(initial_caret: PointCaret) -> PointCaret { + let mut caret = initial_caret; + while caret.is_child_caret() { + match get_adjacent_child_caret(Some(&caret)) { + Some(adjacent) if adjacent.is_child_caret() => caret = adjacent, + _ => break, + } + } + caret +} + +/// `$normalizeCaret(initialCaret)`: the deepest equivalent caret, a text +/// point caret when there is text at the caret. +pub fn normalize_caret(initial_caret: &PointCaret) -> PointCaret { + let caret = get_deepest_child_or_self(initial_caret.get_latest()); + let direction = caret.direction(); + if caret.origin().is_text() { + return if caret.is_text_point_caret() { + caret + } else { + get_text_point_caret_at_end(caret.origin(), direction, direction) + }; + } + match caret.get_adjacent_caret() { + Some(adj) if adj.is_sibling_caret() && adj.origin().is_text() => { + get_text_point_caret_at_end(adj.origin(), direction, flip_direction(direction)) + } + _ => caret, + } +} + +/// `$isExtendableTextPointCaret(caret)`: a text point caret whose offset +/// can move further in its direction without leaving the node. +pub fn is_extendable_text_point_caret(caret: &PointCaret) -> bool { + caret.offset().is_some_and(|offset| { + get_text_node_offset(caret.origin(), TextOffset::End(caret.direction()), false) + .is_ok_and(|end| offset != end) + }) +} + +/// `$getCaretRangeInDirection(range, direction)`: the range, or the same +/// part of the document iterated the other way. +pub fn get_caret_range_in_direction( + range: &CaretRange, + direction: CaretDirection, +) -> Result { + if range.direction() == direction { + return Ok(range.clone()); + } + get_caret_range( + // focus and anchor get flipped here + get_caret_in_direction(&range.focus, direction)?, + get_caret_in_direction(&range.anchor, direction)?, + ) +} + +/// `$getChildCaretAtIndex(parent, index, direction)`: a caret pointing to +/// the child at `index`, or the last caret in `parent` past the end. +pub fn get_child_caret_at_index( + parent: &NodeRef, + index: usize, + direction: CaretDirection, +) -> NodeCaret { + let size = parent.get_children_size(); + let offset = index.min(size); + let origin = match direction { + CaretDirection::Next => offset.checked_sub(1), + CaretDirection::Previous => Some(offset), + } + .filter(|&i| i < size) + .and_then(|i| parent.get_child_at_index(i)); + match origin { + Some(origin) => get_sibling_caret(&origin, direction), + None => get_child_caret(parent, direction).expect("a parent is an element"), + } +} + +/// `$getAdjacentSiblingOrParentSiblingCaret(startCaret, rootMode)`: the +/// next caret in a depth first walk that doesn't descend into the start, +/// with the change in depth. +pub fn get_adjacent_sibling_or_parent_sibling_caret( + start_caret: &NodeCaret, + root_mode: RootMode, +) -> Option<(NodeCaret, isize)> { + let mut depth_diff = 0; + let mut caret = start_caret.clone(); + let mut next_caret = get_adjacent_child_caret(Some(&caret)); + while next_caret.is_none() { + depth_diff -= 1; + caret = caret.get_parent_caret(root_mode)?; + next_caret = get_adjacent_child_caret(Some(&caret)); + } + next_caret.map(|c| (c, depth_diff)) +} + +/// `$getTextPointCaretSliceForNode(slices, node)`: the slice of `node` +/// among a caret range's text slices. +pub fn get_text_point_caret_slice_for_node<'a>( + slices: &'a [Option], + node: &NodeRef, +) -> Option<&'a TextPointCaretSlice> { + slices + .iter() + .flatten() + .find(|slice| slice.caret.origin() == node) +} diff --git a/rust/lexical-editor/src/lib.rs b/rust/lexical-editor/src/lib.rs index 4c8360c2af9..7035fd852ea 100644 --- a/rust/lexical-editor/src/lib.rs +++ b/rust/lexical-editor/src/lib.rs @@ -44,7 +44,9 @@ //! ); //! ``` +mod caret; mod caret_tree; +mod caret_utils; pub mod constants; mod editor; mod editor_state; @@ -57,11 +59,32 @@ pub mod nodes; mod normalization; mod ordered; mod selection; +mod selection_deletion; +mod selection_editing; +mod selection_format; +mod selection_insert_nodes; +mod selection_insert_text; mod serialized_export; mod updates; pub mod utf16; mod utils; +pub use caret::{ + CaretDirection, CaretKind, CaretRange, ChildCaret, CommonAncestorResult, NodeCaret, PointCaret, + RootMode, SiblingCaret, TextOffset, TextPointCaret, TextPointCaretSlice, + compare_point_caret_next, extend_caret_to_range, flip_direction, get_adjacent_child_caret, + get_caret_in_direction, get_caret_range, get_child_caret, get_child_caret_or_self, + get_collapsed_caret_range, get_common_ancestor, get_common_ancestor_result_branch_order, + get_sibling_caret, get_text_node_offset, get_text_point_caret, get_text_point_caret_slice, + make_stepwise_iterator, +}; +pub use caret_utils::{ + SliceMode, caret_from_point, caret_range_from_selection, + get_adjacent_sibling_or_parent_sibling_caret, get_caret_range_in_direction, + get_child_caret_at_index, get_text_point_caret_slice_for_node, is_extendable_text_point_caret, + normalize_caret, remove_text_from_caret_range, rewind_sibling_caret, set_point_from_caret, + set_selection_from_caret_range, update_range_selection_from_caret_range, +}; pub use editor::{ COMMAND_PRIORITY_CRITICAL, COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, COMMAND_PRIORITY_NORMAL, CreateEditorArgs, Editor, HISTORY_MERGE_TAG, @@ -78,6 +101,7 @@ pub use selection::{ NodeSelection, Point, PointType, RangeSelection, Selection, SelectionRef, Which, create_node_selection, create_range_selection, get_selection, set_selection, }; +pub use selection_insert_nodes::{insert_nodes, select_all}; pub use serialized_export::{export_node_json, is_compact_export, with_compact_export}; pub use updates::parse_serialized_node; pub use utils::{get_node_by_key, get_root, reset_random_key}; diff --git a/rust/lexical-editor/src/node.rs b/rust/lexical-editor/src/node.rs index 872018ce440..fc20aa3da1a 100644 --- a/rust/lexical-editor/src/node.rs +++ b/rust/lexical-editor/src/node.rs @@ -85,6 +85,50 @@ pub trait LexicalNode: NodeClone + fmt::Debug + 'static { true } + /// `ElementNode.canMergeWhenEmpty()`: whether the element, when empty, + /// takes the children of the first block `insertNodes` inserts. + fn can_merge_when_empty(&self) -> bool { + false + } + + /// `isParentRequired()`: whether the node needs a parent of a certain + /// kind ([`create_parent_element_node`](Self::create_parent_element_node)). + fn is_parent_required(&self) -> bool { + false + } + + /// `createParentElementNode()`: a new parent for the node, a paragraph + /// by default. + fn create_parent_element_node(&self) -> NodeRef { + crate::nodes::paragraph::create_paragraph_node() + } + + /// `ElementNode.insertNewAfter(selection, restoreSelection)` on the + /// element `this` (this node's handle): inserts the block that splitting + /// the element creates after it, and returns it. `None` (the default) + /// means the element can't be split. + fn insert_new_after( + &self, + this: &NodeRef, + selection: &SelectionRef, + restore_selection: bool, + ) -> Result, LexicalError> { + let _ = (this, selection, restore_selection); + Ok(None) + } + + /// `canInsertTextBefore()` (`TextNode` and `ElementNode`): whether text + /// typed at the node's start goes into it. + fn can_insert_text_before(&self) -> bool { + true + } + + /// `canInsertTextAfter()` (`TextNode` and `ElementNode`): whether text + /// typed at the node's end goes into it. + fn can_insert_text_after(&self) -> bool { + true + } + /// `getTextContent()`. fn get_text_content(&self) -> String { if let Some(text) = self.text() { @@ -208,6 +252,17 @@ impl NodeRef { self.get_latest().base().parent.clone().map(NodeRef::new) } + /// `getParents()`: the ancestors, nearest first. + pub fn get_parents(&self) -> Vec { + let mut parents = Vec::new(); + let mut node = self.get_parent(); + while let Some(n) = node { + node = n.get_parent(); + parents.push(n); + } + parents + } + pub fn get_previous_sibling(&self) -> Option { self.get_latest().base().prev.clone().map(NodeRef::new) } @@ -307,6 +362,31 @@ impl NodeRef { self.get_latest().can_be_empty() } + /// `ElementNode.canMergeWhenEmpty()`. + pub fn can_merge_when_empty(&self) -> bool { + self.get_latest().can_merge_when_empty() + } + + /// `isParentRequired()`. + pub fn is_parent_required(&self) -> bool { + self.get_latest().is_parent_required() + } + + /// `createParentElementNode()`. + pub fn create_parent_element_node(&self) -> NodeRef { + self.get_latest().create_parent_element_node() + } + + /// `ElementNode.insertNewAfter(selection, restoreSelection)`. + pub fn insert_new_after( + &self, + selection: &SelectionRef, + restore_selection: bool, + ) -> Result, LexicalError> { + self.get_latest() + .insert_new_after(self, selection, restore_selection) + } + /// `isSelected()` for a node selection. Range selections come with /// `getNodes`. fn is_selected_by_node_selection(&self, selection: &SelectionRef) -> bool { diff --git a/rust/lexical-editor/src/nodes/line_break.rs b/rust/lexical-editor/src/nodes/line_break.rs index 07ee62fa88a..22c0e5fdb0b 100644 --- a/rust/lexical-editor/src/nodes/line_break.rs +++ b/rust/lexical-editor/src/nodes/line_break.rs @@ -2,7 +2,12 @@ use crate::generated_json::{export_compact_line_break_node, export_line_break_node}; use crate::json::Object; -use crate::node::{LexicalNode, NodeBase}; +use crate::node::{LexicalNode, NodeBase, NodeRef}; + +/// `$createLineBreakNode()`. +pub fn create_line_break_node() -> NodeRef { + crate::utils::set_node_key(Box::new(LineBreakNode::default())) +} /// A soft line break (Shift+Enter). #[derive(Clone, Debug, Default)] diff --git a/rust/lexical-editor/src/nodes/paragraph.rs b/rust/lexical-editor/src/nodes/paragraph.rs index a3d37dcca4d..498dcd5afb9 100644 --- a/rust/lexical-editor/src/nodes/paragraph.rs +++ b/rust/lexical-editor/src/nodes/paragraph.rs @@ -1,11 +1,13 @@ //! `nodes/LexicalParagraphNode.ts`. +use crate::LexicalError; use crate::generated_json::{ export_compact_element_node, export_element_node, update_element_node, }; use crate::json::{Object, Value}; use crate::node::{LexicalNode, NodeBase, NodeRef}; use crate::nodes::element::ElementData; +use crate::selection::SelectionRef; /// `$createParagraphNode()`. pub fn create_paragraph_node() -> NodeRef { @@ -69,4 +71,26 @@ impl LexicalNode for ParagraphNode { fn update_from_json(&mut self, json: &Object) { update_element_node(&mut self.element, json); } + + /// `ParagraphNode.insertNewAfter`: a paragraph with the selection's + /// format and style as its text format and style, and this paragraph's + /// direction, alignment and style. + fn insert_new_after( + &self, + this: &NodeRef, + selection: &SelectionRef, + restore_selection: bool, + ) -> Result, LexicalError> { + let (format, style) = selection + .with_range(|r| (r.format, r.style.clone())) + .unwrap_or_default(); + let new_element = create_paragraph_node(); + new_element.set_text_format(format); + new_element.set_text_style(&style); + new_element.set_direction(self.element.get_direction()); + new_element.set_element_format(self.element.get_format_type()); + new_element.set_element_style(self.element.get_style()); + this.insert_after(&new_element, restore_selection)?; + Ok(Some(new_element)) + } } diff --git a/rust/lexical-editor/src/nodes/tab.rs b/rust/lexical-editor/src/nodes/tab.rs index 4b23bef78e4..9549e47ab97 100644 --- a/rust/lexical-editor/src/nodes/tab.rs +++ b/rust/lexical-editor/src/nodes/tab.rs @@ -3,9 +3,14 @@ use crate::constants::IS_UNMERGEABLE; use crate::generated_json::{export_compact_tab_node, export_tab_node, update_tab_node}; use crate::json::Object; -use crate::node::{LexicalNode, NodeBase}; +use crate::node::{LexicalNode, NodeBase, NodeRef}; use crate::nodes::text::TextData; +/// `$createTabNode()`. +pub fn create_tab_node() -> NodeRef { + crate::utils::set_node_key(Box::new(TabNode::default())) +} + /// A text node that always holds one `\t` and never merges. #[derive(Clone, Debug)] pub struct TabNode { @@ -42,6 +47,12 @@ impl LexicalNode for TabNode { fn text_mut(&mut self) -> Option<&mut TextData> { Some(&mut self.text) } + fn can_insert_text_before(&self) -> bool { + false + } + fn can_insert_text_after(&self) -> bool { + false + } fn export_json(&self, compact: bool) -> Object { if compact { export_compact_tab_node(self.get_type(), &self.text) diff --git a/rust/lexical-editor/src/selection.rs b/rust/lexical-editor/src/selection.rs index 341689df306..0bddec79c67 100644 --- a/rust/lexical-editor/src/selection.rs +++ b/rust/lexical-editor/src/selection.rs @@ -1,11 +1,20 @@ -//! `LexicalSelection.ts`: points, `RangeSelection` and `NodeSelection`, and -//! the helpers node methods use to keep a selection valid while they change -//! the tree. Selection editing (`insertText`, `deleteCharacter`, ...) comes -//! next (rust/DESIGN.md, M1). +//! `LexicalSelection.ts`: points, `RangeSelection` and `NodeSelection` +//! (with `getNodes` and `getTextContent`), and the helpers node methods use +//! to keep a selection valid while they change the tree. Selection editing +//! (`insertText`, `deleteCharacter`, ...) is in the `selection_*` modules. use crate::LexicalError; +use crate::caret::{ + CaretDirection, CaretRange, PointCaret, compare_point_caret_next, get_adjacent_child_caret, + get_child_caret, +}; +use crate::caret_utils::{ + caret_from_point, caret_range_from_selection, get_caret_range_in_direction, + get_text_point_caret_slice_for_node, normalize_caret, +}; use crate::error::invariant; use crate::node::{NodeKey, NodeRef}; +use crate::nodes::line_break::LineBreakNode; use crate::ordered::OrderedSet; use crate::updates::{get_active_editor_state, is_currently_read_only_mode}; use crate::utils::get_node_by_key; @@ -46,6 +55,12 @@ impl Point { } } + /// `point.isBefore(b)`: whether this point comes before `b` in the + /// document. + pub fn is_before(&self, b: &Point) -> Result { + is_before(self, b) + } + /// `point.getNode()`. pub fn get_node(&self) -> Result { get_node_by_key(&self.key).ok_or_else(|| LexicalError::new("Point.getNode: node not found")) @@ -68,6 +83,8 @@ pub struct RangeSelection { pub style: String, pub(crate) dirty: bool, pub(crate) cached_is_backward: Option, + /// `_cachedNodes`. + pub(crate) cached_nodes: Option>, } impl RangeSelection { @@ -80,6 +97,7 @@ impl RangeSelection { style: style.into(), dirty: false, cached_is_backward: None, + cached_nodes: None, } } @@ -115,6 +133,8 @@ impl RangeSelection { pub struct NodeSelection { pub(crate) nodes: OrderedSet, pub(crate) dirty: bool, + /// `_cachedNodes`. + pub(crate) cached_nodes: Option>, } impl NodeSelection { @@ -125,11 +145,13 @@ impl NodeSelection { pub fn add(&mut self, key: &str) { self.dirty = true; self.nodes.add(key); + self.cached_nodes = None; } pub fn delete(&mut self, key: &str) { self.dirty = true; self.nodes.delete(key); + self.cached_nodes = None; } pub fn has(&self, key: &str) -> bool { @@ -211,6 +233,16 @@ impl SelectionRef { } } + /// `nodeSelection.add(key)`; nothing on a range selection. + pub fn add(&self, key: &str) { + self.with_node(|n| n.add(key)); + } + + /// `nodeSelection.delete(key)`; nothing on a range selection. + pub fn delete(&self, key: &str) { + self.with_node(|n| n.delete(key)); + } + /// A node selection's keys, in the order added; empty for a range /// selection. pub fn node_keys(&self) -> Vec { @@ -231,6 +263,98 @@ impl SelectionRef { *self.0.borrow_mut().dirty_mut() = dirty; } + /// `setCachedNodes(nodes)`. + pub(crate) fn set_cached_nodes(&self, nodes: Option>) { + match &mut *self.0.borrow_mut() { + Selection::Range(r) => r.cached_nodes = nodes, + Selection::Node(n) => n.cached_nodes = nodes, + } + } + + fn get_cached_nodes(&self) -> Option> { + match &*self.0.borrow() { + Selection::Range(r) => r.cached_nodes.clone(), + Selection::Node(n) => n.cached_nodes.clone(), + } + } + + /// `selection.getNodes()`: for a range selection, the nodes it covers in + /// document order, with the quirks of the original `getNodes` + /// (`$getNodesFromCaretRangeCompat`); for a node selection, its nodes + /// that exist, in the order added. Cached until the selection or a node + /// changes. + pub fn get_nodes(&self) -> Result, LexicalError> { + if let Some(cached) = self.get_cached_nodes() { + return Ok(cached); + } + let nodes = match self.range() { + Some(range) => { + let caret_range = get_caret_range_in_direction( + &caret_range_from_selection(&range)?, + CaretDirection::Next, + )?; + let nodes = get_nodes_from_caret_range_compat(&caret_range)?; + // JS checks this in DEV builds, which the vectors come from. + invariant!( + !(range.is_collapsed() && nodes.len() > 1), + "RangeSelection.getNodes() returned {} > 1 nodes in a collapsed selection", + nodes.len() + ); + nodes + } + None => self + .node_keys() + .iter() + .filter_map(|key| get_node_by_key(key)) + .collect(), + }; + if !is_currently_read_only_mode() { + self.set_cached_nodes(Some(nodes.clone())); + } + Ok(nodes) + } + + /// `selection.getTextContent()`: for a range selection, the text it + /// covers, with a line break between blocks; for a node selection, the + /// text of its nodes. + pub fn get_text_content(&self) -> Result { + let Some(range) = self.range() else { + return Ok(self + .get_nodes()? + .iter() + .map(NodeRef::get_text_content) + .collect()); + }; + if range.is_collapsed() { + return Ok(String::new()); + } + let nodes = self.get_nodes()?; + let slices = caret_range_from_selection(&range)?.get_text_slices(); + let mut text_content = String::new(); + let mut prev_was_element = true; + for node in &nodes { + if node.is_element() && !node.is_inline() { + if !prev_was_element { + text_content.push('\n'); + } + // Slot text comes first once slots are ported. + prev_was_element = !node.is_empty(); + } else { + prev_was_element = false; + if node.is_text() { + match get_text_point_caret_slice_for_node(&slices, node) { + Some(slice) => text_content.push_str(&slice.get_text_content()), + None => text_content.push_str(&node.get_text_content()), + } + } else if node.get_latest().is::() { + // A DecoratorNode's text too, once it is ported. + text_content.push_str(&node.get_text_content()); + } + } + } + Ok(text_content) + } + /// `selection.clone()`: a separate selection with the same value, not /// dirty. pub fn clone_selection(&self) -> SelectionRef { @@ -239,8 +363,12 @@ impl SelectionRef { Selection::Range(r) => { r.dirty = false; r.cached_is_backward = None; + r.cached_nodes = None; + } + Selection::Node(n) => { + n.dirty = false; + n.cached_nodes = None; } - Selection::Node(n) => n.dirty = false, } SelectionRef::new(value) } @@ -277,6 +405,7 @@ impl SelectionRef { // The composition key follows the point once composition is // ported; a headless editor never composes. if !read_only { + r.cached_nodes = None; r.cached_is_backward = None; r.dirty = true; } @@ -315,15 +444,107 @@ impl SelectionRef { } } -/// `a.isBefore(b)`. Points on different nodes are ordered by carets -/// (`$comparePointCaretNext`), which come with the caret port. +/// `a.isBefore(b)`. fn is_before(a: &Point, b: &Point) -> Result { if a.key == b.key { return Ok(a.offset < b.offset); } - Err(LexicalError::new( - "Point.isBefore: points on different nodes need the caret port (rust/DESIGN.md, M1)", - )) + let a_caret = normalize_caret(&caret_from_point(a, CaretDirection::Next)?); + let b_caret = normalize_caret(&caret_from_point(b, CaretDirection::Next)?); + Ok(compare_point_caret_next(&a_caret, &b_caret)?.is_lt()) +} + +/// `$getNodesFromCaretRangeCompat(range)`: the nodes of a `next` caret +/// range, with all the quirks of the original `RangeSelection.getNodes()`. +fn get_nodes_from_caret_range_compat(range: &CaretRange) -> Result, LexicalError> { + let mut nodes: Vec = Vec::new(); + let [before_slice, after_slice] = range.get_text_slices(); + if let Some(slice) = &before_slice { + nodes.push(slice.caret.origin().clone()); + } + let mut seen_ancestors: Vec = Vec::new(); + // Enter/exit events are nested. Only leading ancestors lack an included + // enter event, so a depth counter can skip repeated elements on exit. + let mut open_elements = 0usize; + for caret in range.iter() { + let origin = caret.origin().clone(); + if caret.is_child_caret() { + // Emulate the leading under-selection behavior of getNodes by + // ignoring the 'enter' of any ElementNode until we've seen a + // SiblingCaret + if nodes.is_empty() { + if !seen_ancestors.contains(&origin) { + seen_ancestors.push(origin); + } + } else { + open_elements += 1; + nodes.push(origin); + } + } else if !origin.is_element() || open_elements == 0 { + nodes.push(origin); + } else { + open_elements -= 1; + } + } + if let Some(slice) = &after_slice { + nodes.push(slice.caret.origin().clone()); + } + // Emulate the trailing underselection behavior when the last offset of + // an element is selected + if range.focus.is_sibling_caret() + && range.focus.origin().is_element() + && range.focus.get_node_at_caret().is_none() + { + let mut reverse_caret = get_child_caret(range.focus.origin(), CaretDirection::Previous); + while let Some(rc) = reverse_caret.filter(|rc| { + rc.is_child_caret() + && seen_ancestors.contains(rc.origin()) + && !rc.origin().is_empty() + && nodes.last() == Some(rc.origin()) + }) { + seen_ancestors.retain(|n| n != rc.origin()); + nodes.pop(); + reverse_caret = get_adjacent_child_caret(Some(&rc)); + } + } + while nodes.len() > 1 { + let last_included_node = nodes.last().expect("len > 1"); + if last_included_node.is_element() + // A non-empty included element at the tail has not exited yet: + // otherwise one of its descendants would follow it in nodes. + && !(open_elements > 0 + || last_included_node.is_empty() + || seen_ancestors.contains(last_included_node)) + { + nodes.pop(); + continue; + } + break; + } + if nodes.is_empty() && range.is_collapsed() { + // Emulate the collapsed behavior of getNodes by returning the + // descendant + let norm_caret = normalize_caret(&range.anchor); + let flipped_norm_caret = normalize_caret(&range.anchor.get_flipped()?); + let get_candidate = |caret: &PointCaret| -> Option { + if caret.is_text_point_caret() { + Some(caret.origin().clone()) + } else { + caret.get_node_at_caret() + } + }; + let node = get_candidate(&norm_caret) + .or_else(|| get_candidate(&flipped_norm_caret)) + .unwrap_or_else(|| { + if range.anchor.get_node_at_caret().is_some() { + norm_caret.origin().clone() + } else { + flipped_norm_caret.origin().clone() + } + }); + nodes.push(node); + } + Ok(nodes) } /// `$getSelection()`. @@ -336,6 +557,7 @@ pub fn set_selection(selection: Option) { crate::updates::error_on_read_only(); if let Some(selection) = &selection { selection.set_dirty(true); + selection.set_cached_nodes(None); } *get_active_editor_state().0.selection.borrow_mut() = selection; } @@ -569,3 +791,120 @@ pub(crate) fn has_dirty_selection( (None, current) => current.is_some(), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{CreateEditorArgs, UpdateOptions, create_editor, get_root, json, reset_random_key}; + + /// `getNodes()`, `getTextContent()` and `isBackward()` of range + /// selections, and a node selection's nodes and text, as JS 0.53.0 + /// gives them for the same document (root > p 1 ["one" 2, bold "two" + /// 3], p 4 ["three" 5, linebreak 6, "x" 7], empty p 8). + #[test] + fn get_nodes_and_text_content_match_js() { + reset_random_key(); + let editor = create_editor(CreateEditorArgs::default()); + let discrete = || UpdateOptions { + discrete: true, + ..UpdateOptions::default() + }; + editor + .update( + || { + let nodes = json::parse( + r#"[{"type":"paragraph","children":[{"type":"text","text":"one"},{"type":"text","text":"two","format":1}]},{"type":"paragraph","children":[{"type":"text","text":"three"},{"type":"linebreak"},{"type":"text","text":"x"}]},{"type":"paragraph","children":[]}]"#, + ) + .unwrap(); + let nodes: Vec = nodes + .as_array() + .unwrap() + .iter() + .map(|n| crate::parse_serialized_node(n).unwrap()) + .collect(); + get_root().append(&nodes) + }, + discrete(), + ) + .unwrap(); + use PointType::{Element as E, Text as T}; + // Anchor, focus, then the expected nodes, text and `isBackward()`. + type Spec = (&'static str, usize, PointType); + type Case = (Spec, Spec, &'static [&'static str], &'static str, bool); + let cases: &[Case] = &[ + ( + ("2", 1, T), + ("5", 2, T), + &["2", "3", "1", "4", "5"], + "netwo\nth", + false, + ), + ( + ("1", 0, E), + ("8", 0, E), + &["2", "3", "1", "4", "5", "6", "7", "8"], + "onetwo\nthree\nx\n", + false, + ), + ( + ("5", 4, T), + ("2", 0, T), + &["2", "3", "1", "4", "5"], + "onetwo\nthre", + true, + ), + (("2", 1, T), ("2", 1, T), &["2"], "", false), + ( + ("root", 0, E), + ("root", 3, E), + &["2", "3", "1", "4", "5", "6", "7", "8"], + "onetwo\nthree\nx\n", + false, + ), + (("1", 1, E), ("4", 0, E), &["3", "1", "4"], "two\n", false), + (("3", 3, T), ("4", 0, E), &["3", "1", "4"], "\n", false), + (("4", 1, E), ("4", 3, E), &["6", "7"], "\nx", false), + (("1", 2, E), ("1", 2, E), &["3"], "", false), + ( + ("8", 0, E), + ("1", 0, E), + &["2", "3", "1", "4", "5", "6", "7", "8"], + "onetwo\nthree\nx\n", + true, + ), + ]; + editor + .update( + move || { + for (anchor, focus, nodes, text, backward) in cases { + let selection = create_range_selection(); + selection.point_set(Which::Anchor, anchor.0, anchor.1, anchor.2)?; + selection.point_set(Which::Focus, focus.0, focus.1, focus.2)?; + set_selection(Some(selection.clone())); + let keys: Vec = selection + .get_nodes()? + .iter() + .map(|n| n.get_key().to_owned()) + .collect(); + assert_eq!(keys, *nodes, "{anchor:?} {focus:?}"); + assert_eq!(selection.get_text_content()?, *text, "{anchor:?} {focus:?}"); + assert_eq!(selection.is_backward()?, *backward, "{anchor:?} {focus:?}"); + } + let selection = create_node_selection(); + for key in ["4", "2", "99"] { + selection.with_node(|n| n.add(key)); + } + let keys: Vec = selection + .get_nodes()? + .iter() + .map(|n| n.get_key().to_owned()) + .collect(); + assert_eq!(keys, ["4", "2"]); + assert_eq!(selection.get_text_content()?, "three\nxone"); + Ok(()) + }, + discrete(), + ) + .unwrap(); + } +} diff --git a/rust/lexical-editor/src/selection_deletion.rs b/rust/lexical-editor/src/selection_deletion.rs new file mode 100644 index 00000000000..4534a1d2c90 --- /dev/null +++ b/rust/lexical-editor/src/selection_deletion.rs @@ -0,0 +1,1892 @@ +//! `deleteCharacter`, `deleteWord` and `deleteLine` from +//! `LexicalSelection.ts`, with the parts of `modify` they need headless. +//! +//! JS measures how far a deletion reaches inside text with the DOM's +//! `Selection.modify` (`$extendSelectionForDeletion`), after +//! `$modifySelectionAroundDecoratorsAndBlocks` has resolved the moves that +//! cross blocks in the model. That measurement starts with +//! `getWindow(editor)`, which throws `window object not found` for an editor +//! without a root element, so a headless JS editor fails the update whenever +//! a deletion needs it: within text, at a text node edge with a sibling in +//! the deletion direction, and at the edges of the document. Only the model +//! paths (merging blocks, removing a line break or an empty block, removing +//! an expanded selection) succeed, and that is what this port does. +//! +//! Decorators, slots and shadow roots other than the root are not ported, +//! so the branches for them are no-ops (each is noted where JS has it). + +use crate::LexicalError; +use crate::caret::{ + CaretDirection, NodeCaret, PointCaret, RootMode, compare_point_caret_next, + extend_caret_to_range, get_caret_in_direction, get_caret_range, get_child_caret, + get_sibling_caret, +}; +use crate::caret_utils::{ + caret_from_point, caret_range_from_selection, get_caret_range_in_direction, + is_extendable_text_point_caret, normalize_caret, rewind_sibling_caret, set_point_from_caret, + update_range_selection_from_caret_range, +}; +use crate::error::invariant; +use crate::node::NodeRef; +use crate::nodes::element::is_root_or_shadow_root; +use crate::nodes::line_break::LineBreakNode; +use crate::nodes::paragraph::{ParagraphNode, create_paragraph_node}; +use crate::selection::{Point, PointType, RangeSelection, SelectionRef, Which}; +use crate::utf16; +use crate::utils::{get_root, is_block, is_shadow_root_node, restore_empty_container_paragraph}; + +/// The `granularity` of a deletion: `'character'`, `'word'` or +/// `'lineboundary'`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Granularity { + Character, + Word, + LineBoundary, +} + +/// `deleteCharacter`'s scan for a block to merge with. +enum MergeState { + /// `{type: 'initial'}`. + Initial, + /// `{type: 'merge-next-block', block}`. + MergeNextBlock(NodeRef), + /// `{type: 'merge-block', caret, block}`. + MergeBlock { caret: NodeCaret, block: NodeRef }, +} + +fn direction(is_backward: bool) -> CaretDirection { + if is_backward { + CaretDirection::Previous + } else { + CaretDirection::Next + } +} + +impl SelectionRef { + /// `selection.deleteCharacter(isBackward)`: deletes one character, or + /// the selected content, and handles the boundaries of blocks. + pub fn delete_character(&self, is_backward: bool) -> Result<(), LexicalError> { + let Some(range) = self.range() else { + return Ok(()); + }; + let was_collapsed = range.is_collapsed(); + if was_collapsed { + let anchor = range.anchor.clone(); + let anchor_node = anchor.get_node()?; + if forward_deletion(&anchor, &anchor_node, is_backward) { + // Forward deletion stops at an adjacent shadow root, unless + // the anchor block is empty and the shadow root is its next + // sibling: the caret walk below then removes the empty block + // (#8075). + let next_sibling = if anchor_node.is_element() { + anchor_node.get_next_sibling() + } else { + None + }; + if !(anchor_node.is_element() + && anchor_node.is_empty() + && next_sibling.is_some_and(|n| is_shadow_root_node(&n))) + { + return Ok(()); + } + } + let direction = direction(is_backward); + let initial_caret = caret_from_point(&anchor, direction)?; + let initial_range = extend_caret_to_range(&initial_caret)?; + if initial_range + .get_text_slices() + .iter() + .all(|slice| slice.as_ref().is_none_or(|s| s.distance == 0)) + { + // The caret is an element point beside a node that renders a + // block cursor: the keystroke deletes that node. + if anchor.point_type == PointType::Element + && let Some(adjacent) = initial_caret.get_node_at_caret() + && adjacent.is_element() + && needs_block_cursor_beside(&adjacent) + { + let container = adjacent.get_parent(); + adjacent.remove(false)?; + if let Some(restored) = + restore_empty_container_paragraph(container.as_ref(), Some(&adjacent))? + { + restored.select_start()?; + } + return Ok(()); + } + // There's no text in the direction of the deletion so we can + // explore our options. + let anchor_origin = initial_range.anchor.origin().clone(); + let mut state = MergeState::Initial; + for caret in initial_range.iter_node_carets(RootMode::ShadowRoot) { + let origin = caret.origin().clone(); + if caret.is_child_caret() { + if origin.is_inline() { + // fall through when descending an inline + } else if origin.get_latest().is_shadow_root() { + if matches!(state, MergeState::MergeBlock { .. }) { + break; + } + // Don't merge with a shadow root block. + if anchor_origin.is_element() && anchor_origin.is_empty() { + // delete an empty paragraph like the + // DecoratorNode case + let norm_caret = normalize_caret(&caret); + update_range_selection_from_caret_range( + self, + &get_caret_range(norm_caret.clone(), norm_caret)?, + )?; + anchor_origin.remove(false)?; + } + return Ok(()); + } else if let MergeState::MergeNextBlock(block) + | MergeState::MergeBlock { block, .. } = &state + { + // Keep descending ChildCaret to find which block + // to merge with. + state = MergeState::MergeBlock { + block: block.clone(), + caret: caret.clone(), + }; + } + } else if matches!(state, MergeState::MergeBlock { .. }) { + break; + } else { + if origin.is_element() { + if !origin.is_inline() { + state = MergeState::MergeNextBlock(origin); + } else if !is_parent_of(&origin, &anchor_origin) { + break; + } + continue; + } + // `$isDecoratorNode(caret.origin)`: decorators are + // not ported. + if origin.get_latest().is::() { + // A LineBreakNode is a single deletable unit. + origin.remove(false)?; + return Ok(()); + } + break; + } + } + if let MergeState::MergeBlock { caret, block } = state { + // `$getSlotNames(block)` is empty: slots are not ported. + let origin = caret.origin(); + // Empty adjacent block at the same nesting level: remove + // it instead of merging, so the current block's type + // survives. + if origin.is_empty() + && !block.is_empty() + && origin.get_parent() == block.get_parent() + { + origin.remove(true)?; + return Ok(()); + } + let range_anchor = if !origin.is_empty() && block.is_empty() { + rewind_sibling_caret(&get_sibling_caret(&block, caret.direction()))? + } else { + initial_range.anchor.clone() + }; + update_range_selection_from_caret_range( + self, + &get_caret_range(range_anchor, caret)?, + )?; + return self.remove_text(); + } + // JS stops here when the caret sits at the edge of a slot + // value; slots are not ported. + } + + // Handle the deletion around decorators. + extend_selection_for_deletion(self, is_backward, Granularity::Character)?; + + let range = self.range().expect("a range selection"); + if !range.is_collapsed() { + let focus = &range.focus; + let anchor = &range.anchor; + let focus_node = match focus.point_type { + PointType::Text => Some(focus.get_node()?), + PointType::Element => None, + }; + let anchor_node = match anchor.point_type { + PointType::Text => Some(anchor.get_node()?), + PointType::Element => None, + }; + if let Some(focus_node) = focus_node.as_ref().filter(|n| n.is_segmented()) { + let offset = focus.offset; + let text_content_size = focus_node.get_text_content_size(); + if anchor_node.as_ref() == Some(focus_node) + || (is_backward && offset != text_content_size) + || (!is_backward && offset != 0) + { + return remove_segment(focus_node, is_backward, offset); + } + } else if let Some(anchor_node) = anchor_node.as_ref().filter(|n| n.is_segmented()) + { + let offset = anchor.offset; + let text_content_size = anchor_node.get_text_content_size(); + if focus_node.as_ref() == Some(anchor_node) + || (is_backward && offset != 0) + || (!is_backward && offset != text_content_size) + { + return remove_segment(anchor_node, is_backward, offset); + } + } + update_caret_selection_for_unicode_character(self, is_backward)?; + } else if is_backward + && range.anchor.offset == 0 + // Special handling around rich text nodes + && collapse_at_start(self, &range.anchor.get_node()?)? + { + return Ok(()); + } + } + if !was_collapsed { + // Widen a whole-document range to the blocks themselves (#5835). + internal_expand_selection_to_whole_document(self)?; + } + self.remove_text()?; + if is_backward + && !was_collapsed + && self.range().is_some_and(|r| { + r.is_collapsed() + && r.anchor.point_type == PointType::Element + && r.anchor.offset == 0 + }) + { + ensure_root_has_paragraph()?; + } + Ok(()) + } + + /// `selection.deleteWord(isBackward)`. + pub fn delete_word(&self, is_backward: bool) -> Result<(), LexicalError> { + delete_text_by_granularity(self, is_backward, Granularity::Word) + } + + /// `selection.deleteLine(isBackward)`. + pub fn delete_line(&self, is_backward: bool) -> Result<(), LexicalError> { + delete_text_by_granularity(self, is_backward, Granularity::LineBoundary) + } +} + +/// `selection.forwardDeletion(anchor, anchorNode, isBackward)`: whether a +/// forward deletion at the end of the anchor node would reach a shadow +/// root, which it must not merge into. +fn forward_deletion(anchor: &Point, anchor_node: &NodeRef, is_backward: bool) -> bool { + let at_end = match anchor.point_type { + PointType::Element => { + anchor_node.is_element() && anchor.offset == anchor_node.get_children_size() + } + PointType::Text => anchor.offset == anchor_node.get_text_content_size(), + }; + if !is_backward && at_end { + let next_sibling = anchor_node + .get_next_sibling() + .or_else(|| anchor_node.get_parent().and_then(|p| p.get_next_sibling())); + if next_sibling.is_some_and(|n| is_shadow_root_node(&n)) { + return true; + } + } + false +} + +/// `$deleteTextByGranularity(selection, isBackward, granularity)`: word and +/// line deletion. +fn delete_text_by_granularity( + selection: &SelectionRef, + is_backward: bool, + granularity: Granularity, +) -> Result<(), LexicalError> { + let Some(range) = selection.range() else { + return Ok(()); + }; + let was_collapsed = range.is_collapsed(); + // Set when the deletion stops at a slot boundary, which it never crosses. + let mut stopped_at_slot = false; + if was_collapsed { + if granularity == Granularity::Word + && forward_deletion(&range.anchor, &range.anchor.get_node()?, is_backward) + { + return Ok(()); + } + stopped_at_slot = extend_selection_for_deletion(selection, is_backward, granularity)?; + if granularity == Granularity::LineBoundary { + stop_line_deletion_at_line_break(selection)?; + } + } + // Line deletion must remain in one block; word deletion may cross blocks. + let range = selection.range().expect("a range selection"); + if granularity == Granularity::LineBoundary + && !range.is_collapsed() + && find_block(&range.anchor.get_node()?) != find_block(&range.focus.get_node()?) + { + let anchor = &range.anchor; + selection.point_set(Which::Focus, &anchor.key, anchor.offset, anchor.point_type)?; + } + if selection.range().is_some_and(|r| r.is_collapsed()) { + // Stopped at a slot boundary with nothing before it: a no-op. + if !stopped_at_slot { + selection.delete_character(is_backward)?; + } + } else { + // An existing whole-document selection deletes its blocks (#5835). + if !was_collapsed { + internal_expand_selection_to_whole_document(selection)?; + } + selection.remove_text()?; + } + Ok(()) +} + +/// `$findMatchingParent(node, INTERNAL_$isBlock)`: the node or its nearest +/// ancestor that is a block. +fn find_block(node: &NodeRef) -> Option { + let mut current = Some(node.clone()); + while let Some(node) = current { + if is_block(&node) { + return Some(node); + } + current = node.get_parent(); + } + None +} + +/// `$stopLineDeletionAtLineBreak(selection)`: pulls a line deletion's focus +/// back to the anchor's side of the first line break between them. +fn stop_line_deletion_at_line_break(selection: &SelectionRef) -> Result<(), LexicalError> { + let range = selection.range().expect("a range selection"); + for caret in caret_range_from_selection(&range)?.iter_node_carets(RootMode::ShadowRoot) { + if caret.is_sibling_caret() && caret.origin().get_latest().is::() { + return set_point_from_caret(selection, Which::Focus, &rewind_sibling_caret(&caret)?); + } + } + Ok(()) +} + +/// `$extendSelectionForDeletion(selection, isBackward, granularity)`: +/// extends the collapsed selection over what the deletion removes. Returns +/// true when the focus stopped at a slot boundary (never, as slots are not +/// ported). +/// +/// The moves across blocks are resolved in the model. Every other move is +/// measured with the DOM selection of `getWindow(editor)`, which throws for +/// an editor without a root element (`_window` is only set by +/// `setRootElement`), so this returns that error, as headless JS does. +fn extend_selection_for_deletion( + selection: &SelectionRef, + is_backward: bool, + granularity: Granularity, +) -> Result { + // Decorator/block handling, resolved in the model exactly as modify() + // does. + if modify_selection_around_decorators_and_blocks(selection, is_backward, granularity)? { + return Ok(false); + } + Err(LexicalError::new("window object not found")) +} + +/// `$modifySelectionAroundDecoratorsAndBlocks(selection, 'extend', +/// isBackward, granularity)`: moves the focus to the adjacent block when +/// the caret is at the edge of its own and returns true; false when the +/// native measurement has to resolve the move. +/// +/// Only the `'extend'` alteration in `'decorators-and-blocks'` mode is +/// ported, the one deletion uses; the `'move'` branches belong to `modify`. +/// Decorators are not ported, so the steps over them are no-ops. +fn modify_selection_around_decorators_and_blocks( + selection: &SelectionRef, + is_backward: bool, + granularity: Granularity, +) -> Result { + let range = selection.range().expect("a range selection"); + let initial_focus = caret_from_point(&range.focus, direction(is_backward))?; + let is_line_boundary = granularity == Granularity::LineBoundary; + // `focus === initialFocus` in JS: whether a new caret was chosen. + let mut focus: Option = None; + let mut check_for_block = true; + if !is_extendable_text_point_caret(&initial_focus) { + if initial_focus.iter().next().is_some() { + // The first sibling in the deletion direction ends the scan; JS + // steps over it (or stops at it when isolated) only when it is a + // decorator. + check_for_block = false; + } + if check_for_block { + for next_caret in + extend_caret_to_range(&initial_focus)?.iter_node_carets(RootMode::ShadowRoot) + { + if next_caret.is_child_caret() { + if !next_caret.origin().is_inline() { + focus = Some(next_caret); + } + } else if next_caret.origin().is_element() { + continue; + } + // A block decorator would be the focus too; decorators are + // not ported. + break; + } + } + } + let Some(focus) = focus else { + return Ok(false); + }; + // After this point checkForBlock is true if and only if we moved to a + // different block, so we should stop regardless of the granularity. + set_point_from_caret(selection, Which::Focus, &normalize_caret(&focus))?; + Ok(check_for_block || !is_line_boundary) +} + +/// `$needsBlockCursorBeside(node)`: whether a caret beside the node needs a +/// block cursor, as a top-level shadow root and an element that can't be +/// empty do (and a block decorator, once decorators are ported). +fn needs_block_cursor_beside(node: &NodeRef) -> bool { + if node.is_inline() { + return false; + } + if node.is_element() { + if node.get_latest().is_shadow_root() { + return !node.get_parent().is_some_and(|p| is_shadow_root_node(&p)); + } + return !node.can_be_empty(); + } + false +} + +/// `node.isParentOf(targetNode)` (`$hasAncestor(targetNode, node)`). +fn is_parent_of(node: &NodeRef, target: &NodeRef) -> bool { + let mut parent = target.get_parent(); + while let Some(p) = parent { + if &p == node { + return true; + } + parent = p.get_parent(); + } + false +} + +/// `$collapseAtStart(selection, startNode)`: lets the elements the +/// selection is at the start of handle a backward deletion there. +fn collapse_at_start(selection: &SelectionRef, start_node: &NodeRef) -> Result { + let mut node = Some(start_node.clone()); + while let Some(n) = node { + if n.is_element() { + if element_collapse_at_start(&n, selection)? { + return Ok(true); + } + if is_root_or_shadow_root(&*n.get_latest()) { + break; + } + } + if n.get_previous_sibling().is_some() { + break; + } + node = n.get_parent(); + } + Ok(false) +} + +/// `node.collapseAtStart(selection)` of the ported elements: `RootNode` +/// returns true, `ParagraphNode` removes itself when it holds only blank +/// text, and `ElementNode` returns false. +fn element_collapse_at_start( + node: &NodeRef, + _selection: &SelectionRef, +) -> Result { + if node.is_root() { + return Ok(true); + } + if !node.get_latest().is::() { + return Ok(false); + } + // `ParagraphNode.collapseAtStart()`: an empty (trimmed) paragraph is + // removed as long as there is a sibling to go to. Every child has to be + // blank text. + if node + .get_children() + .iter() + .all(|child| child.is_text() && child.get_text_content().chars().all(is_js_whitespace)) + { + if node.get_next_sibling().is_some() { + node.select_next(None, None)?; + node.remove(false)?; + return Ok(true); + } + if node.get_previous_sibling().is_some() { + node.select_previous(None, None)?; + node.remove(false)?; + return Ok(true); + } + } + Ok(false) +} + +/// `INTERNAL_$expandSelectionToWholeDocument(selection)`: widens a +/// selection that covers the whole document to the root's element points, +/// so a deletion removes the blocks rather than emptying them. +fn internal_expand_selection_to_whole_document( + selection: &SelectionRef, +) -> Result<(), LexicalError> { + let root = get_root(); + let range = selection.range().expect("a range selection"); + if root.is_empty() || !is_block_fully_selected(&root, &range)? { + return Ok(()); + } + selection.point_set(Which::Anchor, root.get_key(), 0, PointType::Element)?; + selection.point_set( + Which::Focus, + root.get_key(), + root.get_children_size(), + PointType::Element, + ) +} + +/// `$isBlockFullySelected(blockNode, selection)`: whether the selection +/// covers everything in the block. (Slot frames are not ported, so every +/// node is in the same frame.) +fn is_block_fully_selected( + block: &NodeRef, + selection: &RangeSelection, +) -> Result { + let range = get_caret_range_in_direction( + &caret_range_from_selection(selection)?, + CaretDirection::Next, + )?; + let block_start = normalize_caret( + &get_child_caret(block, CaretDirection::Next).expect("a block is an element"), + ); + let block_end = get_caret_in_direction( + &normalize_caret( + &get_child_caret(block, CaretDirection::Previous).expect("a block is an element"), + ), + CaretDirection::Next, + )?; + Ok( + compare_point_caret_next(&range.anchor, &block_start)?.is_le() + && compare_point_caret_next(&range.focus, &block_end)?.is_ge(), + ) +} + +/// `$ensureRootHasParagraph()`: gives an empty root a selected paragraph. +/// (A selection in a slot keeps it from doing so in JS; slots are not +/// ported.) +fn ensure_root_has_paragraph() -> Result<(), LexicalError> { + let root = get_root(); + if root.get_children_size() == 0 { + let paragraph = create_paragraph_node(); + root.append(std::slice::from_ref(¶graph))?; + paragraph.select(None, None)?; + } + Ok(()) +} + +/// `$updateCaretSelectionForUnicodeCharacter(selection, isBackward)`: +/// narrows a deletion within one text node to one code unit, unless the +/// text holds a surrogate pair or an emoji. +fn update_caret_selection_for_unicode_character( + selection: &SelectionRef, + is_backward: bool, +) -> Result<(), LexicalError> { + let range = selection.range().expect("a range selection"); + let anchor = &range.anchor; + let focus = &range.focus; + if anchor.key == focus.key + && anchor.point_type == PointType::Text + && focus.point_type == PointType::Text + { + // Handling of multibyte characters + let anchor_offset = anchor.offset; + let focus_offset = focus.offset; + let is_before = anchor_offset < focus_offset; + let start_offset = if is_before { + anchor_offset + } else { + focus_offset + }; + let end_offset = if is_before { + focus_offset + } else { + anchor_offset + }; + let character_offset = end_offset as isize - 1; + if start_offset as isize != character_offset { + let text = utf16::slice( + &anchor.get_node()?.get_text_content(), + start_offset, + end_offset, + ); + if should_delete_exactly_one_code_unit(&text)? { + let character_offset = character_offset as usize; + if is_backward { + selection.point_set( + Which::Focus, + &focus.key, + character_offset, + focus.point_type, + )?; + } else { + selection.point_set( + Which::Anchor, + &anchor.key, + character_offset, + anchor.point_type, + )?; + } + } + } + } + Ok(()) +} + +/// `shouldDeleteExactlyOneCodeUnit(text)`. +fn should_delete_exactly_one_code_unit(text: &str) -> Result { + // JS checks this in DEV builds, which the vectors come from. + invariant!( + utf16::len(text) > 1, + "shouldDeleteExactlyOneCodeUnit: expecting to be called only with sequences of two or more code units" + ); + Ok(!(does_contain_surrogate_pair(text) || does_contain_emoji(text))) +} + +/// `doesContainSurrogatePair(str)`: a character outside the BMP. (A half +/// pair is U+FFFD here, which JS's regex doesn't match either.) +fn does_contain_surrogate_pair(text: &str) -> bool { + text.chars().any(|c| c.len_utf16() == 2) +} + +/// `doesContainEmoji(text)`: `/\p{Emoji}/u.test(text)`. +fn does_contain_emoji(text: &str) -> bool { + text.chars().any(|c| { + let c = u32::from(c); + EMOJI + .binary_search_by(|&(lo, hi)| { + if hi < c { + std::cmp::Ordering::Less + } else if lo > c { + std::cmp::Ordering::Greater + } else { + std::cmp::Ordering::Equal + } + }) + .is_ok() + }) +} + +/// The Unicode `Emoji` property, as ranges of code points, from Node 24 +/// (Unicode 17.0) `/\p{Emoji}/u`. +const EMOJI: &[(u32, u32)] = &[ + (0x23, 0x23), + (0x2A, 0x2A), + (0x30, 0x39), + (0xA9, 0xA9), + (0xAE, 0xAE), + (0x203C, 0x203C), + (0x2049, 0x2049), + (0x2122, 0x2122), + (0x2139, 0x2139), + (0x2194, 0x2199), + (0x21A9, 0x21AA), + (0x231A, 0x231B), + (0x2328, 0x2328), + (0x23CF, 0x23CF), + (0x23E9, 0x23F3), + (0x23F8, 0x23FA), + (0x24C2, 0x24C2), + (0x25AA, 0x25AB), + (0x25B6, 0x25B6), + (0x25C0, 0x25C0), + (0x25FB, 0x25FE), + (0x2600, 0x2604), + (0x260E, 0x260E), + (0x2611, 0x2611), + (0x2614, 0x2615), + (0x2618, 0x2618), + (0x261D, 0x261D), + (0x2620, 0x2620), + (0x2622, 0x2623), + (0x2626, 0x2626), + (0x262A, 0x262A), + (0x262E, 0x262F), + (0x2638, 0x263A), + (0x2640, 0x2640), + (0x2642, 0x2642), + (0x2648, 0x2653), + (0x265F, 0x2660), + (0x2663, 0x2663), + (0x2665, 0x2666), + (0x2668, 0x2668), + (0x267B, 0x267B), + (0x267E, 0x267F), + (0x2692, 0x2697), + (0x2699, 0x2699), + (0x269B, 0x269C), + (0x26A0, 0x26A1), + (0x26A7, 0x26A7), + (0x26AA, 0x26AB), + (0x26B0, 0x26B1), + (0x26BD, 0x26BE), + (0x26C4, 0x26C5), + (0x26C8, 0x26C8), + (0x26CE, 0x26CF), + (0x26D1, 0x26D1), + (0x26D3, 0x26D4), + (0x26E9, 0x26EA), + (0x26F0, 0x26F5), + (0x26F7, 0x26FA), + (0x26FD, 0x26FD), + (0x2702, 0x2702), + (0x2705, 0x2705), + (0x2708, 0x270D), + (0x270F, 0x270F), + (0x2712, 0x2712), + (0x2714, 0x2714), + (0x2716, 0x2716), + (0x271D, 0x271D), + (0x2721, 0x2721), + (0x2728, 0x2728), + (0x2733, 0x2734), + (0x2744, 0x2744), + (0x2747, 0x2747), + (0x274C, 0x274C), + (0x274E, 0x274E), + (0x2753, 0x2755), + (0x2757, 0x2757), + (0x2763, 0x2764), + (0x2795, 0x2797), + (0x27A1, 0x27A1), + (0x27B0, 0x27B0), + (0x27BF, 0x27BF), + (0x2934, 0x2935), + (0x2B05, 0x2B07), + (0x2B1B, 0x2B1C), + (0x2B50, 0x2B50), + (0x2B55, 0x2B55), + (0x3030, 0x3030), + (0x303D, 0x303D), + (0x3297, 0x3297), + (0x3299, 0x3299), + (0x1F004, 0x1F004), + (0x1F0CF, 0x1F0CF), + (0x1F170, 0x1F171), + (0x1F17E, 0x1F17F), + (0x1F18E, 0x1F18E), + (0x1F191, 0x1F19A), + (0x1F1E6, 0x1F1FF), + (0x1F201, 0x1F202), + (0x1F21A, 0x1F21A), + (0x1F22F, 0x1F22F), + (0x1F232, 0x1F23A), + (0x1F250, 0x1F251), + (0x1F300, 0x1F321), + (0x1F324, 0x1F393), + (0x1F396, 0x1F397), + (0x1F399, 0x1F39B), + (0x1F39E, 0x1F3F0), + (0x1F3F3, 0x1F3F5), + (0x1F3F7, 0x1F4FD), + (0x1F4FF, 0x1F53D), + (0x1F549, 0x1F54E), + (0x1F550, 0x1F567), + (0x1F56F, 0x1F570), + (0x1F573, 0x1F57A), + (0x1F587, 0x1F587), + (0x1F58A, 0x1F58D), + (0x1F590, 0x1F590), + (0x1F595, 0x1F596), + (0x1F5A4, 0x1F5A5), + (0x1F5A8, 0x1F5A8), + (0x1F5B1, 0x1F5B2), + (0x1F5BC, 0x1F5BC), + (0x1F5C2, 0x1F5C4), + (0x1F5D1, 0x1F5D3), + (0x1F5DC, 0x1F5DE), + (0x1F5E1, 0x1F5E1), + (0x1F5E3, 0x1F5E3), + (0x1F5E8, 0x1F5E8), + (0x1F5EF, 0x1F5EF), + (0x1F5F3, 0x1F5F3), + (0x1F5FA, 0x1F64F), + (0x1F680, 0x1F6C5), + (0x1F6CB, 0x1F6D2), + (0x1F6D5, 0x1F6D8), + (0x1F6DC, 0x1F6E5), + (0x1F6E9, 0x1F6E9), + (0x1F6EB, 0x1F6EC), + (0x1F6F0, 0x1F6F0), + (0x1F6F3, 0x1F6FC), + (0x1F7E0, 0x1F7EB), + (0x1F7F0, 0x1F7F0), + (0x1F90C, 0x1F93A), + (0x1F93C, 0x1F945), + (0x1F947, 0x1F9FF), + (0x1FA70, 0x1FA7C), + (0x1FA80, 0x1FA8A), + (0x1FA8E, 0x1FAC6), + (0x1FAC8, 0x1FAC8), + (0x1FACD, 0x1FADC), + (0x1FADF, 0x1FAEA), + (0x1FAEF, 0x1FAF8), +]; + +/// A JS `\s` character (WhiteSpace or LineTerminator), what `trim()` and +/// `/\S/` use: Unicode `White_Space` without U+0085, plus U+FEFF. +fn is_js_whitespace(c: char) -> bool { + (c.is_whitespace() && c != '\u{85}') || c == '\u{FEFF}' +} + +/// `$removeSegment(node, isBackward, offset)`: removes the +/// whitespace-separated segment of a segmented text node at `offset`. +fn remove_segment(node: &NodeRef, is_backward: bool, offset: usize) -> Result<(), LexicalError> { + let text_content = node.get_text_content(); + // `textContent.split(/(?=\s)/g)`: split before each whitespace character + // but the first. + let mut split: Vec<&str> = Vec::new(); + let mut start = 0; + for (i, c) in text_content.char_indices() { + if i > 0 && is_js_whitespace(c) { + split.push(&text_content[start..i]); + start = i; + } + } + split.push(&text_content[start..]); + let split_length = split.len(); + let mut segment_offset = 0; + let mut restore_offset = Some(0); + for i in 0..split_length { + let text = split[i]; + let is_last = i == split_length - 1; + restore_offset = Some(segment_offset); + segment_offset += utf16::len(text); + if (is_backward && segment_offset == offset) || segment_offset > offset || is_last { + split.remove(i); + if is_last { + restore_offset = None; + } + break; + } + } + let joined = split.concat(); + let next_text_content = joined.trim_matches(is_js_whitespace); + if next_text_content.is_empty() { + node.remove(false)?; + } else { + node.set_text_content(next_text_content); + node.select(restore_offset, restore_offset)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::selection::{Selection, create_range_selection, set_selection}; + use crate::{ + CreateEditorArgs, UpdateOptions, create_editor, json, parse_serialized_node, + reset_random_key, + }; + + /// What JS does for one deletion on a headless editor. + enum Want { + /// The root's children (compact JSON) and the anchor and focus + /// after it. + Deleted(&'static str, &'static str, &'static str), + /// The update throws `window object not found` and the editor keeps + /// its state. + NoWindow, + } + use Want::{Deleted, NoWindow}; + + /// `deleteCharacter` ('C'), `deleteWord` ('W') and `deleteLine` ('L') + /// on a headless editor (`createEditor` with no root element), as JS + /// gives them in the conformance package's Node environment: anything + /// that needs the native `Selection.modify` measurement throws, and the + /// moves the model resolves (merging and removing blocks, removing a line + /// break, removing an expanded selection) succeed. Points are + /// `key:offset:t|e`. + #[test] + fn headless_deletion_matches_js() { + type Case = (char, bool, usize, &'static str, &'static str, Want); + let docs = [ + r#"[{"type":"paragraph","children":[{"type":"text","text":"abc"}]}]"#, + r#"[{"type":"paragraph","children":[{"type":"text","format":1,"text":"ab"},{"type":"text","text":"cd"}],"textFormat":1}]"#, + r#"[{"type":"paragraph","children":[{"type":"text","text":"abc"}]},{"type":"paragraph","children":[{"type":"text","text":"def"}]}]"#, + r#"[{"type":"paragraph","children":[{"type":"text","text":"ašŸ˜€b"}]}]"#, + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"},{"type":"linebreak"},{"type":"text","text":"b"}]}]"#, + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"},{"type":"tab"},{"type":"text","text":"b"}]}]"#, + r#"[{"type":"paragraph","children":[{"type":"text","text":"abcdef"}]}]"#, + r#"[{"type":"paragraph","children":[]},{"type":"paragraph","children":[{"type":"text","text":"a"}]}]"#, + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"}]},{"type":"paragraph","children":[]}]"#, + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"}]},{"type":"paragraph","children":[]},{"type":"paragraph","children":[{"type":"text","text":"b"}]}]"#, + r#"[{"type":"paragraph","children":[]},{"type":"paragraph","children":[]}]"#, + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"}]},{"type":"paragraph","children":[{"type":"linebreak"}]}]"#, + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"}]},{"type":"paragraph","children":[{"type":"linebreak"},{"type":"text","text":"b"}]}]"#, + r#"[{"type":"paragraph","children":[{"type":"text","mode":"token","text":"ab"}]},{"type":"paragraph","children":[{"type":"text","text":"cd"}]}]"#, + r#"[{"type":"paragraph","children":[{"type":"text","text":"ab"}]},{"type":"paragraph","children":[{"type":"text","mode":"segmented","text":"cd"}]}]"#, + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"},{"type":"text","mode":"segmented","text":"b c"},{"type":"text","text":"d"}]}]"#, + r#"[{"type":"paragraph","children":[]}]"#, + r#"[{"type":"paragraph","children":[]},{"type":"paragraph","children":[{"type":"text","text":"def"}]}]"#, + ]; + let cases: &[Case] = &[ + ('C', true, 0, "2:1:t", "2:1:t", NoWindow), // mid text + ('C', true, 0, "2:0:t", "2:0:t", NoWindow), // text start, only node + ('C', true, 0, "2:3:t", "2:3:t", NoWindow), // text end, only node + ('C', true, 1, "3:0:t", "3:0:t", NoWindow), // start of second text node + ('C', true, 1, "2:2:t", "2:2:t", NoWindow), // end of first text node + ( + 'C', + true, + 2, + "4:0:t", + "4:0:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"abcdef"}]}]"#, + "2:3:t", + "2:3:t", + ), + ), // start of second paragraph + ('C', true, 2, "2:3:t", "2:3:t", NoWindow), // end of first paragraph + ('C', true, 3, "2:3:t", "2:3:t", NoWindow), // after emoji + ('C', true, 3, "2:1:t", "2:1:t", NoWindow), // before emoji + ('C', true, 3, "2:2:t", "2:2:t", NoWindow), // inside surrogate pair + ( + 'C', + true, + 4, + "4:0:t", + "4:0:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"ab"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // text after linebreak + ('C', true, 4, "2:1:t", "2:1:t", NoWindow), // text before linebreak + ( + 'C', + true, + 4, + "1:2:e", + "1:2:e", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"ab"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // element point after linebreak + ('C', true, 5, "4:0:t", "4:0:t", NoWindow), // text after tab + ('C', true, 5, "3:1:t", "3:1:t", NoWindow), // inside tab end + ('C', true, 5, "2:1:t", "2:1:t", NoWindow), // text before tab + ( + 'C', + true, + 6, + "2:1:t", + "2:4:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"aef"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // non-collapsed + ( + 'C', + true, + 6, + "2:4:t", + "2:1:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"aef"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // non-collapsed backward + ( + 'C', + true, + 2, + "2:1:t", + "4:2:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"af"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // non-collapsed across blocks + ( + 'C', + true, + 2, + "2:0:t", + "4:3:t", + Deleted(r#"[{"type":"paragraph","children":[]}]"#, "5:0:e", "5:0:e"), + ), // whole document + ('C', true, 7, "1:0:e", "1:0:e", NoWindow), // empty first paragraph + ( + 'C', + true, + 8, + "3:0:e", + "3:0:e", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // empty last paragraph + ( + 'C', + true, + 9, + "3:0:e", + "3:0:e", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"}]},{"type":"paragraph","children":[{"type":"text","text":"b"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // empty middle paragraph + ( + 'C', + true, + 10, + "2:0:e", + "2:0:e", + Deleted(r#"[{"type":"paragraph","children":[]}]"#, "1:0:e", "1:0:e"), + ), // two empty paragraphs + ( + 'C', + true, + 11, + "3:0:e", + "3:0:e", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"},{"type":"linebreak"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // paragraph with only a linebreak + ('C', true, 4, "1:1:e", "1:1:e", NoWindow), // element point before linebreak + ('C', true, 12, "2:1:t", "2:1:t", NoWindow), // before a block starting with a linebreak + ( + 'C', + true, + 13, + "4:0:t", + "4:0:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","mode":"token","text":"ab"},{"type":"text","text":"cd"}]}]"#, + "2:2:t", + "2:2:t", + ), + ), // after a token block + ('C', true, 14, "2:2:t", "2:2:t", NoWindow), // before a segmented block + ( + 'C', + true, + 15, + "3:2:t", + "3:1:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"abcd"}]}]"#, + "2:2:t", + "2:2:t", + ), + ), // non-collapsed in segmented + ('C', true, 16, "1:0:e", "1:0:e", NoWindow), // only empty paragraph + ( + 'C', + true, + 17, + "3:0:t", + "3:0:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"def"}]}]"#, + "3:0:t", + "3:0:t", + ), + ), // after empty paragraph + ('C', true, 8, "2:1:t", "2:1:t", NoWindow), // before empty paragraph + ('C', false, 0, "2:1:t", "2:1:t", NoWindow), // mid text + ('C', false, 0, "2:0:t", "2:0:t", NoWindow), // text start, only node + ('C', false, 0, "2:3:t", "2:3:t", NoWindow), // text end, only node + ('C', false, 1, "3:0:t", "3:0:t", NoWindow), // start of second text node + ('C', false, 1, "2:2:t", "2:2:t", NoWindow), // end of first text node + ('C', false, 2, "4:0:t", "4:0:t", NoWindow), // start of second paragraph + ( + 'C', + false, + 2, + "2:3:t", + "2:3:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"abcdef"}]}]"#, + "2:3:t", + "2:3:t", + ), + ), // end of first paragraph + ('C', false, 3, "2:3:t", "2:3:t", NoWindow), // after emoji + ('C', false, 3, "2:1:t", "2:1:t", NoWindow), // before emoji + ('C', false, 3, "2:2:t", "2:2:t", NoWindow), // inside surrogate pair + ('C', false, 4, "4:0:t", "4:0:t", NoWindow), // text after linebreak + ( + 'C', + false, + 4, + "2:1:t", + "2:1:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"ab"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // text before linebreak + ('C', false, 4, "1:2:e", "1:2:e", NoWindow), // element point after linebreak + ('C', false, 5, "4:0:t", "4:0:t", NoWindow), // text after tab + ('C', false, 5, "3:1:t", "3:1:t", NoWindow), // inside tab end + ('C', false, 5, "2:1:t", "2:1:t", NoWindow), // text before tab + ( + 'C', + false, + 6, + "2:1:t", + "2:4:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"aef"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // non-collapsed + ( + 'C', + false, + 6, + "2:4:t", + "2:1:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"aef"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // non-collapsed backward + ( + 'C', + false, + 2, + "2:1:t", + "4:2:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"af"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // non-collapsed across blocks + ( + 'C', + false, + 2, + "2:0:t", + "4:3:t", + Deleted(r#"[{"type":"paragraph","children":[]}]"#, "5:0:e", "5:0:e"), + ), // whole document + ( + 'C', + false, + 7, + "1:0:e", + "1:0:e", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"}]}]"#, + "3:0:t", + "3:0:t", + ), + ), // empty first paragraph + ('C', false, 8, "3:0:e", "3:0:e", NoWindow), // empty last paragraph + ( + 'C', + false, + 9, + "3:0:e", + "3:0:e", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"}]},{"type":"paragraph","children":[{"type":"text","text":"b"}]}]"#, + "root:1:e", + "root:1:e", + ), + ), // empty middle paragraph + ('C', false, 10, "2:0:e", "2:0:e", NoWindow), // two empty paragraphs + ( + 'C', + false, + 11, + "3:0:e", + "3:0:e", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"}]},{"type":"paragraph","children":[]}]"#, + "3:0:e", + "3:0:e", + ), + ), // paragraph with only a linebreak + ( + 'C', + false, + 4, + "1:1:e", + "1:1:e", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"ab"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // element point before linebreak + ( + 'C', + false, + 12, + "2:1:t", + "2:1:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"},{"type":"linebreak"},{"type":"text","text":"b"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // before a block starting with a linebreak + ('C', false, 13, "4:0:t", "4:0:t", NoWindow), // after a token block + ( + 'C', + false, + 14, + "2:2:t", + "2:2:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"ab"},{"type":"text","mode":"segmented","text":"cd"}]}]"#, + "2:2:t", + "2:2:t", + ), + ), // before a segmented block + ( + 'C', + false, + 15, + "3:2:t", + "3:1:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"abcd"}]}]"#, + "2:2:t", + "2:2:t", + ), + ), // non-collapsed in segmented + ('C', false, 16, "1:0:e", "1:0:e", NoWindow), // only empty paragraph + ('C', false, 17, "3:0:t", "3:0:t", NoWindow), // after empty paragraph + ( + 'C', + false, + 8, + "2:1:t", + "2:1:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // before empty paragraph + ('W', true, 0, "2:1:t", "2:1:t", NoWindow), // mid text + ('W', true, 0, "2:0:t", "2:0:t", NoWindow), // text start, only node + ( + 'W', + true, + 2, + "4:0:t", + "4:0:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"abcdef"}]}]"#, + "2:3:t", + "2:3:t", + ), + ), // start of second paragraph + ('W', true, 4, "2:1:t", "2:1:t", NoWindow), // text before linebreak + ( + 'W', + true, + 6, + "2:1:t", + "2:4:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"aef"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // non-collapsed + ( + 'W', + true, + 6, + "2:4:t", + "2:1:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"aef"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // non-collapsed backward + ( + 'W', + true, + 2, + "2:1:t", + "4:2:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"af"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // non-collapsed across blocks + ( + 'W', + true, + 2, + "2:0:t", + "4:3:t", + Deleted(r#"[{"type":"paragraph","children":[]}]"#, "5:0:e", "5:0:e"), + ), // whole document + ( + 'W', + true, + 8, + "3:0:e", + "3:0:e", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // empty last paragraph + ( + 'W', + true, + 9, + "3:0:e", + "3:0:e", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"}]},{"type":"paragraph","children":[{"type":"text","text":"b"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // empty middle paragraph + ( + 'W', + true, + 10, + "2:0:e", + "2:0:e", + Deleted(r#"[{"type":"paragraph","children":[]}]"#, "1:0:e", "1:0:e"), + ), // two empty paragraphs + ( + 'W', + true, + 11, + "3:0:e", + "3:0:e", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"},{"type":"linebreak"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // paragraph with only a linebreak + ( + 'W', + true, + 13, + "4:0:t", + "4:0:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","mode":"token","text":"ab"},{"type":"text","text":"cd"}]}]"#, + "2:2:t", + "2:2:t", + ), + ), // after a token block + ( + 'W', + true, + 15, + "3:2:t", + "3:1:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"abcd"}]}]"#, + "2:2:t", + "2:2:t", + ), + ), // non-collapsed in segmented + ( + 'W', + true, + 17, + "3:0:t", + "3:0:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"def"}]}]"#, + "3:0:t", + "3:0:t", + ), + ), // after empty paragraph + ('W', false, 0, "2:1:t", "2:1:t", NoWindow), // mid text + ('W', false, 0, "2:0:t", "2:0:t", NoWindow), // text start, only node + ( + 'W', + false, + 2, + "2:3:t", + "2:3:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"abcdef"}]}]"#, + "2:3:t", + "2:3:t", + ), + ), // end of first paragraph + ('W', false, 4, "2:1:t", "2:1:t", NoWindow), // text before linebreak + ( + 'W', + false, + 6, + "2:1:t", + "2:4:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"aef"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // non-collapsed + ( + 'W', + false, + 6, + "2:4:t", + "2:1:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"aef"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // non-collapsed backward + ( + 'W', + false, + 2, + "2:1:t", + "4:2:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"af"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // non-collapsed across blocks + ( + 'W', + false, + 2, + "2:0:t", + "4:3:t", + Deleted(r#"[{"type":"paragraph","children":[]}]"#, "5:0:e", "5:0:e"), + ), // whole document + ( + 'W', + false, + 7, + "1:0:e", + "1:0:e", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"}]}]"#, + "3:0:t", + "3:0:t", + ), + ), // empty first paragraph + ( + 'W', + false, + 9, + "3:0:e", + "3:0:e", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"}]},{"type":"paragraph","children":[{"type":"text","text":"b"}]}]"#, + "5:0:t", + "5:0:t", + ), + ), // empty middle paragraph + ( + 'W', + false, + 12, + "2:1:t", + "2:1:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"},{"type":"linebreak"},{"type":"text","text":"b"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // before a block starting with a linebreak + ( + 'W', + false, + 14, + "2:2:t", + "2:2:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"ab"},{"type":"text","mode":"segmented","text":"cd"}]}]"#, + "2:2:t", + "2:2:t", + ), + ), // before a segmented block + ( + 'W', + false, + 15, + "3:2:t", + "3:1:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"abcd"}]}]"#, + "2:2:t", + "2:2:t", + ), + ), // non-collapsed in segmented + ( + 'W', + false, + 8, + "2:1:t", + "2:1:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // before empty paragraph + ('L', true, 0, "2:1:t", "2:1:t", NoWindow), // mid text + ('L', true, 0, "2:0:t", "2:0:t", NoWindow), // text start, only node + ( + 'L', + true, + 2, + "4:0:t", + "4:0:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"abcdef"}]}]"#, + "2:3:t", + "2:3:t", + ), + ), // start of second paragraph + ('L', true, 4, "2:1:t", "2:1:t", NoWindow), // text before linebreak + ( + 'L', + true, + 6, + "2:1:t", + "2:4:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"aef"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // non-collapsed + ( + 'L', + true, + 6, + "2:4:t", + "2:1:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"aef"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // non-collapsed backward + ( + 'L', + true, + 8, + "3:0:e", + "3:0:e", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // empty last paragraph + ( + 'L', + true, + 9, + "3:0:e", + "3:0:e", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"}]},{"type":"paragraph","children":[{"type":"text","text":"b"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // empty middle paragraph + ( + 'L', + true, + 10, + "2:0:e", + "2:0:e", + Deleted(r#"[{"type":"paragraph","children":[]}]"#, "1:0:e", "1:0:e"), + ), // two empty paragraphs + ( + 'L', + true, + 11, + "3:0:e", + "3:0:e", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"},{"type":"linebreak"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // paragraph with only a linebreak + ( + 'L', + true, + 13, + "4:0:t", + "4:0:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","mode":"token","text":"ab"},{"type":"text","text":"cd"}]}]"#, + "2:2:t", + "2:2:t", + ), + ), // after a token block + ( + 'L', + true, + 15, + "3:2:t", + "3:1:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"abcd"}]}]"#, + "2:2:t", + "2:2:t", + ), + ), // non-collapsed in segmented + ( + 'L', + true, + 17, + "3:0:t", + "3:0:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"def"}]}]"#, + "3:0:t", + "3:0:t", + ), + ), // after empty paragraph + ('L', false, 0, "2:1:t", "2:1:t", NoWindow), // mid text + ('L', false, 0, "2:0:t", "2:0:t", NoWindow), // text start, only node + ( + 'L', + false, + 2, + "2:3:t", + "2:3:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"abcdef"}]}]"#, + "2:3:t", + "2:3:t", + ), + ), // end of first paragraph + ('L', false, 4, "2:1:t", "2:1:t", NoWindow), // text before linebreak + ( + 'L', + false, + 6, + "2:1:t", + "2:4:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"aef"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // non-collapsed + ( + 'L', + false, + 6, + "2:4:t", + "2:1:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"aef"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // non-collapsed backward + ( + 'L', + false, + 7, + "1:0:e", + "1:0:e", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"}]}]"#, + "3:0:t", + "3:0:t", + ), + ), // empty first paragraph + ( + 'L', + false, + 9, + "3:0:e", + "3:0:e", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"}]},{"type":"paragraph","children":[{"type":"text","text":"b"}]}]"#, + "root:1:e", + "root:1:e", + ), + ), // empty middle paragraph + ( + 'L', + false, + 12, + "2:1:t", + "2:1:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"},{"type":"linebreak"},{"type":"text","text":"b"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // before a block starting with a linebreak + ( + 'L', + false, + 14, + "2:2:t", + "2:2:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"ab"},{"type":"text","mode":"segmented","text":"cd"}]}]"#, + "2:2:t", + "2:2:t", + ), + ), // before a segmented block + ( + 'L', + false, + 15, + "3:2:t", + "3:1:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"abcd"}]}]"#, + "2:2:t", + "2:2:t", + ), + ), // non-collapsed in segmented + ( + 'L', + false, + 8, + "2:1:t", + "2:1:t", + Deleted( + r#"[{"type":"paragraph","children":[{"type":"text","text":"a"}]}]"#, + "2:1:t", + "2:1:t", + ), + ), // before empty paragraph + ]; + let point = |spec: &str| { + let mut parts = spec.split(':'); + let key = parts.next().unwrap().to_owned(); + let offset: usize = parts.next().unwrap().parse().unwrap(); + let point_type = match parts.next().unwrap() { + "t" => PointType::Text, + _ => PointType::Element, + }; + Point::new(key, offset, point_type) + }; + let show = |p: &Point| { + let t = match p.point_type { + PointType::Text => "t", + PointType::Element => "e", + }; + format!("{}:{}:{t}", p.key, p.offset) + }; + let discrete = || UpdateOptions { + discrete: true, + ..UpdateOptions::default() + }; + for (method, is_backward, doc, anchor, focus, want) in cases { + let label = format!("{method} {is_backward} doc {doc} {anchor} {focus}"); + reset_random_key(); + let editor = create_editor(CreateEditorArgs::default()); + let children = json::parse(docs[*doc]).unwrap(); + editor + .update( + move || { + let nodes: Vec = children + .as_array() + .unwrap() + .iter() + .map(|n| parse_serialized_node(n).unwrap()) + .collect(); + get_root().append(&nodes) + }, + discrete(), + ) + .unwrap(); + let before = json::stringify(&editor.get_editor_state().to_json(true)); + let (anchor, focus, method, is_backward) = + (point(anchor), point(focus), *method, *is_backward); + let result = editor.update( + move || { + let selection = create_range_selection(); + selection.point_set( + Which::Anchor, + &anchor.key, + anchor.offset, + anchor.point_type, + )?; + selection.point_set( + Which::Focus, + &focus.key, + focus.offset, + focus.point_type, + )?; + set_selection(Some(selection.clone())); + match method { + 'C' => selection.delete_character(is_backward), + 'W' => selection.delete_word(is_backward), + _ => selection.delete_line(is_backward), + } + }, + discrete(), + ); + let state = editor.get_editor_state(); + let compact = json::stringify(&state.to_json(true)).unwrap(); + match want { + NoWindow => { + assert_eq!( + result.map_err(|e| e.message().to_owned()), + Err("window object not found".to_owned()), + "{label}" + ); + assert_eq!(Some(compact), before, "{label}"); + } + Deleted(children, want_anchor, want_focus) => { + assert_eq!(result, Ok(()), "{label}"); + assert_eq!( + compact, + format!(r#"{{"root":{{"type":"root","children":{children}}}}}"#), + "{label}" + ); + let Some(Selection::Range(range)) = state.selection().map(|s| s.get()) else { + panic!("{label}: no range selection"); + }; + assert_eq!( + (show(&range.anchor), show(&range.focus)), + (want_anchor.to_string(), want_focus.to_string()), + "{label}" + ); + } + } + } + } +} diff --git a/rust/lexical-editor/src/selection_editing.rs b/rust/lexical-editor/src/selection_editing.rs new file mode 100644 index 00000000000..7d4b782042e --- /dev/null +++ b/rust/lexical-editor/src/selection_editing.rs @@ -0,0 +1,105 @@ +//! `RangeSelection`'s editing methods from `LexicalSelection.ts`: the +//! pending format and style, and `removeText`, which the other editing +//! methods build on. + +use crate::LexicalError; +use crate::caret_utils::{ + SliceMode, caret_range_from_selection, remove_text_from_caret_range, + update_range_selection_from_caret_range, +}; +use crate::nodes::text::{to_int32, toggle_text_format_type}; +use crate::selection::{SelectionRef, get_selection, set_selection}; + +impl SelectionRef { + /// `toggleFormat(format)`: toggles a format on the selection's pending + /// format, the one text typed at a collapsed selection gets. + pub fn toggle_format(&self, format_type: &str) { + self.with_range(|r| { + r.format = f64::from(toggle_text_format_type( + to_int32(r.format), + format_type, + None, + )); + r.dirty = true; + }); + } + + /// `setFormat(format)`: sets the selection's pending format. + pub fn set_format(&self, format: f64) { + self.with_range(|r| { + r.format = format; + r.dirty = true; + }); + } + + /// `setStyle(style)`: sets the selection's pending style. + pub fn set_style(&self, style: &str) { + self.with_range(|r| { + r.style = style.to_owned(); + r.dirty = true; + }); + } + + /// `hasFormat(type)`: whether the pending format has `format_type`. + pub fn has_format(&self, format_type: &str) -> bool { + let flag = toggle_text_format_type(0, format_type, None); + self.with_range(|r| to_int32(r.format) & flag != 0) + .unwrap_or(false) + } + + /// `removeText()`: removes the selected content and collapses the + /// selection where it was. When the caret lands in another node, the + /// pending format and style come from that node. + pub fn remove_text(&self) -> Result<(), LexicalError> { + let Some(range) = self.range() else { + return Ok(()); + }; + let is_current_selection = get_selection().is_some_and(|s| s.ptr_eq(self)); + let previous_anchor_key = range.anchor.key.clone(); + let new_range = remove_text_from_caret_range( + &caret_range_from_selection(&range)?, + SliceMode::RemoveEmptySlices, + )?; + update_range_selection_from_caret_range(self, &new_range)?; + if self.range().is_some_and(|r| r.is_collapsed()) { + internal_refresh_selection_format_and_style(self, &previous_anchor_key)?; + } + if is_current_selection && !get_selection().is_some_and(|s| s.ptr_eq(self)) { + set_selection(Some(self.clone())); + } + Ok(()) + } +} + +/// `$internalRefreshSelectionFormatAndStyle(selection, previousAnchorKey)`: +/// when the anchor moved to another node, takes the pending format and +/// style from the node it is in now. +pub(crate) fn internal_refresh_selection_format_and_style( + selection: &SelectionRef, + previous_anchor_key: &str, +) -> Result<(), LexicalError> { + let Some(range) = selection.range() else { + return Ok(()); + }; + if range.anchor.key == previous_anchor_key { + return Ok(()); + } + let anchor_node = range.anchor.get_node()?; + let (format, style) = if anchor_node.is_text() { + let data = anchor_node.text_data(); + (data.get_format(), data.get_style().to_owned()) + } else if anchor_node.is_element() { + let data = anchor_node.element_data(); + (data.get_text_format(), data.get_text_style().to_owned()) + } else { + (0.0, String::new()) + }; + selection.with_range(|r| { + if r.format != format || r.style != style { + r.format = format; + r.style = style; + r.dirty = true; + } + }); + Ok(()) +} diff --git a/rust/lexical-editor/src/selection_format.rs b/rust/lexical-editor/src/selection_format.rs new file mode 100644 index 00000000000..d25afb2a745 --- /dev/null +++ b/rust/lexical-editor/src/selection_format.rs @@ -0,0 +1,419 @@ +//! `formatText` from `LexicalSelection.ts` (`$formatText`, +//! `$updateTextFormat`, `$setTextFormat`, `$getCharacterOffsets`), with +//! `$splitTextPointCaretSlice` from `caret/LexicalCaretUtils.ts`, which +//! isolates the selected part of a text node. + +use crate::LexicalError; +use crate::caret::TextPointCaretSlice; +use crate::caret_utils::{caret_range_from_selection, get_text_point_caret_slice_for_node}; +use crate::constants::{TEXT_TYPE_TO_FORMAT, lookup}; +use crate::node::NodeRef; +use crate::nodes::tab::TabNode; +use crate::nodes::text::{to_int32, toggle_text_format_type}; +use crate::selection::{ + PointType, SelectionRef, Which, get_selection, selection_touches_element, + update_element_selection_on_create_delete_node, +}; +use crate::utf16; + +impl SelectionRef { + /// `selection.formatText(formatType, alignWithFormat)` + /// (`$formatText(selection, formatType, alignWithFormat)`): toggles + /// `format_type` on the selected text, splitting text nodes at the + /// selection's edges, and aligns every node with the same target. On a + /// collapsed selection (or one without text) it toggles the pending + /// format instead. + /// + /// Without `align_with_format`, a range selection aligns with its + /// pending format toggled, so the type is added unless the pending + /// format has it; a node selection toggles each node on its own. + pub fn format_text( + &self, + format_type: &str, + align_with_format: Option, + ) -> Result<(), LexicalError> { + let effective_align = match (align_with_format, self.range()) { + (None, Some(range)) => Some(f64::from(toggle_text_format_type( + to_int32(range.format), + format_type, + None, + ))), + (align, _) => align, + }; + update_text_format(self, |format| { + toggle_format_number(format, format_type, effective_align) + }) + } + + /// `$setTextFormat(selection, formats)`: sets (`true`) or clears + /// (`false`) each format type on the selection, in order, so a later + /// entry wins over an exclusive earlier one (subscript and superscript, + /// the case transforms). + pub fn set_text_format(&self, formats: &[(&str, bool)]) -> Result<(), LexicalError> { + if formats.is_empty() { + return Ok(()); + } + update_text_format(self, |mut format| { + for &(format_type, value) in formats { + let align = if value { + lookup(TEXT_TYPE_TO_FORMAT, format_type).unwrap_or(0) + } else { + 0 + }; + format = toggle_format_number(format, format_type, Some(f64::from(align))); + } + format + }) + } + + /// `$getCharacterOffsets(selection)`: the anchor's and focus's offsets + /// in characters; an element point counts as the end of the element's + /// text when it is after its last child, and 0 otherwise. A collapsed + /// element selection and a node selection give `(0, 0)`. + pub fn get_character_offsets(&self) -> Result<(usize, usize), LexicalError> { + let Some(range) = self.range() else { + return Ok((0, 0)); + }; + let (anchor, focus) = (&range.anchor, &range.focus); + if anchor.point_type == PointType::Element + && focus.point_type == PointType::Element + && anchor.key == focus.key + && anchor.offset == focus.offset + { + return Ok((0, 0)); + } + // `getCharacterOffset(point)` + let character_offset = |point: &crate::selection::Point| { + if point.point_type == PointType::Text { + return Ok(point.offset); + } + let parent = point.get_node()?; + Ok(if point.offset == parent.get_children_size() { + utf16::len(&parent.get_text_content()) + } else { + 0 + }) + }; + Ok((character_offset(anchor)?, character_offset(focus)?)) + } +} + +/// `toggleTextFormatType(format, type, alignWithFormat)` on the JS number: +/// when `format` already agrees with the alignment it comes back unchanged +/// (not as `ToInt32`), as in JS. +fn toggle_format_number(format: f64, format_type: &str, align_with_format: Option) -> f64 { + let align = align_with_format.map(to_int32); + let active = lookup(TEXT_TYPE_TO_FORMAT, format_type).unwrap_or(0); + if let Some(align) = align + && to_int32(format) & active == align & active + { + return format; + } + f64::from(toggle_text_format_type( + to_int32(format), + format_type, + align, + )) +} + +/// `$isTokenOrSegmented(node)`: a tab, or a text node in token or segmented +/// mode. +fn is_token_or_segmented(node: &NodeRef) -> bool { + node.get_latest().is::() || node.is_token() || node.is_segmented() +} + +/// `$updateTextFormat(selection, applyFormat)`: applies `apply_format` to +/// every formattable node, isolating partially selected text with caret +/// slices. Elements take it on their `textFormat`. When no text is +/// selected, it applies to the selection's pending format. +fn update_text_format( + selection: &SelectionRef, + apply_format: impl Fn(f64) -> f64, +) -> Result<(), LexicalError> { + let Some(range) = selection.range() else { + // A node selection. `$isInlineFormattable` holds for text nodes + // (DecoratorTextNode is not ported). + for node in selection.get_nodes()? { + if node.is_text() { + node.set_format(apply_format(node.text_data().get_format())); + } + } + return Ok(()); + }; + + let nodes = if range.is_collapsed() { + Vec::new() + } else { + selection.get_nodes()? + }; + let slices = if nodes.is_empty() { + Vec::new() + } else { + caret_range_from_selection(&range)? + .get_text_slices() + .to_vec() + }; + let mut has_text = false; + let mut skipped_start: Option = None; + let mut first_text: Option = None; + let mut first_format: Option = None; + let mut last_format = 0.0; + for node in &nodes { + if node.is_text() { + // Main's multi-text-node path moves a skipped text-edge start to + // the first formatted node, including when the final slice is + // empty. + if let (Some(which), Some(first)) = (skipped_start, &first_text) { + selection.point_set(which, first.get_key(), 0, PointType::Text)?; + skipped_start = None; + } + let slice = get_text_point_caret_slice_for_node(&slices, node); + let empty_slice = slice.is_some_and(|s| s.distance == 0); + if !has_text && empty_slice { + let start = if selection.is_backward()? { + Which::Focus + } else { + Which::Anchor + }; + let point = selection.point(start).expect("a range selection"); + if point.point_type == PointType::Text && point.key == node.get_key() { + skipped_start = Some(start); + } + } + has_text = true; + if empty_slice { + continue; + } + let next_format = apply_format(node.text_data().get_format()); + let original_size = if skipped_start.is_some() { + node.get_text_content_size() + } else { + 0 + }; + let replacement = match slice { + Some(slice) if !is_token_or_segmented(node) => { + split_text_point_caret_slice(slice, Some(selection))? + } + _ => Some(node.clone()), + }; + if let Some(replacement) = replacement { + replacement.set_format(next_format); + if first_format.is_none() { + first_format = Some(next_format); + first_text = Some(replacement.clone()); + // A single partial TextNode also repins the text + // endpoints after splitting. A whole-node or atomic-node + // selection keeps them. + if let Some(which) = skipped_start + && replacement.get_text_content_size() != original_size + { + selection.point_set(which, replacement.get_key(), 0, PointType::Text)?; + skipped_start = None; + } + } + last_format = next_format; + } + } else if node.is_element() { + node.set_text_format(apply_format(node.element_data().get_text_format())); + } + // `$isInlineFormattable` holds for no other node here + // (DecoratorTextNode is not ported). + } + if !has_text { + let format = apply_format(range.format); + selection.set_format(format); + // `$setCompositionKey(null)`: composition is not ported. + } else if let Some(first_format) = first_format { + let format = f64::from(to_int32(first_format) | to_int32(last_format)); + selection.with_range(|r| r.format = format); + } + Ok(()) +} + +/// `$splitTextPointCaretSlice(slice, selection)`: splits the slice's text +/// node at both ends of the slice and returns the node holding exactly the +/// slice, or `None` for an empty slice (nothing changes). With +/// `selection`, its endpoints in the slice, including an element point just +/// before the node, stay on the returned node. +pub(crate) fn split_text_point_caret_slice( + slice: &TextPointCaretSlice, + selection: Option<&SelectionRef>, +) -> Result, LexicalError> { + let origin = slice.caret.origin().clone(); + let (start, end) = slice.get_slice_indices(); + if start == end { + return Ok(None); + } + if start == 0 && end == origin.get_text_content_size() { + return Ok(Some(origin)); + } + let parent = origin.get_parent(); + // Only element-point repair needs the sibling index. Text-only ranges + // avoid an extra linear scan before splitText's own insertion lookup. + let index = match (selection, &parent) { + (Some(selection), Some(parent)) if selection_touches_element(selection, parent) => { + origin.get_index_within_parent() + } + _ => None, + }; + // Capture offsets before splitText repairs the active selection. In + // particular, an element point before a prefix must stay on that prefix. + let mut points = Vec::new(); + if let Some(selection) = selection { + for which in [Which::Anchor, Which::Focus] { + let point = selection.point(which).expect("a range selection"); + let offset = if point.key == origin.get_key() && point.point_type == PointType::Text { + Some(point.offset.saturating_sub(start)) + } else if parent.as_ref().is_some_and(|p| point.key == p.get_key()) + && Some(point.offset) == index + { + Some(0) + } else { + None + }; + points.push((which, offset)); + } + } + let split_nodes = origin.split_text(&[start as i64, end as i64])?; + let node = split_nodes[if start == 0 { 0 } else { 1 }].clone(); + if let (Some(parent), Some(selection), Some(index)) = (&parent, selection, index) + && !get_selection().is_some_and(|s| s.ptr_eq(selection)) + { + update_element_selection_on_create_delete_node( + selection, + parent, + index, + split_nodes.len() as isize - 1, + )?; + } + if let Some(selection) = selection { + for (which, offset) in points { + if let Some(offset) = offset { + selection.point_set(which, node.get_key(), offset, PointType::Text)?; + } + } + } + Ok(Some(node)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::selection::{create_range_selection, set_selection}; + use crate::{ + CreateEditorArgs, Editor, UpdateOptions, create_editor, get_root, json, + parse_serialized_node, reset_random_key, + }; + + fn discrete() -> UpdateOptions { + UpdateOptions { + discrete: true, + ..UpdateOptions::default() + } + } + + type Spec = (&'static str, usize, PointType); + + fn select(anchor: Spec, focus: Spec) -> Result { + let selection = create_range_selection(); + selection.point_set(Which::Anchor, anchor.0, anchor.1, anchor.2)?; + selection.point_set(Which::Focus, focus.0, focus.1, focus.2)?; + set_selection(Some(selection.clone())); + Ok(selection) + } + + /// The compact JSON, and the selection's anchor, focus and format. + fn snapshot(editor: &Editor) -> (String, (Spec, Spec, f64)) { + let state = editor.get_editor_state(); + let text = json::stringify(&state.to_json(true)).unwrap(); + let range = state + .read(|| get_selection().and_then(|s| s.range())) + .unwrap(); + let spec = |p: &crate::selection::Point| -> Spec { + (p.key.clone().leak(), p.offset, p.point_type) + }; + ( + text, + (spec(&range.anchor), spec(&range.focus), range.format), + ) + } + + /// `formatText` with and without `alignWithFormat` over a token node, + /// a tab, element points and a skipped text-edge start, + /// `$setTextFormat` with exclusive formats, and `$getCharacterOffsets`, + /// as JS 0.53.0 gives them. (A collapsed toggle without alignment and a + /// toggle over mixed formats are in `scripts/selection`.) + #[test] + fn format_text_matches_js() { + use PointType::{Element as E, Text as T}; + reset_random_key(); + let editor = create_editor(CreateEditorArgs::default()); + editor + .update( + || { + let nodes = json::parse( + r#"[{"type":"paragraph","children":[{"type":"text","text":"abc","format":1},{"type":"text","text":"de","mode":"token"},{"type":"text","text":"fgh","format":2},{"type":"tab","text":"\t"}]},{"type":"paragraph","textFormat":4,"children":[{"type":"text","text":"ij"}]},{"type":"paragraph","children":[]}]"#, + ) + .unwrap(); + let nodes: Vec = nodes + .as_array() + .unwrap() + .iter() + .map(|n| parse_serialized_node(n).unwrap()) + .collect(); + get_root().append(&nodes) + }, + discrete(), + ) + .unwrap(); + let after_align = r#"{"root":{"type":"root","children":[{"type":"paragraph","children":[{"type":"text","format":1,"text":"abc"},{"type":"text","format":3,"mode":"token","text":"de"},{"type":"text","format":3,"text":"fgh"},{"type":"tab","format":1}],"textFormat":1},{"type":"paragraph","children":[{"type":"text","format":1,"text":"ij"}],"textFormat":1},{"type":"paragraph","children":[],"textFormat":1}]}}"#; + let after_set = r#"{"root":{"type":"root","children":[{"type":"paragraph","children":[{"type":"text","format":1,"text":"a"},{"type":"text","format":64,"text":"bc"},{"type":"text","format":66,"mode":"token","text":"de"},{"type":"text","format":66,"text":"fgh"},{"type":"tab","format":64}],"textFormat":1},{"type":"paragraph","children":[{"type":"text","format":64,"text":"i"},{"type":"text","format":1,"text":"j"}],"textFormat":64},{"type":"paragraph","children":[],"textFormat":1}]}}"#; + type Step = fn() -> Result<(), LexicalError>; + let steps: [(Step, &str, (Spec, Spec, f64)); 4] = [ + ( + // Backward, from the end of "abc" (an empty slice) over a + // token node to inside "fgh", which is already italic. + || select(("4", 1, T), ("2", 3, T))?.format_text("italic", None), + r#"{"root":{"type":"root","children":[{"type":"paragraph","children":[{"type":"text","format":1,"text":"abc"},{"type":"text","format":2,"mode":"token","text":"de"},{"type":"text","format":2,"text":"fgh"},{"type":"tab"}],"textFormat":1},{"type":"paragraph","children":[{"type":"text","text":"ij"}]},{"type":"paragraph","children":[]}]}}"#, + (("4", 1, T), ("3", 0, T), 2.0), + ), + ( + || select(("1", 1, E), ("8", 0, E))?.format_text("bold", Some(1.0)), + after_align, + (("1", 1, E), ("8", 0, E), 3.0), + ), + ( + || select(("7", 1, T), ("7", 1, T))?.format_text("underline", Some(8.0)), + after_align, + (("7", 1, T), ("7", 1, T), 8.0), + ), + ( + || { + select(("2", 1, T), ("7", 1, T))?.set_text_format(&[ + ("bold", false), + ("subscript", true), + ("superscript", true), + ]) + }, + after_set, + (("10", 0, T), ("7", 1, T), 64.0), + ), + ]; + for (i, (step, state, selection)) in steps.into_iter().enumerate() { + editor.update(step, discrete()).unwrap(); + assert_eq!(snapshot(&editor), (state.to_owned(), selection), "step {i}"); + } + editor + .update( + || { + let offsets = select(("1", 5, E), ("7", 1, T))?.get_character_offsets()?; + assert_eq!(offsets, (9, 1)); + let offsets = select(("1", 2, E), ("1", 2, E))?.get_character_offsets()?; + assert_eq!(offsets, (0, 0)); + Ok(()) + }, + discrete(), + ) + .unwrap(); + } +} diff --git a/rust/lexical-editor/src/selection_insert_nodes.rs b/rust/lexical-editor/src/selection_insert_nodes.rs new file mode 100644 index 00000000000..5a5b3785ec5 --- /dev/null +++ b/rust/lexical-editor/src/selection_insert_nodes.rs @@ -0,0 +1,836 @@ +//! `insertNodes`, `insertParagraph`, `insertLineBreak` and +//! `insertRawText` from `LexicalSelection.ts`, for both selection kinds, +//! with `$insertNodes`, `$generateNodesFromRawText` and `$selectAll`. +//! +//! Slots, `DecoratorNode`, `CodeNode` and declared node structure +//! (`NodeStructureExtension`) are not ported, so the branches for a slot +//! value, a code block, a decorator and a declared block container do +//! nothing here. + +use crate::LexicalError; +use crate::caret::CaretDirection; +use crate::caret_utils::{caret_from_point, normalize_caret, set_point_from_caret}; +use crate::node::NodeRef; +use crate::nodes::element::is_root_or_shadow_root; +use crate::nodes::line_break::{LineBreakNode, create_line_break_node}; +use crate::nodes::paragraph::create_paragraph_node; +use crate::nodes::tab::create_tab_node; +use crate::nodes::text::create_text_node; +use crate::selection::{ + Point, PointType, RangeSelection, Selection, SelectionRef, Which, get_selection, set_selection, +}; +use crate::updates::{get_active_editor, get_active_editor_state}; +use crate::utils::{get_root, is_block}; +use std::rc::Rc; + +impl SelectionRef { + /// `selection.insertNodes(nodes)`: `RangeSelection.insertNodes`, which + /// replaces the selected content with `nodes`, splitting blocks as + /// needed, and selects the end of what it inserted; or + /// `NodeSelection.insertNodes`, which inserts them after the last + /// selected node and removes the selected nodes. + pub fn insert_nodes(&self, nodes: Vec) -> Result<(), LexicalError> { + if self.is_node() { + node_selection_insert_nodes(self, &nodes) + } else { + range_selection_insert_nodes(self, &nodes) + } + } + + /// `selection.insertRawText(text)`: on a range selection, inserts the + /// text with its tabs and line breaks as nodes; on a node selection, + /// nothing. + pub fn insert_raw_text(&self, text: &str) -> Result<(), LexicalError> { + if self.is_node() { + // `NodeSelection.insertRawText`: "Do nothing?" + return Ok(()); + } + self.insert_nodes(generate_nodes_from_raw_text(text)) + } + + /// `selection.insertParagraph()`: splits the block at the selection, + /// and returns the new block, if one was inserted, with the selection at + /// its start. + pub fn insert_paragraph(&self) -> Result, LexicalError> { + // The root/shadow-root branch below only splices a paragraph in, so + // the selected content has to go first. + if !is_collapsed(self) { + self.remove_text()?; + } + let anchor = anchor_of(self); + let anchor_node = anchor.get_node()?; + if anchor.point_type == PointType::Element && is_block_container(&anchor_node) { + let paragraph = create_paragraph_node(); + anchor_node.splice(anchor.offset, 0, std::slice::from_ref(¶graph))?; + paragraph.select(None, None)?; + return Ok(Some(paragraph)); + } + let (_, index) = remove_text_and_split_block(self, false)?; + let block = find_matching_parent(&anchor_of(self).get_node()?, is_block); + // A block that is a slot value has no room for a sibling paragraph, + // once slots are ported. + let Some(block) = block.filter(NodeRef::is_element) else { + return Err(LexicalError::new( + "Expected ancestor to be a block ElementNode", + )); + }; + let nodes_to_insert = match block.get_child_at_index(index) { + Some(first_to_append) => { + let mut nodes = vec![first_to_append.clone()]; + nodes.extend(get_next_siblings(&first_to_append)); + nodes + } + None => Vec::new(), + }; + match block + .insert_new_after(self, false)? + .filter(NodeRef::is_element) + { + Some(new_block) => { + new_block.append(&nodes_to_insert)?; + new_block.select_start()?; + Ok(Some(new_block)) + } + // A block that can't be split, such as a CodeNode. + None => Ok(None), + } + } + + /// `selection.insertLineBreak(selectStart)`: inserts a line break, + /// with the selection after it, or before it when `select_start`. + pub fn insert_line_break(&self, select_start: bool) -> Result<(), LexicalError> { + let line_break = create_line_break_node(); + self.insert_nodes(vec![line_break.clone()])?; + // this is used in MacOS with the command 'ctrl-O' (openLineBreak) + if select_start { + let parent = line_break.get_parent_or_throw()?; + let index = line_break.get_index_within_parent(); + parent.select(index, index)?; + } + Ok(()) + } +} + +/// A range selection's anchor. +fn anchor_of(selection: &SelectionRef) -> Point { + selection.point(Which::Anchor).expect("a range selection") +} + +/// `isCollapsed()` of a range selection. +fn is_collapsed(selection: &SelectionRef) -> bool { + selection.with_range(|r| r.is_collapsed()).unwrap_or(false) +} + +/// `NodeSelection.insertNodes(nodes)`. +fn node_selection_insert_nodes( + selection: &SelectionRef, + nodes: &[NodeRef], +) -> Result<(), LexicalError> { + // Slotted nodes would be skipped here, once slots are ported. + let selected_nodes = selection.get_nodes()?; + let Some(last_selected_node) = selected_nodes.last() else { + return Ok(()); + }; + // Insert nodes + let selection_at_end = if last_selected_node.is_text() { + last_selected_node.select(None, None)? + } else { + let index = last_selected_node + .get_index_within_parent() + .map_or(0, |i| i + 1); + last_selected_node + .get_parent_or_throw()? + .select(Some(index), Some(index))? + }; + selection_at_end.insert_nodes(nodes.to_vec())?; + // Remove selected nodes + for node in &selected_nodes { + node.remove(false)?; + } + Ok(()) +} + +/// `RangeSelection.insertNodes(nodes)`. +fn range_selection_insert_nodes( + selection: &SelectionRef, + nodes: &[NodeRef], +) -> Result<(), LexicalError> { + if nodes.is_empty() { + return Ok(()); + } + if !is_collapsed(selection) { + selection.remove_text()?; + } + // An anchor on a slot value root redirects into the slot's subtree, once + // slots are ported. + let anchor = anchor_of(selection); + let anchor_node = anchor.get_node()?; + + // The anchor is an element point directly on a block container: splice + // the nodes in at the anchor offset, inline runs wrapped in a block. + if anchor.point_type == PointType::Element && is_block_container(&anchor_node) { + let blocks_parent = wrap_inline_nodes(nodes)?; + let node_to_select = blocks_parent.get_last_descendant(); + anchor_node.splice(anchor.offset, 0, &blocks_parent.get_children())?; + if let Some(node_to_select) = node_to_select { + node_to_select.select_end()?; + } + return Ok(()); + } + + let first_point_which = if selection.is_backward()? { + Which::Focus + } else { + Which::Anchor + }; + let first_point = selection + .point(first_point_which) + .expect("a range selection"); + let mut first_node = first_point.get_node()?; + let mut first_block = find_matching_parent(&first_node, is_block); + // JS compares `firstBlock` with `lastInsertedBlock` by object identity + // below. The object it holds is the latest version only while no + // `getWritable` clones the node, which happens unless the node is + // already writable in this update. + let mut first_block_is_latest = first_block.as_ref().is_some_and(is_clone_not_needed); + + let last = nodes.last().expect("not empty"); + + // CASE 1: insert inside a code block. CodeNode (`__language`) is not + // ported. + + // CASE 2: All elements of the array are inline + // (A DecoratorNode that is not inline counts too, once it is ported.) + let not_inline = |node: &NodeRef| node.is_element() && !node.is_inline(); + + if !nodes.iter().any(not_inline) { + let Some(first_block) = first_block.filter(NodeRef::is_element) else { + return Err(expected_block_ancestor(&first_node, "block ElementNode")); + }; + // The split walk normally ends on firstBlock, but it stops early on + // an inline ElementNode that cannot be split (#6477). + let (container, index) = remove_text_and_split_block(selection, true)?; + let insertion_parent = if container.is_element() { + container + } else { + first_block + }; + insertion_parent.splice(index, 0, nodes)?; + last.select_end()?; + return Ok(()); + } + + // CASE 3a: there is non-inline content but no block ancestor to insert + // it relative to: a malformed document. JS inserts the blocks with + // `$insertNodeToNearestRootAtCaret`, which is not ported yet. + let Some(found_block) = first_block.clone() else { + return Err(LexicalError::new( + "insertNodes: inserting blocks with no block ancestor ($insertNodeToNearestRootAtCaret) is not ported yet", + )); + }; + + // CASE 3b: the block cannot receive block siblings (a block inside an + // inline-only element, #8724; a slot value too, once slots are ported). + // Flatten the incoming content. + if found_block.is_element() + && !found_block.is_parent_required() + && !is_block_container(&found_block.get_parent_or_throw()?) + { + let (_, index) = remove_text_and_split_block(selection, false)?; + let inline_nodes = extract_inline_from_blocks(nodes); + found_block.splice(index, 0, &inline_nodes)?; + match inline_nodes.last() { + Some(last_inserted) => { + last_inserted.select_end()?; + } + None => { + found_block.select(Some(index), Some(index))?; + } + } + return Ok(()); + } + + // CASE 3: At least 1 element of the array is not inline + let blocks_parent = wrap_inline_nodes(nodes)?; + let node_to_select = blocks_parent + .get_last_descendant() + .ok_or_else(|| LexicalError::new("insertNodes: nothing to select"))?; + let blocks = blocks_parent.get_children(); + // An empty line before the insertion point reads as a block boundary + // (#4815). + let is_after_empty_line = is_point_after_empty_line(&first_point)?; + let is_mergeable = |node: &NodeRef, first_block: Option<&NodeRef>| { + !is_after_empty_line + && node.is_element() + && is_block(node) + && !node.is_empty() + && first_block + .is_some_and(|b| b.is_element() && (!b.is_empty() || b.can_merge_when_empty())) + }; + + let should_insert = !found_block.is_element() || !found_block.is_empty(); + let inserted_paragraph = if should_insert { + selection.insert_paragraph()? + } else { + None + }; + if inserted_paragraph.is_some() && !found_block.is_attached() { + first_node = anchor_of(selection).get_node()?; + first_block = find_matching_parent(&first_node, is_block); + first_block_is_latest = first_block.as_ref().is_some_and(is_clone_not_needed); + } + let last_to_insert = blocks.last(); + let mut first_to_insert = blocks.first(); + if let Some(first) = first_to_insert + && is_mergeable(first, first_block.as_ref()) + { + let Some(first_block) = first_block.as_ref().filter(|b| b.is_element()) else { + return Err(expected_block_ancestor(&first_node, "block ElementNode")); + }; + first_block.append(&first.get_children())?; + first_to_insert = blocks.get(1); + } + if let Some(first_to_insert) = first_to_insert { + let Some(first_block) = &first_block else { + return Err(expected_block_ancestor(&first_node, "block")); + }; + insert_range_after(first_block, first_to_insert)?; + } + let last_inserted_block = find_matching_parent(&node_to_select, is_block); + + // Mark where the inserted content ends before the split-off content is + // moved in after it. + let insert_selection = node_to_select.select_end()?; + + if let Some(inserted_paragraph) = &inserted_paragraph { + match last_inserted_block.as_ref().filter(|b| { + b.is_element() + && (inserted_paragraph.can_merge_when_empty() + || last_to_insert.is_some_and(is_block)) + }) { + Some(last_inserted_block) => { + last_inserted_block.append(&inserted_paragraph.get_children())?; + inserted_paragraph.remove(false)?; + } + None => { + // The split-off paragraph could not be merged into the last + // inserted block, but it holds no content either. + if inserted_paragraph.is_empty() { + inserted_paragraph.remove(false)?; + } + } + } + } + if let Some(first_block) = first_block.as_ref().filter(|b| b.is_element()) + && first_block.is_empty() + { + first_block.remove(false)?; + } + + // To understand this take a look at the test "can wrap post-linebreak + // nodes into new element" + let last_child = first_block + .as_ref() + .filter(|b| b.is_element()) + .and_then(NodeRef::get_last_child); + // `lastInsertedBlock !== firstBlock` compares objects: the same node + // differs from a `firstBlock` that has been cloned since. + let same_block = + first_block.is_some() && last_inserted_block == first_block && first_block_is_latest; + if let Some(last_child) = last_child + && last_child.get_latest().is::() + && !same_block + { + last_child.remove(false)?; + } + + // Resolve the marked caret onto the text beside it. + let normalized_caret = normalize_caret(&caret_from_point( + &anchor_of(&insert_selection), + CaretDirection::Next, + )?); + set_point_from_caret(&insert_selection, Which::Anchor, &normalized_caret)?; + set_point_from_caret(&insert_selection, Which::Focus, &normalized_caret)?; + Ok(()) +} + +/// The invariant `Expected node %s of type %s to have a block ElementNode +/// ancestor` (or `a block ancestor`). +fn expected_block_ancestor(node: &NodeRef, what: &str) -> LexicalError { + LexicalError::new(format!( + "Expected node {} of type {} to have a {what} ancestor", + node.get_key(), + node.get_type() + )) +} + +/// Whether the node is already writable in this update (JS's +/// `_cloneNotNeeded`), so `getWritable` changes the object JS code holds +/// rather than replacing it: nothing else, such as the previous editor +/// state, shares its value. +fn is_clone_not_needed(node: &NodeRef) -> bool { + get_active_editor_state() + .0 + .node_map + .borrow() + .get(node.get_key()) + .is_some_and(|n| Rc::strong_count(n) == 1) +} + +/// `$isBlockContainer(node)`: the root or a shadow root. A block element +/// whose class declares `children: 'block'` counts too in an editor that +/// enforces node structure, once that is ported. +fn is_block_container(node: &NodeRef) -> bool { + node.is_element() && is_root_or_shadow_root(&*node.get_latest()) +} + +/// `$findMatchingParent(startingNode, findFn)`: the node or its nearest +/// ancestor below the root that matches. +fn find_matching_parent( + starting_node: &NodeRef, + find_fn: impl Fn(&NodeRef) -> bool, +) -> Option { + let mut curr = Some(starting_node.clone()); + while let Some(node) = curr.filter(|n| !n.is_root()) { + if find_fn(&node) { + return Some(node); + } + curr = node.get_parent(); + } + None +} + +/// `getNextSiblings()`. +fn get_next_siblings(node: &NodeRef) -> Vec { + let mut siblings = Vec::new(); + let mut sibling = node.get_next_sibling(); + while let Some(s) = sibling { + sibling = s.get_next_sibling(); + siblings.push(s); + } + siblings +} + +/// `insertRangeAfter(node, firstToInsert)`: inserts `first_to_insert` and +/// its next siblings after `node`. +fn insert_range_after(node: &NodeRef, first_to_insert: &NodeRef) -> Result<(), LexicalError> { + let mut nodes_to_insert = vec![first_to_insert.clone()]; + nodes_to_insert.extend(get_next_siblings(first_to_insert)); + let mut current_node = node.clone(); + for node_to_insert in &nodes_to_insert { + current_node = current_node.insert_after(node_to_insert, true)?; + } + Ok(()) +} + +/// `$getNodeBeforePoint(point)`: the node just before the point in its +/// block, looking through the boundaries of inline elements. +fn get_node_before_point(point: &Point) -> Result, LexicalError> { + let node = point.get_node()?; + if point.offset > 0 { + // An element point has the child before it; a text point has + // characters before it, so no node is adjacent. + return Ok( + if point.point_type == PointType::Element && node.is_element() { + node.get_child_at_index(point.offset - 1) + } else { + None + }, + ); + } + let mut child = Some(node); + while let Some(c) = child.filter(|c| !is_block(c) && !is_root_or_shadow_root(&*c.get_latest())) + { + if let Some(previous_sibling) = c.get_previous_sibling() { + return Ok(Some(previous_sibling)); + } + child = c.get_parent(); + } + Ok(None) +} + +/// `$isPointAfterEmptyLine(point)`: whether two line breaks come just +/// before the point. +fn is_point_after_empty_line(point: &Point) -> Result { + let is_line_break = |node: &NodeRef| node.get_latest().is::(); + Ok(get_node_before_point(point)?.is_some_and(|node_before| { + is_line_break(&node_before) + && node_before + .get_previous_sibling() + .is_some_and(|n| is_line_break(&n)) + })) +} + +/// `$extractInlineFromBlocks(nodes)`: the inline content of `nodes`, with +/// blocks replaced by their inline content and line breaks dropped. (A +/// block `DecoratorNode` is dropped too, once it is ported.) +fn extract_inline_from_blocks(nodes: &[NodeRef]) -> Vec { + let mut inline_nodes = Vec::new(); + for node in nodes { + if node.get_latest().is::() { + continue; + } + if node.is_element() && !node.is_inline() { + inline_nodes.extend(extract_inline_from_blocks(&node.get_children())); + continue; + } + inline_nodes.push(node.clone()); + } + inline_nodes +} + +/// `$removeTextAndSplitBlock(selection, stopAtUnsplittableNode)`: removes +/// the selected text and splits the ancestors of the anchor up to the +/// nearest block, and returns the node to insert into and the index in it. +fn remove_text_and_split_block( + selection: &SelectionRef, + stop_at_unsplittable_node: bool, +) -> Result<(NodeRef, usize), LexicalError> { + let mut selection_ = selection.clone(); + if !is_collapsed(selection) { + selection_.remove_text()?; + } + // A new selection can originate as a result of node replacement, in + // which case is registered via $setSelection + if let Some(new_selection) = get_selection().filter(SelectionRef::is_range) { + selection_ = new_selection; + } + let anchor = anchor_of(&selection_); + let mut node = anchor.get_node()?; + let mut offset = anchor.offset; + // The walk stops at a slot value too, once slots are ported. + while !is_block(&node) { + let prev_node = node.clone(); + (node, offset) = split_node_at_point(&node, offset, stop_at_unsplittable_node)?; + if prev_node == node { + break; + } + } + Ok((node, offset)) +} + +/// `$splitNodeAtPoint(node, offset, stopAtUnsplittableNode)`: splits +/// `node` at `offset`, and returns the parent that holds the two halves and +/// the index between them. +fn split_node_at_point( + node: &NodeRef, + offset: usize, + stop_at_unsplittable_node: bool, +) -> Result<(NodeRef, usize), LexicalError> { + let Some(parent) = node.get_parent() else { + let paragraph = create_paragraph_node(); + get_root().append(std::slice::from_ref(¶graph))?; + paragraph.select(None, None)?; + return Ok((get_root(), 0)); + }; + let index_within_parent = || { + node.get_index_within_parent() + .expect("a child of its parent") + }; + + if node.is_text() { + let split = node.split_text(&[offset as i64])?; + let Some(first) = split.first() else { + return Ok((parent, index_within_parent())); + }; + let x = usize::from(offset != 0); + let index = first + .get_index_within_parent() + .expect("a child of its parent") + + x; + return Ok((parent, index)); + } + + if !node.is_element() || offset == 0 { + return Ok((parent, index_within_parent())); + } + + if let Some(first_to_append) = node.get_child_at_index(offset) { + let insert_point = SelectionRef::new(Selection::Range(RangeSelection::new( + Point::new(node.get_key(), offset, PointType::Element), + Point::new(node.get_key(), offset, PointType::Element), + 0.0, + "", + ))); + // JS passes no `restoreSelection`, which `insertAfter` defaults to + // true. + match node.insert_new_after(&insert_point, true)? { + Some(new_element) if new_element.is_element() => { + let mut nodes = vec![first_to_append.clone()]; + nodes.extend(get_next_siblings(&first_to_append)); + new_element.append(&nodes)?; + } + _ if stop_at_unsplittable_node => return Ok((node.clone(), offset)), + _ => {} + } + } + Ok((parent, index_within_parent() + 1)) +} + +/// `$isInlineRunNode(node)`: a node `$wrapInlineNodes` puts in a block of +/// its own run. +fn is_inline_run_node(node: &NodeRef) -> bool { + let latest = node.get_latest(); + latest.is::() + // `$isInlineElementOrDecoratorNode`; inline decorators come with + // DecoratorNode. + || (latest.element().is_some() && latest.is_inline()) + || latest.text().is_some() + || latest.is_parent_required() +} + +/// `$wrapInlineNodes(nodes)`: a detached paragraph holding `nodes`, each +/// run of inline nodes wrapped in its required parent (a paragraph). +fn wrap_inline_nodes(nodes: &[NodeRef]) -> Result { + // We temporarily insert the topLevelNodes into an arbitrary ElementNode, + // since insertAfter does not work on nodes that have no parent (TO-DO: + // fix that). + let virtual_root = create_paragraph_node(); + let mut current_block: Option = None; + for (i, node) in nodes.iter().enumerate() { + if is_inline_run_node(node) { + let block = match ¤t_block { + Some(block) => block.clone(), + None => { + let block = node.create_parent_element_node(); + virtual_root.append(std::slice::from_ref(&block))?; + current_block = Some(block.clone()); + // A LineBreakNode that is an entire run by itself + // collapses to an empty paragraph, since the block + // boundary already provides the visual newline. + let next_node = nodes.get(i + 1); + if node.get_latest().is::() + && next_node.is_none_or(|n| !is_inline_run_node(n)) + { + continue; + } + block + } + }; + block.append(std::slice::from_ref(node))?; + } else { + virtual_root.append(std::slice::from_ref(node))?; + current_block = None; + } + } + Ok(virtual_root) +} + +/// `RawTextVisitor`: what [`tokenize_raw_text`] calls for each token. +pub trait RawTextVisitor { + fn linebreak(&mut self); + fn tab(&mut self); + /// A non-empty run of text. + fn text(&mut self, text: &str); +} + +/// `tokenizeRawText(text, visitor)`: splits `text` at `\n`, `\r\n` and +/// `\t` (`text.split(/(\r?\n|\t)/)`), and calls the visitor for each token +/// in order, skipping empty text runs. +pub fn tokenize_raw_text(text: &str, visitor: &mut impl RawTextVisitor) { + let mut start = 0; + for (i, c) in text.char_indices() { + match c { + '\n' => { + let end = if text[..i].ends_with('\r') && i > start { + i - 1 + } else { + i + }; + if end > start { + visitor.text(&text[start..end]); + } + visitor.linebreak(); + start = i + 1; + } + '\t' => { + if i > start { + visitor.text(&text[start..i]); + } + visitor.tab(); + start = i + 1; + } + _ => {} + } + } + if text.len() > start { + visitor.text(&text[start..]); + } +} + +/// `$generateNodesFromRawText(text)`: text, line break and tab nodes for +/// `text`, split at `\n`, `\r\n` and `\t`. +pub fn generate_nodes_from_raw_text(text: &str) -> Vec { + struct Nodes(Vec); + impl RawTextVisitor for Nodes { + fn linebreak(&mut self) { + self.0.push(create_line_break_node()); + } + fn tab(&mut self) { + self.0.push(create_tab_node()); + } + fn text(&mut self, text: &str) { + self.0.push(create_text_node(text)); + } + } + let mut nodes = Nodes(Vec::new()); + tokenize_raw_text(text, &mut nodes); + nodes.0 +} + +/// `$getPreviousSelection()`: the committed editor state's selection. +fn get_previous_selection() -> Option { + let editor = get_active_editor()?; + editor.get_editor_state().0.selection.borrow().clone() +} + +/// `$insertNodes(nodes)`: inserts `nodes` at the selection, the previous +/// one if there is none, or else the end of the root. +pub fn insert_nodes(nodes: Vec) -> Result<(), LexicalError> { + let selection = match get_selection().or_else(get_previous_selection) { + Some(selection) => selection, + None => get_root().select_end()?, + }; + selection.insert_nodes(nodes) +} + +/// `$selectAll()`: selects the whole root, with the points moved down to +/// text where they can be, and returns the selection. (JS's +/// `$selectAll(selection)`, which scopes an existing selection, is not +/// ported.) +pub fn select_all() -> Result { + let root = get_root(); + let new_selection = root.select(Some(0), Some(root.get_children_size()))?; + set_selection(Some(normalize_selection_for_select_all( + &new_selection, + &root, + )?)); + Ok(new_selection) +} + +/// `$normalizeSelectionForSelectAll(selection, container)`: normalizes the +/// points, unless the select-all spans a document whose points would both +/// descend into the same top-level shadow root (#6938). +fn normalize_selection_for_select_all( + selection: &SelectionRef, + container: &NodeRef, +) -> Result { + let range = selection.range().expect("a range selection"); + normalize_selection(selection)?; + if !container.is_root() { + return Ok(selection.clone()); + } + let anchor_top = get_root_child_ancestor(&anchor_of(selection).get_node()?); + let focus = selection.point(Which::Focus).expect("a range selection"); + if let Some(anchor_top) = anchor_top + && anchor_top.is_element() + && anchor_top.get_latest().is_shadow_root() + && get_root_child_ancestor(&focus.get_node()?).as_ref() == Some(&anchor_top) + { + let (anchor, focus) = (&range.anchor, &range.focus); + selection.point_set(Which::Anchor, &anchor.key, anchor.offset, anchor.point_type)?; + selection.point_set(Which::Focus, &focus.key, focus.offset, focus.point_type)?; + } + Ok(selection.clone()) +} + +/// `$getRootChildAncestor(node)`: the node or its ancestor that is a child +/// of the root. +fn get_root_child_ancestor(node: &NodeRef) -> Option { + let mut current = node.clone(); + loop { + // A detached node. + let parent = current.get_parent()?; + if parent.is_root() { + return Some(current); + } + current = parent; + } +} + +/// `$normalizeSelection(selection)` (`LexicalNormalization.ts`): moves +/// element points down to the deepest text or element point. +fn normalize_selection(selection: &SelectionRef) -> Result<(), LexicalError> { + normalize_point(selection, Which::Anchor)?; + normalize_point(selection, Which::Focus) +} + +/// `$normalizePoint(point)`. +fn normalize_point(selection: &SelectionRef, which: Which) -> Result<(), LexicalError> { + loop { + let point = selection.point(which).expect("a range selection"); + if point.point_type != PointType::Element { + return Ok(()); + } + let node = point.get_node()?; + let offset = point.offset; + let (next_node, next_offset_at_end) = if offset == node.get_children_size() { + ( + offset + .checked_sub(1) + .and_then(|i| node.get_child_at_index(i)), + true, + ) + } else { + (node.get_child_at_index(offset), false) + }; + let Some(next_node) = next_node else { + return Ok(()); + }; + // `set(..., onlyIfChanged)` always changes the point here: the node + // differs. + if next_node.is_text() { + let offset = if next_offset_at_end { + next_node.get_text_content_size() + } else { + 0 + }; + return selection.point_set(which, next_node.get_key(), offset, PointType::Text); + } else if !next_node.is_element() { + return Ok(()); + } + let offset = if next_offset_at_end { + next_node.get_children_size() + } else { + 0 + }; + selection.point_set(which, next_node.get_key(), offset, PointType::Element)?; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Tokens(Vec); + impl RawTextVisitor for Tokens { + fn linebreak(&mut self) { + self.0.push("
".to_owned()); + } + fn tab(&mut self) { + self.0.push("".to_owned()); + } + fn text(&mut self, text: &str) { + self.0.push(text.to_owned()); + } + } + + /// `tokenizeRawText` splits as `text.split(/(\r?\n|\t)/)` does: a + /// `\r` only goes with the `\n` right after it. + #[test] + fn tokenize_raw_text_matches_js_split() { + let tokens = |text: &str| { + let mut tokens = Tokens(Vec::new()); + tokenize_raw_text(text, &mut tokens); + tokens.0 + }; + assert_eq!( + tokens("a\r\nb\tc\n"), + ["a", "
", "b", "", "c", "
"] + ); + assert_eq!(tokens("\r\r\n\r"), ["\r", "
", "\r"]); + assert_eq!(tokens("\r\n\r\n"), ["
", "
"]); + assert_eq!(tokens("Ć©\t\tšŸ˜€"), ["Ć©", "", "", "šŸ˜€"]); + assert!(tokens("").is_empty()); + } +} diff --git a/rust/lexical-editor/src/selection_insert_text.rs b/rust/lexical-editor/src/selection_insert_text.rs new file mode 100644 index 00000000000..a861584e4dc --- /dev/null +++ b/rust/lexical-editor/src/selection_insert_text.rs @@ -0,0 +1,448 @@ +//! `insertText` from `LexicalSelection.ts` (`$insertText`, +//! `$insertTextAtPoint` and their helpers). +//! +//! A headless editor never composes, so `$getCompositionKey()` is always +//! `null` and `isComposing()` always false: the composition branches are +//! left out, each noted where JS has it. Without the text edit log or +//! NodeState, `$recordDeleted`, `$recordTextInserted` and `$carryNodeState` +//! do nothing. + +use crate::LexicalError; +use crate::caret::{CaretDirection, get_sibling_caret}; +use crate::error::invariant; +use crate::node::NodeRef; +use crate::nodes::element::is_root_or_shadow_root; +use crate::nodes::paragraph::{ParagraphNode, create_paragraph_node}; +use crate::nodes::tab::TabNode; +use crate::nodes::text::create_text_node; +use crate::selection::{PointType, SelectionRef, Which, get_selection, set_selection}; + +impl SelectionRef { + /// `selection.insertText(text)`: inserts `text` at a range selection, + /// replacing what it covers; nothing for a node selection + /// (`NodeSelection.insertText()`). + pub fn insert_text(&self, text: &str) -> Result<(), LexicalError> { + if !self.is_range() { + return Ok(()); + } + insert_text(self, text) + } +} + +impl NodeRef { + /// `canInsertTextBefore()` of a text node or an element. + pub fn can_insert_text_before(&self) -> bool { + self.get_latest().can_insert_text_before() + } + + /// `canInsertTextAfter()` of a text node or an element. + pub fn can_insert_text_after(&self) -> bool { + self.get_latest().can_insert_text_after() + } +} + +/// `$isTokenOrSegmented(node)`: a tab, or a text node in token or +/// segmented mode. +pub(crate) fn is_token_or_segmented(node: &NodeRef) -> bool { + node.get_latest().is::() || node.is_token() || node.is_segmented() +} + +/// `$isParagraphNode(node)`. +fn is_paragraph_node(node: &NodeRef) -> bool { + node.get_latest().is::() +} + +/// `$insertText(selection, text, false)`. +fn insert_text(selection: &SelectionRef, text: &str) -> Result<(), LexicalError> { + let range = selection.range().expect("a range selection"); + // For non-collapsed selections, inherit format/style from the first + // selected text node so the replacement preserves the original styling. + let mut format = range.format; + let mut style = range.style.clone(); + if !range.is_collapsed() { + let first_point = if range.focus.is_before(&range.anchor)? { + &range.focus + } else { + &range.anchor + }; + let first_node = first_point.get_node()?; + if first_node.is_text() { + let data = first_node.text_data(); + format = data.get_format(); + style = data.get_style().to_owned(); + } + selection.remove_text()?; + selection.with_range(|r| { + r.format = format; + r.style = style.clone(); + }); + if text.is_empty() { + return Ok(()); + } + // `$getCompositionKey()` is always null headless, so JS never falls + // through to the collapsed code here. + if selection.point(Which::Anchor).expect("a range").point_type == PointType::Element { + transfer_starting_element_point_to_text_point(selection, format, &style)?; + } + return insert_text_at_point(selection, text, format, &style); + } + + if range.anchor.point_type == PointType::Element { + transfer_starting_element_point_to_text_point(selection, format, &style)?; + } + + let anchor = selection.point(Which::Anchor).expect("a range selection"); + let anchor_node = anchor.get_node()?; + invariant!( + anchor_node.is_text(), + "insertText: anchor is not a text node" + ); + let offset = anchor.offset; + + let anchor_parent = anchor_node.get_parent_or_throw()?; + let anchor_size = anchor_node.get_text_content_size(); + let needs_redirect = is_token_or_segmented(&anchor_node) + || (offset == 0 + && (!anchor_node.can_insert_text_before() + || (!anchor_parent.can_insert_text_before() + && anchor_node.get_previous_sibling().is_none()))) + || (offset == anchor_size + && (!anchor_node.can_insert_text_after() + || (!anchor_parent.can_insert_text_after() + && anchor_node.get_next_sibling().is_none()))); + + if needs_redirect { + // Token/segmented nodes and nodes whose parent forbids text insertion + // at the boundary: reposition the cursor to an adjacent insertable + // node, then recurse. + + // Segmented node with cursor in the middle: convert to normal + // text node so that the recursive insertText can proceed. + if anchor_node.is_segmented() && offset != 0 && offset != anchor_size { + // JS sets the node's mode to normal instead while composing, + // which a headless editor never does. + let anchor_data = anchor_node.text_data(); + let replacement = create_text_node(anchor_data.get_text()); + replacement.set_format(format); + replacement.set_style(&style); + replacement.set_detail(anchor_data.get_detail())?; + // `$carryNodeState`: NodeState is not ported. + let is_current_selection = get_selection().is_some_and(|s| s.ptr_eq(selection)); + anchor_node.replace(&replacement, false)?; + // replace() installs a clone of the active selection, so this + // selection is still anchored to the segmented node that was just + // detached. Move it onto the replacement and make it current + // again, otherwise the recursive insertText below resolves an + // anchor that is no longer in the tree. + selection.set_text_node_range(&replacement, offset, &replacement, offset)?; + if is_current_selection && !get_selection().is_some_and(|s| s.ptr_eq(selection)) { + set_selection(Some(selection.clone())); + } + if !text.is_empty() { + insert_text(selection, text)?; + } + return Ok(()); + } + if text.is_empty() { + return Ok(()); + } + if offset == 0 || offset == anchor_size { + let before = offset == 0; + let direction = if before { + CaretDirection::Previous + } else { + CaretDirection::Next + }; + let sibling = get_sibling_caret(&anchor_node, direction).get_node_at_caret(); + let target = match sibling.filter(|sibling| { + sibling.is_text() + && (if before { + sibling.can_insert_text_after() + } else { + sibling.can_insert_text_before() + }) + && !is_token_or_segmented(sibling) + }) { + Some(sibling) => sibling, + None => { + let target = create_text_node(""); + target.set_format(format); + target.set_style(&style); + let can_insert = if before { + anchor_parent.can_insert_text_before() + } else { + anchor_parent.can_insert_text_after() + }; + get_sibling_caret( + if can_insert { + &anchor_node + } else { + &anchor_parent + }, + direction, + ) + .insert(&target)?; + target + } + }; + let target_offset = if before { None } else { Some(0) }; + target.select(target_offset, target_offset)?; + return insert_text(selection, text); + } + let new_node = create_text_node(text); + new_node.set_format(format); + new_node.set_style(&style); + // `$recordDeleted` and `$recordTextInserted`: the text edit log is + // not ported. + anchor_node.replace(&new_node, false)?; + new_node.select(None, None)?; + return Ok(()); + } + + if text.is_empty() { + return Ok(()); + } + + let at_start_of_inline = + anchor_parent.is_inline() && offset == 0 && anchor_node.get_previous_sibling().is_none(); + let at_end_of_inline = anchor_parent.is_inline() + && offset == anchor_size + && anchor_node.get_next_sibling().is_none(); + let anchor_data = anchor_node.text_data(); + let format_differs = anchor_data.get_format() != format || anchor_data.get_style() != style; + + if at_start_of_inline || at_end_of_inline || format_differs { + if anchor_data.get_text().is_empty() && !at_start_of_inline && !at_end_of_inline { + anchor_node.set_format(format); + anchor_node.set_style(&style); + } else { + return insert_text_at_point(selection, text, format, &style); + } + } + + anchor_node.splice_text(offset as i64, 0, text, true)?; + // JS moves the anchor back over the text when the node is composing, + // which it never is headless. + Ok(()) +} + +/// `$insertTextAtPoint(selection, text, format, style)`: inserts `text` as +/// a new text node with `format` and `style` at the selection's anchor, +/// splitting the anchor's text node, and selects its end. +fn insert_text_at_point( + selection: &SelectionRef, + text: &str, + format: f64, + style: &str, +) -> Result<(), LexicalError> { + let anchor = selection.point(Which::Anchor).expect("a range selection"); + let anchor_node = anchor.get_node()?; + invariant!( + anchor_node.is_text(), + "insertText: anchor is not a text node" + ); + let offset = anchor.offset; + let text_node = create_text_node(text); + text_node.set_format(format); + text_node.set_style(style); + // `$recordTextInserted`: the text edit log is not ported. + let parent = anchor_node.get_parent_or_throw()?; + let before = offset == 0; + let at_boundary = before || offset == anchor_node.get_text_content_size(); + let adjacent = if before { + anchor_node.get_previous_sibling() + } else { + anchor_node.get_next_sibling() + }; + if at_boundary && parent.is_inline() && adjacent.is_none() { + let direction = if before { + CaretDirection::Previous + } else { + CaretDirection::Next + }; + get_sibling_caret(&parent, direction).insert(&text_node)?; + } else { + let origin = if at_boundary { + anchor_node.clone() + } else { + anchor_node.split_text(&[offset as i64])?.swap_remove(0) + }; + if before { + origin.insert_before(&text_node, false)?; + } else { + origin.insert_after(&text_node, false)?; + } + } + if anchor_node.get_text_content().is_empty() && anchor_node.is_attached() { + anchor_node.remove(false)?; + } + text_node.select_end()?; + // JS moves the anchor back over the text when the node is composing, + // which it never is headless. + Ok(()) +} + +/// `$transferStartingElementPointToTextPoint(start, end, format, style)` +/// with the selection's anchor as `start` and its focus as `end`: puts an +/// empty text node with `format` and `style` where the element point is, +/// in a new paragraph where the element is a root, and moves the anchor +/// (and the focus, when it is the same point) into it. +fn transfer_starting_element_point_to_text_point( + selection: &SelectionRef, + format: f64, + style: &str, +) -> Result<(), LexicalError> { + let start = selection.point(Which::Anchor).expect("a range selection"); + let element = start.get_node()?; + let placement_node = element.get_child_at_index(start.offset); + let text_node = create_text_node(""); + text_node.set_format(format); + text_node.set_style(style); + if let Some(placement_node) = placement_node { + if is_paragraph_node(&placement_node) { + placement_node.splice(0, 0, std::slice::from_ref(&text_node))?; + } else { + // root or shadow-root + element-mode anchor before a + // non-paragraph child (typically a sibling block decorator): + // wrap the new text in a paragraph so it stays a valid + // block-level child of the root or slot frame. + let target = if is_root_or_shadow_root(&*element.get_latest()) { + let paragraph = create_paragraph_node(); + paragraph.append(std::slice::from_ref(&text_node))?; + paragraph + } else { + text_node.clone() + }; + placement_node.insert_before(&target, true)?; + } + } else if is_root_or_shadow_root(&*element.get_latest()) { + // root or shadow-root + last-offset typing: reuse the empty trailing + // block when one exists instead of appending a fresh paragraph. + match element.get_last_child() { + Some(last_child) + if last_child.is_element() && !last_child.is_inline() && last_child.is_empty() => + { + last_child.append(std::slice::from_ref(&text_node))?; + } + _ => { + let paragraph = create_paragraph_node(); + paragraph.append(std::slice::from_ref(&text_node))?; + element.append(std::slice::from_ref(¶graph))?; + } + } + } else { + element.append(std::slice::from_ref(&text_node))?; + } + // Transfer the element point to a text point. + let range = selection.range().expect("a range selection"); + if range.anchor == range.focus { + selection.point_set(Which::Focus, text_node.get_key(), 0, PointType::Text)?; + } + selection.point_set(Which::Anchor, text_node.get_key(), 0, PointType::Text) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + CreateEditorArgs, UpdateOptions, create_editor, get_root, json, parse_serialized_node, + reset_random_key, + }; + + /// The redirects around token, segmented and tab nodes and the + /// element-point transfer, which no vector reaches: the compact state + /// and the selection JS 0.53.0 gives for the same document, point and + /// text. + #[test] + fn insert_text_redirects_match_js() { + // The paragraph's children, the collapsed point, the text, then the + // state and the collapsed text point JS ends with. + type Case = ( + &'static str, + (&'static str, usize, PointType), + &'static str, + &'static str, + (&'static str, usize), + ); + let cases: &[Case] = &[ + ( + r#"{"type":"text","text":"a","mode":"token"},{"type":"text","text":"b","mode":"token"}"#, + ("2", 1, PointType::Text), + "X", + r#"{"root":{"type":"root","children":[{"type":"paragraph","children":[{"type":"text","mode":"token","text":"a"},{"type":"text","text":"X"},{"type":"text","mode":"token","text":"b"}]}]}}"#, + ("4", 1), + ), + ( + r#"{"type":"text","text":"ab cd","mode":"segmented","format":1,"detail":2}"#, + ("2", 2, PointType::Text), + "X", + r#"{"root":{"type":"root","children":[{"type":"paragraph","children":[{"type":"text","detail":2,"text":"abX cd"}]}]}}"#, + ("3", 3), + ), + ( + r#"{"type":"text","text":"a"},{"type":"tab"}"#, + ("3", 1, PointType::Text), + "X", + r#"{"root":{"type":"root","children":[{"type":"paragraph","children":[{"type":"text","text":"a"},{"type":"tab"},{"type":"text","text":"X"}]}]}}"#, + ("4", 1), + ), + ( + r#"{"type":"text","text":"a"}"#, + ("root", 0, PointType::Element), + "X", + r#"{"root":{"type":"root","children":[{"type":"paragraph","children":[{"type":"text","text":"Xa"}]}]}}"#, + ("3", 1), + ), + ( + r#"{"type":"text","text":"a"},{"type":"text","text":"b","format":1}"#, + ("1", 1, PointType::Element), + "X", + r#"{"root":{"type":"root","children":[{"type":"paragraph","children":[{"type":"text","text":"aX"},{"type":"text","format":1,"text":"b"}]}]}}"#, + ("2", 2), + ), + ]; + let discrete = || UpdateOptions { + discrete: true, + ..UpdateOptions::default() + }; + for &(children, (key, offset, point_type), text, state, (end_key, end_offset)) in cases { + reset_random_key(); + let editor = create_editor(CreateEditorArgs::default()); + let doc = format!(r#"{{"type":"paragraph","children":[{children}]}}"#); + editor + .update( + move || { + let node = parse_serialized_node(&json::parse(&doc).unwrap())?; + get_root().append(&[node]) + }, + discrete(), + ) + .unwrap(); + editor + .update( + move || { + let selection = crate::selection::create_range_selection(); + selection.point_set(Which::Anchor, key, offset, point_type)?; + selection.point_set(Which::Focus, key, offset, point_type)?; + set_selection(Some(selection.clone())); + selection.insert_text(text) + }, + discrete(), + ) + .unwrap(); + let editor_state = editor.get_editor_state(); + assert_eq!( + json::stringify(&editor_state.to_json(true)).unwrap(), + state, + "{children}" + ); + let range = editor_state.selection().unwrap().range().unwrap(); + let end = crate::selection::Point::new(end_key, end_offset, PointType::Text); + assert_eq!( + (range.anchor, range.focus), + (end.clone(), end), + "{children}" + ); + } + } +} diff --git a/rust/lexical-editor/src/utils.rs b/rust/lexical-editor/src/utils.rs index 22321d8cf0f..a0f93da734d 100644 --- a/rust/lexical-editor/src/utils.rs +++ b/rust/lexical-editor/src/utils.rs @@ -1,12 +1,15 @@ //! `LexicalUtils.ts`: keys, node lookup, `getWritable` and the dirty marks //! it leaves for the update cycle. -use crate::EditorState; +use crate::caret_tree::detach_node; use crate::editor::DirtyType; use crate::node::{LexicalNode, NodeRef}; +use crate::nodes::element::is_root_or_shadow_root; +use crate::nodes::paragraph::create_paragraph_node; use crate::updates::{ error_on_infinite_transforms, error_on_read_only, get_active_editor, get_active_editor_state, }; +use crate::{EditorState, LexicalError}; use std::cell::Cell; use std::rc::Rc; @@ -41,11 +44,69 @@ pub fn get_node_by_key(key: &str) -> Option { get_node_by_key_impl(&get_active_editor_state(), key).map(|_| NodeRef::new(key)) } +/// `$getNodeByKeyOrThrow(key)`. +pub(crate) fn get_node_by_key_or_throw(key: &str) -> Result { + get_node_by_key(key).ok_or_else(|| { + LexicalError::new(format!( + "Expected node with key {key} to exist but it's not in the nodeMap." + )) + }) +} + /// `$getRoot()`. pub fn get_root() -> NodeRef { NodeRef::new("root") } +/// `$isShadowRootNode(node)`. +pub(crate) fn is_shadow_root_node(node: &NodeRef) -> bool { + let node = node.get_latest(); + node.element().is_some() && node.is_shadow_root() +} + +/// `INTERNAL_$isBlock(node)`: an element that is not a root or shadow +/// root, not inline, can be empty, and has no children or an inline first +/// child. (A block `DecoratorNode` too, once it is ported.) +pub(crate) fn is_block(node: &NodeRef) -> bool { + if !node.is_element() || is_root_or_shadow_root(&*node.get_latest()) { + return false; + } + let is_leaf_element = node.get_first_child().is_none_or(|first| first.is_inline()); + !node.is_inline() && node.can_be_empty() && is_leaf_element +} + +/// `$removeFromParent(node)`: detaches the node without repairing the +/// selection. +pub(crate) fn remove_from_parent(node: &NodeRef) { + if node.get_parent().is_some() { + get_writable(node.get_key()); + } + detach_node(node); +} + +/// `$restoreEmptyContainerParagraph(container, removedChild)`: appends an +/// empty paragraph to a root or shadow root a removal left empty, and +/// returns it. +pub(crate) fn restore_empty_container_paragraph( + container: Option<&NodeRef>, + removed_child: Option<&NodeRef>, +) -> Result, LexicalError> { + let Some(container) = container.filter(|c| is_root_or_shadow_root(&*c.get_latest())) else { + return Ok(None); + }; + // Root slots (such as footnotes) do not replace the editable body, once + // slots are ported. + if !container.is_attached() + || !container.is_empty() + || !(container.is_root() || removed_child.is_some_and(is_block)) + { + return Ok(None); + } + let paragraph = create_paragraph_node(); + container.append(std::slice::from_ref(¶graph))?; + Ok(Some(paragraph)) +} + /// `$setNodeKey(node, null)`: gives a new node a key, puts it in the active /// editor state and marks it dirty. pub(crate) fn set_node_key(mut node: Box) -> NodeRef { @@ -118,6 +179,9 @@ pub(crate) fn internal_mark_siblings_as_dirty(node: &NodeRef) { /// it. Changes then go through [`mutate`]. pub(crate) fn get_writable(key: &str) { error_on_read_only(); + if let Some(selection) = get_active_editor_state().0.selection.borrow().as_ref() { + selection.set_cached_nodes(None); + } unshare(key); internal_mark_node_as_dirty(key); }