From e63bbbf024d1c8a9ad5495a8215e4ddda546973d Mon Sep 17 00:00:00 2001 From: Amp Date: Fri, 13 Nov 2026 22:42:39 +0000 Subject: [PATCH] feat(rust): update cycle, node methods, commands, transforms and listeners The first part of M1 (rust/DESIGN.md): the core model can now be edited, not only parsed and written. - The update cycle, ported line by line from LexicalUpdates.ts: editor.update (discrete or committed at the next read), nested and queued updates, tags, normalization of adjacent simple text (LexicalNormalization.ts), node transforms (leaves first, the root last), garbage collection of detached nodes (LexicalGC.ts), the selection check, discarding a failed update, and the text content and update listeners. Commands: createCommand, registerCommand with the five priorities, dispatchCommand. setEditorState, which marks a parsed state's nodes dirty so normalization and transforms run on it. - getWritable marks nodes dirty in JS's order (the order update listeners see), with Rc uniqueness standing in for _cloneNotNeeded; the dirty sets are insertion-ordered like JS's Map and Set. - Node methods with UTF-16 offsets: remove, replace, insertBefore, insertAfter, select*, splice (with selection repair), append, clear, the element setters, splitText, mergeWithSibling, spliceText, setTextContent and the text setters and toggles, with TabNode's and RootNode's overrides. The selection model and the helpers node methods use to keep it valid (LexicalSelection.ts); selection editing and carets come next. - lexical-conformance replays scripts/* with an interpreter of ops.ts's script language. A script that needs selection editing or carets is counted as waiting on them, never as passing. Checked in rust/: cargo fmt --check, clippy -D warnings, cargo test (including new tests that doctored script vectors fail), rustdoc -D warnings, cargo +1.88 check. Conformance: scripts/updates 12/12, scripts/commands 3/3, scripts/listeners 2/2 and scripts/transforms 4/4 of the cases without typing, scripts/selection 2/2 without selection editing; meta, serialization/core and property/documents unchanged; 0 failures. setEditorState was also checked against the JS package on a document with mergeable, empty and formatted text, with and without a transform, and on an empty state: identical listener calls, transform calls, errors and JSON. Amp-Thread-ID: https://ampcode.com/threads/T-16d45229-bfa8-417a-a091-d3e3888d830d --- rust/DESIGN.md | 25 +- rust/PARITY.md | 29 +- rust/conformance/src/suites/mod.rs | 31 ++ rust/conformance/src/suites/scripts.rs | 564 ++++++++++++++++++++ rust/conformance/tests/conformance.rs | 39 ++ rust/lexical-editor/src/caret_tree.rs | 317 ++++++++++-- rust/lexical-editor/src/editor.rs | 506 +++++++++++++++++- rust/lexical-editor/src/editor_state.rs | 60 ++- rust/lexical-editor/src/gc.rs | 118 +++++ rust/lexical-editor/src/lib.rs | 47 +- rust/lexical-editor/src/node.rs | 323 +++++++++++- rust/lexical-editor/src/nodes/element.rs | 311 ++++++++++- rust/lexical-editor/src/nodes/paragraph.rs | 5 + rust/lexical-editor/src/nodes/text.rs | 468 ++++++++++++++++- rust/lexical-editor/src/normalization.rs | 68 +++ rust/lexical-editor/src/ordered.rs | 150 ++++++ rust/lexical-editor/src/selection.rs | 571 +++++++++++++++++++++ rust/lexical-editor/src/updates.rs | 534 ++++++++++++++++++- rust/lexical-editor/src/utf16.rs | 50 ++ rust/lexical-editor/src/utils.rs | 110 +++- 20 files changed, 4183 insertions(+), 143 deletions(-) create mode 100644 rust/conformance/src/suites/scripts.rs create mode 100644 rust/lexical-editor/src/gc.rs create mode 100644 rust/lexical-editor/src/normalization.rs create mode 100644 rust/lexical-editor/src/ordered.rs create mode 100644 rust/lexical-editor/src/selection.rs create mode 100644 rust/lexical-editor/src/utf16.rs diff --git a/rust/DESIGN.md b/rust/DESIGN.md index a0d84103a2d..10ae85e5a14 100644 --- a/rust/DESIGN.md +++ b/rust/DESIGN.md @@ -86,8 +86,12 @@ Names may change as packages land; this table is updated with them. - **Nodes.** JS's class hierarchy is the `LexicalNode` trait plus the data each base class adds (`ElementData`, `TextData`). A node is a value behind an `Rc` in its editor state's node map; `getWritable` clones it unless - nothing else shares it (`utils::with_writable`), so an earlier editor state - never sees a change. Code outside a node reads it through `NodeRef`, a key + nothing else shares it (`utils::get_writable`), so an earlier editor state + never sees a change. The `Rc`'s uniqueness stands in for JS's + `_cloneNotNeeded`. `getWritable` also marks the node dirty, as in JS, and + helpers that take "writable" nodes expect their callers to have called it + in JS's order, because update listeners see nodes in the order they were + marked. Code outside a node reads it through `NodeRef`, a key that resolves the latest version on every call, as JS node methods call `getLatest()`. Base class behavior a subclass overrides with a `super` call is a free function in the base class's module. @@ -111,7 +115,20 @@ Names may change as packages land; this table is updated with them. from Swift (PARITY.md). - **Errors.** A JS `invariant` a document or a caller can break returns a `LexicalError` with the same message; one only a bug in a node - implementation can break panics, as `RefCell` does. + implementation can break panics, as `RefCell` does. A failed update + returns its error from `Editor::update` (JS reports it to `onError`) after + discarding the update and notifying listeners, as JS does. +- **The update cycle** (`updates.rs`) follows `$beginUpdate` and + `$commitPendingUpdates` line by line: nested and queued updates, + normalization, transforms (leaves first, the root last), GC, the + selection check, discarding a failed update, and the text content and + update listeners. The dirty sets are insertion-ordered like JS's `Map` and + `Set` (`ordered.rs`), including iteration that sees entries added + meanwhile. Without a DOM nothing reconciles, so mutation listeners and + selection-change notifications never fire, as in JS's headless editor. + JS commits a non-discrete update in a microtask; with no event loop, Rust + commits it at the next `read` or discrete update, which JS also does when + one comes first. Update closures that may be queued are `'static`. ## Conformance @@ -153,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/*` | | +| 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 | | 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 a91c83e2a2c..286c618b77b 100644 --- a/rust/PARITY.md +++ b/rust/PARITY.md @@ -14,11 +14,16 @@ updated with each milestone. | Export form | `LexicalSerializedExport.ts` | `serialized_export` | `serialization/core` | | Generated JSON of the core nodes | `LexicalGeneratedJSON.ts` | `generated_json` | `serialization/core`, `property/documents` | | Core nodes | `nodes/LexicalRootNode.ts`, `LexicalParagraphNode.ts`, `LexicalTextNode.ts`, `LexicalTabNode.ts`, `LexicalLineBreakNode.ts`, `LexicalElementNode.ts` (fields, `getTextContent`, `shouldSerializeTextStyles`) | `nodes::*` | `serialization/core`, `property/documents` | -| Node references and `getWritable` | `LexicalNode.ts` (`getLatest`, `getParent`, siblings, children) | `node`, `utils` | through the nodes | -| Appending children | `ElementNode.splice` (insertion only), `RootNode.splice`, `caret/LexicalCaretTree.ts` | `caret_tree` | through parsing | -| Editor state | `LexicalEditorState.ts` (`toJSON`, `read`, `isEmpty`) | `editor_state` | `serialization/core` | -| Parsing | `parseEditorState`, `$parseSerializedNodeImpl` (`LexicalUpdates.ts`) | `updates` | `serialization/core`, `property/documents` | -| Editor | `createEditor` (node registration), `getEditorState`, `read` | `editor` | | +| Node methods | `LexicalNode.ts` (`getLatest`, `getWritable`, `isAttached`, `getIndexWithinParent`, `remove`/`$removeNode`, `replace`, `insertBefore`, `insertAfter`, `select*`, `markDirty`) | `node`, `utils` | `scripts/updates`, `scripts/transforms` | +| Element methods | `LexicalElementNode.ts` (`splice` with selection repair, `append`, `clear`, `select`, children and descendants, the setters), `RootNode.splice` and its other overrides | `nodes::element`, `node` | `scripts/updates` | +| 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` | +| 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` | +| Editor | `createEditor` (node registration), `update`, `read`, `getEditorState`, `setEditorState`, `registerNodeTransform`, `registerUpdateListener`, `registerTextContentListener`, `registerCommand`, `dispatchCommand`, `createCommand` | `editor` | `scripts/*` | +| UTF-16 offsets | `String#length`, `String#slice` | `utf16` | through the text methods; unit tests against Node | | Upstream version | `meta.json` | `UPSTREAM_VERSION` | `meta` | ## Not ported yet @@ -28,10 +33,16 @@ touches: - **NodeState** (`"$"`) and **slots** (`"$slots"`): a document that carries either fails to parse with an error naming it, rather than losing them. -- `editor.update`, the commit, normalization (JS's `setEditorState` merges - adjacent simple text nodes; the runner compares text content, which that - doesn't change), selection, transforms, commands and listeners. -- `RootNode.__cachedText`, which only the DOM reconciler fills. +- `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. +- Composition, the DOM reconciler, mutation listeners, selection-change + notifications and `$onUpdate` callbacks (none of which a headless editor + runs, except `$onUpdate`), and `RootNode.__cachedText`. ## Documented exceptions diff --git a/rust/conformance/src/suites/mod.rs b/rust/conformance/src/suites/mod.rs index 36a3b070b02..87d531d3d0b 100644 --- a/rust/conformance/src/suites/mod.rs +++ b/rust/conformance/src/suites/mod.rs @@ -3,6 +3,7 @@ mod meta; mod property; +mod scripts; mod serialization; use crate::Suite; @@ -19,6 +20,36 @@ pub fn all() -> Vec { variant: None, check: serialization::check, }, + Suite { + file: "scripts/updates", + variant: None, + check: scripts::check, + }, + Suite { + file: "scripts/transforms", + variant: None, + check: scripts::check, + }, + Suite { + file: "scripts/listeners", + variant: None, + check: scripts::check, + }, + Suite { + file: "scripts/commands", + variant: None, + check: scripts::check, + }, + Suite { + file: "scripts/selection", + variant: None, + check: scripts::check, + }, + Suite { + file: "scripts/caret", + variant: None, + check: scripts::check, + }, Suite { file: "property/documents", variant: Some("JSON and text"), diff --git a/rust/conformance/src/suites/scripts.rs b/rust/conformance/src/suites/scripts.rs new file mode 100644 index 00000000000..07d81a3af90 --- /dev/null +++ b/rust/conformance/src/suites/scripts.rs @@ -0,0 +1,564 @@ +//! `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/conformance/tests/conformance.rs b/rust/conformance/tests/conformance.rs index 2decb0f006d..6223b43c650 100644 --- a/rust/conformance/tests/conformance.rs +++ b/rust/conformance/tests/conformance.rs @@ -79,3 +79,42 @@ fn doctored_vectors_fail() { report.markdown() ); } + +/// The script suites compare what listeners saw after each step: in a copy +/// of `scripts/listeners.json` where one dirty element's flag and the tags +/// of the failed update's notification differ from what JS recorded, those +/// two cases fail, and only those. +#[test] +fn doctored_scripts_fail() { + let Some(vectors) = vectors_or_skip() else { + return; + }; + let text = std::fs::read_to_string(vectors.join("scripts/listeners.json")).unwrap(); + let flag = ("[\"1\",false]", "[\"1\",true]"); + let tags = ("[\"update\",[],[],[]]", "[\"update\",[],[],[\"tag\"]]"); + for (from, _) in [flag, tags] { + assert!(text.contains(from), "no {from} to doctor"); + } + let doctored = text.replacen(flag.0, flag.1, 1).replacen(tags.0, tags.1, 1); + + let dir = std::env::temp_dir().join(format!( + "lexical-conformance-doctored-scripts-{}", + std::process::id() + )); + std::fs::create_dir_all(dir.join("scripts")).unwrap(); + std::fs::write(dir.join("scripts/listeners.json"), doctored).unwrap(); + let report = lexical_conformance::run_in(&dir, Some("scripts/listeners")); + std::fs::remove_dir_all(&dir).unwrap(); + let report = report.unwrap(); + let f = report + .files + .iter() + .find(|f| f.file == "scripts/listeners") + .unwrap(); + assert_eq!( + (f.failed, f.failures.len()), + (2, 2), + "{}", + report.markdown() + ); +} diff --git a/rust/lexical-editor/src/caret_tree.rs b/rust/lexical-editor/src/caret_tree.rs index 6abbcca96a2..b27a5b13fe2 100644 --- a/rust/lexical-editor/src/caret_tree.rs +++ b/rust/lexical-editor/src/caret_tree.rs @@ -1,85 +1,288 @@ -//! `caret/LexicalCaretTree.ts`: linking children into their parent. +//! `caret/LexicalCaretTree.ts`: linking nodes into and out of their parent, +//! with the selection repairs that go with it. +//! +//! As in JS, the helpers that take "writable" nodes expect the caller to +//! have called `getWritable` on them already ([`get_writable`]), in the +//! order JS does: that order is the order update listeners see the nodes +//! marked dirty in. use crate::LexicalError; use crate::error::invariant; use crate::node::{NodeKey, NodeRef}; -use crate::utils::with_writable; +use crate::selection::{ + PointType, SelectionRef, Which, get_selection, selection_touches_element, + update_element_selection_on_create_delete_node, +}; +use crate::updates::error_on_read_only; +use crate::utils::{get_writable, mutate}; -/// `$linkSiblings`. -fn link_siblings(parent: &str, previous: Option, next: Option) { - match &previous { - None => with_writable(parent, |p| element_of(p).first = next.clone()), - Some(prev) => with_writable(prev, |p| p.base_mut().next = next.clone()), - } - match &next { - None => with_writable(parent, |p| element_of(p).last = previous.clone()), - Some(n) => with_writable(n, |n| n.base_mut().prev = previous.clone()), - } +fn set_first(parent: &str, key: Option) { + mutate(parent, |p| { + p.element_mut().expect("a parent is an element").first = key + }); } -fn element_of(node: &mut dyn crate::LexicalNode) -> &mut crate::ElementData { - node.element_mut().expect("a parent is an element") +fn set_last(parent: &str, key: Option) { + mutate(parent, |p| { + p.element_mut().expect("a parent is an element").last = key + }); +} + +pub(crate) fn add_size(parent: &str, delta: isize) { + mutate(parent, |p| { + let element = p.element_mut().expect("a parent is an element"); + element.size = element.size.checked_add_signed(delta).expect("size >= 0"); + }); } -/// `$insertNodeBetween`. -fn insert_node_between(parent: &str, node: &str, previous: Option, next: Option) { +/// `$linkSiblings(writableParent, writablePrevious, writableNext)`. +fn link_siblings(parent: &str, previous: Option<&str>, next: Option<&str>) { + match previous { + None => set_first(parent, next.map(str::to_owned)), + Some(p) => mutate(p, |p| p.base_mut().next = next.map(str::to_owned)), + } + match next { + None => set_last(parent, previous.map(str::to_owned)), + Some(n) => mutate(n, |n| n.base_mut().prev = previous.map(str::to_owned)), + } +} + +/// `$insertNodeBetween(writableParent, writableNode, writablePrevious, +/// writableNext)`. +pub(crate) fn insert_node_between( + parent: &str, + node: &str, + previous: Option<&str>, + next: Option<&str>, +) { let key = Some(node.to_owned()); - match &previous { - None => with_writable(parent, |p| element_of(p).first = key.clone()), - Some(prev) => with_writable(prev, |p| p.base_mut().next = key.clone()), + match previous { + None => set_first(parent, key.clone()), + Some(p) => mutate(p, |p| p.base_mut().next = key.clone()), } - match &next { - None => with_writable(parent, |p| element_of(p).last = key.clone()), - Some(n) => with_writable(n, |n| n.base_mut().prev = key.clone()), + match next { + None => set_last(parent, key), + Some(n) => mutate(n, |n| n.base_mut().prev = Some(node.to_owned())), } - with_writable(node, |n| { + mutate(node, |n| { let base = n.base_mut(); - base.prev = previous; - base.next = next; + base.prev = previous.map(str::to_owned); + base.next = next.map(str::to_owned); base.parent = Some(parent.to_owned()); }); } -/// `$detachNode`. -fn detach_node(node: &NodeRef) { - let latest = node.get_latest(); - let Some(parent) = latest.base().parent.clone() else { - return; - }; - link_siblings( - &parent, - latest.base().prev.clone(), - latest.base().next.clone(), - ); - with_writable(node.get_key(), |n| { +fn clear_links(node: &str) { + mutate(node, |n| { let base = n.base_mut(); base.prev = None; base.next = None; base.parent = None; }); - with_writable(&parent, |p| element_of(p).size -= 1); } -/// `parent.append(...nodes)`: `ElementNode.splice(size, 0, nodes)`, with -/// `RootNode.splice`'s check. A node that is already a child moves to the -/// end. The deleting half of `splice`, which also repairs the selection, -/// comes with the update API. -pub(crate) fn append(parent: &NodeRef, nodes: &[NodeRef]) -> Result<(), LexicalError> { - if parent.is_root() { - for node in nodes { - // DecoratorNode is allowed too, once it is ported. - invariant!( - node.is_element(), - "rootNode.splice: Only element or decorator nodes can be inserted to the root node" - ); - } +/// `$detachNode(writableNode)`: unlinks a writable node from its parent. +pub(crate) fn detach_node(node: &NodeRef) { + let Some(parent) = node.get_parent() else { + return; + }; + get_writable(parent.get_key()); + let previous = node.get_previous_sibling(); + let next = node.get_next_sibling(); + if let Some(p) = &previous { + get_writable(p.get_key()); } - for node in nodes { - invariant!(node != parent, "append: attempting to append self"); - detach_node(node); - let last = parent.get_last_child().map(|n| n.get_key().to_owned()); - insert_node_between(parent.get_key(), node.get_key(), last, None); - with_writable(parent.get_key(), |p| element_of(p).size += 1); + if let Some(n) = &next { + get_writable(n.get_key()); } + link_siblings( + parent.get_key(), + previous.as_ref().map(NodeRef::get_key), + next.as_ref().map(NodeRef::get_key), + ); + clear_links(node.get_key()); + add_size(parent.get_key(), -1); +} + +/// `$detachSiblingRange(writableParent, first, count, before)`: unlinks +/// `count` siblings from `first` on, and returns their keys. +pub(crate) fn detach_sibling_range( + parent: &NodeRef, + first: Option, + count: usize, + before: Option<&NodeRef>, +) -> Result, LexicalError> { + let mut nodes = Vec::with_capacity(count); + let mut node = first; + for _ in 0..count { + let Some(n) = node else { + return Err(LexicalError::new("splice: sibling not found")); + }; + let next = n.get_next_sibling(); + get_writable(n.get_key()); + nodes.push(n); + node = next; + } + if let Some(b) = before { + get_writable(b.get_key()); + } + if let Some(n) = &node { + get_writable(n.get_key()); + } + link_siblings( + parent.get_key(), + before.map(NodeRef::get_key), + node.as_ref().map(NodeRef::get_key), + ); + add_size(parent.get_key(), -(nodes.len() as isize)); + Ok(nodes + .into_iter() + .map(|n| { + clear_links(n.get_key()); + n.get_key().to_owned() + }) + .collect()) +} + +/// `$detachNodeWithSelection(node, selection)`: detaches a writable node, +/// shifting element points on its parent past it. Returns the points that +/// were just after the node, which `insertAfter` puts after it again. +pub(crate) fn detach_node_with_selection( + node: &NodeRef, + selection: Option<&SelectionRef>, +) -> Result>, LexicalError> { + let parent = selection.and_then(|_| node.get_parent()); + // Keep bulk moves linear when no element point observes the index. + let index = match (selection, &parent) { + (Some(s), Some(p)) if selection_touches_element(s, p) => node.get_index_within_parent(), + _ => None, + }; + let mut points = None; + if let (Some(selection), Some(parent), Some(index)) = (selection, &parent, index) { + points = Some( + [Which::Anchor, Which::Focus] + .into_iter() + .filter(|&which| { + let point = selection.point(which).expect("a range selection"); + point.point_type == PointType::Element + && point.key == parent.get_key() + && point.offset == index + 1 + }) + .collect(), + ); + // Offset repair needs direction while both points still share a + // tree. + selection.is_backward()?; + } + detach_node(node); + if let (Some(selection), Some(parent), Some(index)) = (selection, &parent, index) { + update_element_selection_on_create_delete_node(selection, parent, index, -1)?; + } + if let Some(selection) = selection { + // Reinsertion can reverse text-only ranges without changing either + // point. + selection.with_range(|r| r.cached_is_backward = None); + } + Ok(points) +} + +/// `errorOnInsertTextNodeOnRoot(node, insertNode)`. +pub(crate) fn error_on_insert_text_node_on_root( + node: &NodeRef, + insert_node: &NodeRef, +) -> Result<(), LexicalError> { + invariant!( + !node.get_parent().is_some_and(|p| p.is_root()) || insert_node.is_element(), + "Only element or decorator nodes can be inserted in to the root node" + ); Ok(()) } + +/// `$insertSibling(origin, direction, node, restoreSelection)`: the shared +/// body of `insertBefore` and `insertAfter`. +pub(crate) fn insert_sibling( + origin: &NodeRef, + is_next: bool, + node: &NodeRef, + restore_selection: bool, +) -> Result { + error_on_read_only(); + error_on_insert_text_node_on_root(origin, node)?; + get_writable(origin.get_key()); + get_writable(node.get_key()); + origin.get_parent_or_throw()?; + let selection = get_selection().filter(|s| restore_selection && s.is_range()); + let points = detach_node_with_selection(node, selection.as_ref())?; + let parent = origin.get_parent_or_throw()?; + get_writable(parent.get_key()); + // Before insertion this is the new node's index in either direction. + let index = match &selection { + Some(s) + if (is_next && points.as_ref().is_some_and(|p| !p.is_empty())) + || selection_touches_element(s, &parent) => + { + origin + .get_index_within_parent() + .map(|i| i + usize::from(is_next)) + } + _ => None, + }; + let sibling = if is_next { + origin.get_next_sibling() + } else { + origin.get_previous_sibling() + }; + if let Some(s) = &sibling { + get_writable(s.get_key()); + } + let sibling_key = sibling.as_ref().map(NodeRef::get_key); + let (previous, next) = if is_next { + (Some(origin.get_key()), sibling_key) + } else { + (sibling_key, Some(origin.get_key())) + }; + insert_node_between(parent.get_key(), node.get_key(), previous, next); + add_size(parent.get_key(), 1); + if let (Some(selection), Some(index)) = (&selection, index) { + update_element_selection_on_create_delete_node(selection, &parent, index, 1)?; + if is_next && let Some(points) = points { + for which in points { + selection.point_set(which, parent.get_key(), index + 1, PointType::Element)?; + } + } + } + Ok(node.clone()) +} + +/// `$selectAdjacentNode(caret, anchorOffset, focusOffset)`: selects next to +/// `origin`, in the sibling on that side or in the parent. +pub(crate) fn select_adjacent_node( + origin: &NodeRef, + is_next: bool, + anchor_offset: Option, + focus_offset: Option, +) -> Result { + error_on_read_only(); + let sibling = if is_next { + origin.get_next_sibling() + } else { + origin.get_previous_sibling() + }; + let parent = origin.get_parent_or_throw()?; + match sibling { + None if is_next => parent.select(None, None), + None => parent.select(Some(0), Some(0)), + Some(sibling) if sibling.is_element() => { + if is_next { + sibling.select(Some(0), Some(0)) + } else { + sibling.select(None, None) + } + } + Some(sibling) if sibling.is_text() => sibling.select(anchor_offset, focus_offset), + Some(sibling) => { + let index = sibling.get_index_within_parent().unwrap_or(0) + usize::from(!is_next); + parent.select(Some(index), Some(index)) + } + } +} diff --git a/rust/lexical-editor/src/editor.rs b/rust/lexical-editor/src/editor.rs index 0abe87c5820..3f1128654af 100644 --- a/rust/lexical-editor/src/editor.rs +++ b/rust/lexical-editor/src/editor.rs @@ -1,18 +1,26 @@ -//! `LexicalEditor.ts`: creating an editor and its registered nodes. +//! `LexicalEditor.ts`: creating an editor, its registered nodes, and the +//! registration of transforms, listeners and commands. -use crate::editor_state::create_empty_editor_state; +use crate::editor_state::{clone_editor_state, create_empty_editor_state}; +use crate::error::invariant; use crate::json::{self, Object, Value}; -use crate::node::{LexicalNode, NodeRef}; +use crate::node::{LexicalNode, NodeKey, NodeRef}; use crate::nodes::{ line_break::LineBreakNode, paragraph::ParagraphNode, root::RootNode, tab::TabNode, text::TextNode, }; -use crate::updates::{parse_editor_state, read_editor_state}; +use crate::ordered::{OrderedMap, OrderedSet}; +use crate::updates::{ + begin_update, commit_pending_updates, parse_editor_state, read_editor_state, + trigger_command_listeners, update_editor_sync, +}; use crate::utils::set_node_key; use crate::{EditorState, LexicalError}; -use std::cell::RefCell; -use std::collections::HashMap; -use std::rc::Rc; +use std::any::Any; +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, VecDeque}; +use std::marker::PhantomData; +use std::rc::{Rc, Weak}; /// A node class an editor registers: its type and how it imports JSON (the /// static `importJSON`). @@ -56,20 +64,151 @@ impl NodeClass { } } +/// `HISTORY_MERGE_TAG` (`LexicalUpdateTags.ts`): the update joins the +/// previous history entry. +pub const HISTORY_MERGE_TAG: &str = "history-merge"; + /// `CreateEditorArgs`: the node classes to register besides the core ones. #[derive(Clone, Default)] pub struct CreateEditorArgs { pub nodes: Vec, } +/// `EditorUpdateOptions`. `onUpdate` and `event` come with their users. +#[derive(Clone, Debug, Default)] +pub struct UpdateOptions { + /// Commit at the end of the update instead of at the next read. + pub discrete: bool, + /// Update tags (`tag`). + pub tags: Vec, + pub skip_transforms: bool, +} + +/// What an update listener receives (`UpdateListenerPayload`). The dirty +/// nodes are in the order they were marked; each dirty element says whether +/// it was marked itself (`true`) or only because a descendant was. +#[derive(Clone, Debug)] +pub struct UpdateListenerPayload { + pub dirty_elements: Vec<(NodeKey, bool)>, + pub dirty_leaves: Vec, + pub normalized_nodes: Vec, + pub tags: Vec, + pub editor_state: EditorState, + pub prev_editor_state: EditorState, +} + +/// A node transform (`Transform`). +pub type Transform = Rc Result<(), LexicalError>>; + +/// Calling it removes what was registered, as the function JS's `register*` +/// methods return. Dropping it without calling it keeps the registration. +pub type Unregister = Box; + +/// A command (`createCommand`), compared by identity: two commands made +/// with the same name are different commands. +pub struct LexicalCommand

{ + id: u64, + name: Rc, + _payload: PhantomData, +} + +impl

Clone for LexicalCommand

{ + fn clone(&self) -> Self { + Self { + id: self.id, + name: self.name.clone(), + _payload: PhantomData, + } + } +} + +impl

LexicalCommand

{ + pub fn name(&self) -> &str { + &self.name + } + pub(crate) fn id(&self) -> u64 { + self.id + } +} + +thread_local! { + static NEXT_COMMAND_ID: Cell = const { Cell::new(1) }; +} + +/// `createCommand(type)`. +pub fn create_command

(name: &str) -> LexicalCommand

{ + let id = NEXT_COMMAND_ID.with(|c| c.replace(c.get() + 1)); + LexicalCommand { + id, + name: name.into(), + _payload: PhantomData, + } +} + +pub const COMMAND_PRIORITY_EDITOR: u8 = 0; +pub const COMMAND_PRIORITY_LOW: u8 = 1; +pub const COMMAND_PRIORITY_NORMAL: u8 = 2; +pub const COMMAND_PRIORITY_HIGH: u8 = 3; +pub const COMMAND_PRIORITY_CRITICAL: u8 = 4; + +/// A command listener: it returns whether it handled the command, which +/// stops the ones after it. +pub(crate) type CommandListener

= Rc Result>; + +/// `NO_DIRTY_NODES`, `HAS_DIRTY_NODES`, `FULL_RECONCILE`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum DirtyType { + #[default] + None, + HasDirtyNodes, + FullReconcile, +} + +/// The editor's record of what an update changed: `_dirtyType`, +/// `_dirtyElements`, `_dirtyLeaves` and `_normalizedNodes`. +#[derive(Default)] +pub(crate) struct Dirty { + pub(crate) dirty_type: DirtyType, + pub(crate) elements: OrderedMap, + pub(crate) leaves: OrderedSet, + pub(crate) normalized: OrderedSet, +} + +/// An update queued while the editor was updating (`_updates`). +pub(crate) struct QueuedUpdate { + pub(crate) id: u64, + pub(crate) update_fn: Box Result<(), LexicalError>>, + pub(crate) options: UpdateOptions, +} + /// A Lexical editor. A handle; cloning it shares the same editor. Without a -/// DOM it is what JS calls a headless editor. +/// DOM it is what JS calls a headless editor. It is neither `Send` nor +/// `Sync`. #[derive(Clone)] -pub struct Editor(Rc); +pub struct Editor(pub(crate) Rc); + +type Listeners = RefCell)>>; +type UpdateListener = Rc; +type TextContentListener = Rc; +/// A command's listeners by priority, each a `CommandListener

` of the +/// command's payload type. +type CommandListeners = [Vec<(u64, Rc)>; 5]; -struct EditorInner { +pub(crate) struct EditorInner { nodes: HashMap<&'static str, NodeClass>, - editor_state: RefCell, + pub(crate) editor_state: RefCell, + pub(crate) pending: RefCell>, + pub(crate) dirty: RefCell, + pub(crate) tags: RefCell, + pub(crate) updating: Cell, + pub(crate) dispatching_commit_listeners: Cell, + pub(crate) updates: RefCell>, + pub(crate) cascade_count: Cell, + transforms: RefCell>>, + update_listeners: Listeners, + text_content_listeners: Listeners, + commands: RefCell>, + next_id: Cell, } /// `createEditor(config)`: registers the core nodes (root, text, line @@ -90,6 +229,18 @@ pub fn create_editor(args: CreateEditorArgs) -> Editor { Editor(Rc::new(EditorInner { nodes, editor_state: RefCell::new(create_empty_editor_state()), + pending: RefCell::new(None), + dirty: RefCell::new(Dirty::default()), + tags: RefCell::new(OrderedSet::new()), + updating: Cell::new(false), + dispatching_commit_listeners: Cell::new(false), + updates: RefCell::new(VecDeque::new()), + cascade_count: Cell::new(0), + transforms: RefCell::new(HashMap::new()), + update_listeners: RefCell::new(Vec::new()), + text_content_listeners: RefCell::new(Vec::new()), + commands: RefCell::new(HashMap::new()), + next_id: Cell::new(1), })) } @@ -98,11 +249,27 @@ impl Editor { &self.0.nodes } - /// `getEditorState()`. + pub(crate) fn ptr_eq(&self, other: &Editor) -> bool { + Rc::ptr_eq(&self.0, &other.0) + } + + pub(crate) fn next_id(&self) -> u64 { + self.0.next_id.replace(self.0.next_id.get() + 1) + } + + fn weak(&self) -> Weak { + Rc::downgrade(&self.0) + } + + /// `getEditorState()`: the committed state. pub fn get_editor_state(&self) -> EditorState { self.0.editor_state.borrow().clone() } + pub(crate) fn pending(&self) -> Option { + self.0.pending.borrow().clone() + } + /// `parseEditorState(json)` from the JSON text. pub fn parse_editor_state(&self, json: &str) -> Result { let value = json::parse(json).map_err(|e| LexicalError::new(e.to_string()))?; @@ -114,9 +281,316 @@ impl Editor { parse_editor_state(json, self) } - /// `read(callbackFn)`: runs `f` with the editor's state active, - /// read-only. - pub fn read(&self, f: impl FnOnce() -> R) -> R { - read_editor_state(&self.get_editor_state(), f) + /// `update(updateFn, options)`. Inside another update of this editor (or + /// a listener of its commit) the update is queued and runs after it, as + /// in JS, which is why `update_fn` is `'static`. + /// + /// A non-discrete update is committed by the next [`read`](Self::read) + /// or discrete update, where JS commits it in a microtask. An update + /// that fails is discarded, the editor keeps its previous state, and the + /// error is returned (JS reports it to `onError`). + pub fn update( + &self, + update_fn: impl FnOnce() -> Result<(), LexicalError> + 'static, + options: UpdateOptions, + ) -> Result<(), LexicalError> { + if self.0.updating.get() { + let id = self.next_id(); + self.0.updates.borrow_mut().push_back(QueuedUpdate { + id, + update_fn: Box::new(update_fn), + options, + }); + Ok(()) + } else { + begin_update(self, update_fn, &options) + } + } + + /// `setEditorState(editorState, {tag})`: makes `editor_state` the + /// editor's state, through an update, so a state from + /// [`parse_editor_state`](Self::parse_editor_state) has its text + /// normalized and its transforms run, as in JS. The editor keeps its + /// state and this returns the error when the update fails, such as for + /// a state with nothing but the root (an invariant in JS's DEV build). + pub fn set_editor_state( + &self, + editor_state: &EditorState, + tag: Option<&str>, + ) -> Result<(), LexicalError> { + let is_empty_editor_state = editor_state.is_empty(); + // A writable state, so transforms can run on it. + let writable_editor_state = if editor_state.0.read_only.get() { + let writable = clone_editor_state(editor_state); + *writable.0.selection.borrow_mut() = + editor_state.selection().map(|s| s.clone_selection()); + writable + } else { + editor_state.clone() + }; + let tags: Vec = tag.map(str::to_owned).into_iter().collect(); + if let Some(pending) = self.pending() + && !pending.is_empty() + { + self.add_tags(&tags); + commit_pending_updates(self)?; + } + *self.0.pending.borrow_mut() = Some(writable_editor_state.clone()); + { + let mut dirty = self.0.dirty.borrow_mut(); + dirty.dirty_type = DirtyType::FullReconcile; + dirty.elements.set("root", false); + } + let parsed = editor_state.0.parsed.get(); + let options = UpdateOptions { + discrete: true, + ..UpdateOptions::default() + }; + let editor = self.clone(); + update_editor_sync( + self, + move || { + editor.add_tags(&tags); + invariant!( + !is_empty_editor_state, + "setEditorState: the editor state is empty. Ensure the editor state's root node never becomes empty." + ); + if parsed { + // Every node is dirty, so normalization and transforms + // run on a state that arrived from outside. + let node_map = writable_editor_state.0.node_map.borrow(); + let mut dirty = editor.0.dirty.borrow_mut(); + for key in writable_editor_state.keys_in_creation_order() { + if node_map[&key].element().is_some() { + dirty.elements.set(&key, true); + } else { + dirty.leaves.add(&key); + } + } + } + Ok(()) + }, + (!self.0.updating.get()).then_some(&options), + ) + } + + /// `read(callbackFn)`: commits any pending update, then runs `f` with + /// the editor's state active, read-only. Fails when committing runs a + /// queued update that fails. + pub fn read(&self, f: impl FnOnce() -> R) -> Result { + if !self.0.dispatching_commit_listeners.get() { + commit_pending_updates(self)?; + } + Ok(read_editor_state(Some(self), &self.get_editor_state(), f)) + } + + /// `registerNodeTransform(klass, listener)`, by the node type. Nodes of + /// that type already in the editor are marked dirty, so the transform + /// runs on them with the next update. + pub fn register_node_transform( + &self, + node_type: &'static str, + transform: impl Fn(&NodeRef) -> Result<(), LexicalError> + 'static, + ) -> Unregister { + let id = self.next_id(); + self.0 + .transforms + .borrow_mut() + .entry(node_type) + .or_default() + .push((id, Rc::new(transform))); + mark_nodes_with_types_as_dirty(self, &[node_type]); + let weak = self.weak(); + Box::new(move || { + if let Some(inner) = weak.upgrade() + && let Some(list) = inner.transforms.borrow_mut().get_mut(node_type) + { + list.retain(|(i, _)| *i != id); + } + }) + } + + pub(crate) fn transforms_of(&self, node_type: &str) -> Vec { + self.0 + .transforms + .borrow() + .get(node_type) + .map(|list| list.iter().map(|(_, t)| t.clone()).collect()) + .unwrap_or_default() + } + + /// `registerUpdateListener(listener)`. + pub fn register_update_listener( + &self, + listener: impl Fn(&UpdateListenerPayload) + 'static, + ) -> Unregister { + let id = self.next_id(); + self.0 + .update_listeners + .borrow_mut() + .push((id, Rc::new(listener))); + let weak = self.weak(); + Box::new(move || { + if let Some(inner) = weak.upgrade() { + inner + .update_listeners + .borrow_mut() + .retain(|(i, _)| *i != id); + } + }) + } + + /// `registerTextContentListener(listener)`. + pub fn register_text_content_listener(&self, listener: impl Fn(&str) + 'static) -> Unregister { + let id = self.next_id(); + self.0 + .text_content_listeners + .borrow_mut() + .push((id, Rc::new(listener))); + let weak = self.weak(); + Box::new(move || { + if let Some(inner) = weak.upgrade() { + inner + .text_content_listeners + .borrow_mut() + .retain(|(i, _)| *i != id); + } + }) + } + + pub(crate) fn update_listeners(&self) -> Vec { + self.0 + .update_listeners + .borrow() + .iter() + .map(|(_, l)| l.clone()) + .collect() + } + + pub(crate) fn text_content_listeners(&self) -> Vec { + self.0 + .text_content_listeners + .borrow() + .iter() + .map(|(_, l)| l.clone()) + .collect() + } + + /// `registerCommand(command, listener, priority)`, `priority` being one + /// of the `COMMAND_PRIORITY_*` constants (0 to 4). + pub fn register_command( + &self, + command: &LexicalCommand

, + listener: impl Fn(&P, &Editor) -> Result + 'static, + priority: u8, + ) -> Unregister { + let priority = usize::from(priority.min(COMMAND_PRIORITY_CRITICAL)); + let id = self.next_id(); + let listener: CommandListener

= Rc::new(listener); + let command_id = command.id(); + self.0.commands.borrow_mut().entry(command_id).or_default()[priority] + .push((id, Rc::new(listener) as Rc)); + let weak = self.weak(); + Box::new(move || { + if let Some(inner) = weak.upgrade() + && let Some(lists) = inner.commands.borrow_mut().get_mut(&command_id) + { + lists[priority].retain(|(i, _)| *i != id); + } + }) + } + + pub(crate) fn command_listeners( + &self, + command: &LexicalCommand

, + priority: usize, + ) -> Vec> { + self.0 + .commands + .borrow() + .get(&command.id()) + .map(|lists| { + lists[priority] + .iter() + .filter_map(|(_, l)| l.downcast_ref::>().cloned()) + .collect() + }) + .unwrap_or_default() + } + + /// `dispatchCommand(command, payload)`: runs the command's listeners, by + /// priority, until one handles it, and returns whether one did. + pub fn dispatch_command( + &self, + command: &LexicalCommand

, + payload: P, + ) -> Result { + trigger_command_listeners(self, command, &payload) + } + + // The dirty records. + + pub(crate) fn dirty_type(&self) -> DirtyType { + self.0.dirty.borrow().dirty_type + } + + /// Marks a node dirty: an element as intentionally dirty, any other node + /// as a dirty leaf. + pub(crate) fn mark_dirty(&self, key: &str, is_element: bool) { + let mut dirty = self.0.dirty.borrow_mut(); + // Don't downgrade a full reconcile. + if dirty.dirty_type == DirtyType::None { + dirty.dirty_type = DirtyType::HasDirtyNodes; + } + if is_element { + dirty.elements.set(key, true); + } else { + dirty.leaves.add(key); + } + } + + pub(crate) fn add_tags(&self, tags: &[String]) { + let mut set = self.0.tags.borrow_mut(); + for tag in tags { + set.add(tag); + } + } +} + +/// `markNodesWithTypesAsDirty(editor, types)`: an update that marks every +/// node of those types in the committed state dirty, in the order JS's +/// node map holds them. +fn mark_nodes_with_types_as_dirty(editor: &Editor, types: &[&str]) { + let editor_state = editor.get_editor_state(); + let keys: Vec = { + let node_map = editor_state.0.node_map.borrow(); + editor_state + .keys_in_creation_order() + .into_iter() + .filter(|key| types.contains(&node_map[key].get_type())) + .collect() + }; + if keys.is_empty() { + return; } + let options = UpdateOptions { + tags: if editor.pending().is_none() { + vec![HISTORY_MERGE_TAG.to_owned()] + } else { + Vec::new() + }, + ..UpdateOptions::default() + }; + // Marking existing nodes dirty can't fail. + let _ = editor.update( + move || { + for key in &keys { + if let Some(node) = crate::get_node_by_key(key) { + node.mark_dirty(); + } + } + Ok(()) + }, + options, + ); } diff --git a/rust/lexical-editor/src/editor_state.rs b/rust/lexical-editor/src/editor_state.rs index 8eb8b9c11aa..59907931248 100644 --- a/rust/lexical-editor/src/editor_state.rs +++ b/rust/lexical-editor/src/editor_state.rs @@ -3,6 +3,7 @@ use crate::json::{Object, Value}; use crate::node::{LexicalNode, NodeKey, NodeRef}; use crate::nodes::root::RootNode; +use crate::selection::SelectionRef; use crate::serialized_export::{export_node_json, with_compact_export}; use crate::updates::read_editor_state; use crate::utils::get_root; @@ -12,28 +13,46 @@ use std::rc::Rc; pub(crate) type NodeMap = HashMap>; -/// An editor state: the node map (and, once ported, the selection). A -/// handle; cloning it shares the same state. Once read-only, nothing changes -/// it. +/// An editor state: the node map and the selection. A handle; cloning it +/// shares the same state. Once read-only (committed or parsed), nothing +/// changes it: an update works on a clone whose node map shares the nodes, +/// and a node is copied before its first change (`getWritable`). #[derive(Clone, Debug)] pub struct EditorState(pub(crate) Rc); #[derive(Debug)] pub(crate) struct EditorStateInner { pub(crate) node_map: RefCell, + pub(crate) selection: RefCell>, pub(crate) read_only: Cell, + /// `_flushSync`: commit at the end of the update (`discrete`). + pub(crate) flush_sync: Cell, + /// `_parsed`: made by `parseEditorState`, so no transform has run on it. + pub(crate) parsed: Cell, } -/// `createEmptyEditorState()`. -pub(crate) fn create_empty_editor_state() -> EditorState { - let mut node_map = NodeMap::new(); - node_map.insert("root".to_owned(), Rc::new(RootNode::new())); +fn new_editor_state(node_map: NodeMap) -> EditorState { EditorState(Rc::new(EditorStateInner { node_map: RefCell::new(node_map), + selection: RefCell::new(None), read_only: Cell::new(false), + flush_sync: Cell::new(false), + parsed: Cell::new(false), })) } +/// `createEmptyEditorState()`. +pub(crate) fn create_empty_editor_state() -> EditorState { + let mut node_map = NodeMap::new(); + node_map.insert("root".to_owned(), Rc::new(RootNode::new())); + new_editor_state(node_map) +} + +/// `cloneEditorState(current)`: the same nodes, no selection, writable. +pub(crate) fn clone_editor_state(current: &EditorState) -> EditorState { + new_editor_state(current.0.node_map.borrow().clone()) +} + /// `$exportNodeToJSON(node)`: the node and its children. Slots come with /// their port. fn export_node_to_json(node: &NodeRef) -> Value { @@ -50,15 +69,32 @@ fn export_node_to_json(node: &NodeRef) -> Value { } impl EditorState { - /// `isEmpty()`: whether the state has nothing but its root (and no - /// selection). + /// `isEmpty()`: nothing but the root, and no selection. pub fn is_empty(&self) -> bool { - self.0.node_map.borrow().len() <= 1 + self.0.node_map.borrow().len() <= 1 && self.0.selection.borrow().is_none() + } + + /// The state's selection (`editorState._selection`). + pub fn selection(&self) -> Option { + self.0.selection.borrow().clone() + } + + /// The node keys in the order JS's node map holds them, the order nodes + /// were created in: the root, then by key, as keys come from a counter. + pub(crate) fn keys_in_creation_order(&self) -> Vec { + let mut keys: Vec = self.0.node_map.borrow().keys().cloned().collect(); + keys.sort_by_key(|k| (k != "root", k.parse::().unwrap_or(u64::MAX), k.clone())); + keys + } + + /// Whether `self` and `other` are the same state. + pub fn ptr_eq(&self, other: &EditorState) -> bool { + Rc::ptr_eq(&self.0, &other.0) } /// `read(callbackFn)`: runs `f` with this state active, read-only. pub fn read(&self, f: impl FnOnce() -> R) -> R { - read_editor_state(self, f) + read_editor_state(None, self, f) } /// `toJSON(compact)`: the document's JSON, in the compact form or the @@ -66,7 +102,7 @@ impl EditorState { /// is `JSON.stringify(editorState.toJSON())`. pub fn to_json(&self, compact: bool) -> Value { with_compact_export(compact, || { - read_editor_state(self, || { + read_editor_state(None, self, || { let mut json = Object::new(); json.insert("root", export_node_to_json(&get_root())); Value::Object(json) diff --git a/rust/lexical-editor/src/gc.rs b/rust/lexical-editor/src/gc.rs new file mode 100644 index 00000000000..35ee06bddc7 --- /dev/null +++ b/rust/lexical-editor/src/gc.rs @@ -0,0 +1,118 @@ +//! `LexicalGC.ts`: dropping the nodes an update detached. Decorators and +//! slots come with their ports. + +use crate::editor_state::NodeMap; +use crate::ordered::OrderedMap; +use crate::{Editor, EditorState}; + +/// `node.isAttached()` on a node map: whether its parents lead to the root. +fn is_attached(node_map: &NodeMap, key: &str) -> bool { + let mut key = key; + loop { + if key == "root" { + return true; + } + match node_map.get(key).and_then(|n| n.base().parent.as_deref()) { + Some(parent) => key = parent, + None => return false, + } + } +} + +/// `$garbageCollectDetachedDeepChildNodes`. +fn garbage_collect_detached_deep_child_nodes( + parent_key: &str, + prev_node_map: &NodeMap, + node_map: &NodeMap, + node_map_delete: &mut Vec, + dirty_elements: &mut OrderedMap, +) { + let Some(node) = node_map.get(parent_key) else { + return; + }; + let Some(element) = node.element() else { + return; + }; + let mut child_key = element.first.clone(); + while let Some(key) = child_key { + let Some(child) = node_map.get(&key) else { + break; + }; + let is_element = child.element().is_some(); + // A dirty child element is left to the outer loop over the dirty + // elements, which visits it (unless deleted before its turn). + if child.base().parent.as_deref() == Some(parent_key) + && !(is_element && dirty_elements.contains(&key)) + { + if is_element { + garbage_collect_detached_deep_child_nodes( + &key, + prev_node_map, + node_map, + node_map_delete, + dirty_elements, + ); + } + // A node created and dropped in this update leaves the dirty + // record too. + if !prev_node_map.contains_key(&key) { + dirty_elements.delete(&key); + } + node_map_delete.push(key.clone()); + } + child_key = child.base().next.clone(); + } +} + +/// `$garbageCollectDetachedNodes(prevEditorState, editorState, dirtyLeaves, +/// dirtyElements)`: removes the detached dirty nodes, and their subtrees, +/// from `editor_state`. +pub(crate) fn garbage_collect_detached_nodes( + prev_editor_state: &EditorState, + editor_state: &EditorState, + editor: &Editor, +) { + let prev_node_map = prev_editor_state.0.node_map.borrow(); + let mut node_map_delete = Vec::new(); + { + let node_map = editor_state.0.node_map.borrow(); + let mut dirty = editor.0.dirty.borrow_mut(); + let mut i = 0; + while let Some(key) = dirty.elements.entry_at(&mut i).map(|(k, _)| k.to_owned()) { + i += 1; + let Some(node) = node_map.get(&key) else { + continue; + }; + if is_attached(&node_map, &key) { + continue; + } + if node.element().is_some() { + garbage_collect_detached_deep_child_nodes( + &key, + &prev_node_map, + &node_map, + &mut node_map_delete, + &mut dirty.elements, + ); + } + if !prev_node_map.contains_key(&key) { + dirty.elements.delete(&key); + } + node_map_delete.push(key); + } + let mut i = 0; + while let Some(key) = dirty.leaves.key_at(&mut i).map(str::to_owned) { + i += 1; + if node_map.contains_key(&key) && !is_attached(&node_map, &key) { + if !prev_node_map.contains_key(&key) { + dirty.leaves.delete(&key); + } + node_map_delete.push(key); + } + } + } + let mut node_map = editor_state.0.node_map.borrow_mut(); + for key in node_map_delete { + node_map.remove(&key); + } +} diff --git a/rust/lexical-editor/src/lib.rs b/rust/lexical-editor/src/lib.rs index c36a7b2e2ea..4c8360c2af9 100644 --- a/rust/lexical-editor/src/lib.rs +++ b/rust/lexical-editor/src/lib.rs @@ -7,12 +7,13 @@ //! //! The internals mirror the upstream sources file by file. JS's `$` //! functions drop the `$` and take snake case: `$getRoot()` is -//! [`get_root()`], and like JS they only work inside [`Editor::read`] or -//! [`EditorState::read`]. A node is read through a [`NodeRef`], which -//! resolves the latest version on every call, as JS node methods do. +//! [`get_root()`], and like JS they only work inside [`Editor::update`], +//! [`Editor::read`] or [`EditorState::read`]. A node is read and changed +//! through a [`NodeRef`], which resolves the latest version on every call, +//! as JS node methods do. Text offsets are UTF-16 code units, as in JS. //! //! ``` -//! use lexical_editor::{create_editor, get_root, CreateEditorArgs}; +//! use lexical_editor::{create_editor, get_root, CreateEditorArgs, UpdateOptions}; //! //! let editor = create_editor(CreateEditorArgs::default()); //! let state = editor @@ -23,6 +24,24 @@ //! state.to_json(true).to_string(), //! r#"{"root":{"type":"root","children":[{"type":"paragraph","children":[{"type":"text","text":"Hi"}]}]}}"#, //! ); +//! +//! // An update: split the text and bold the second half. +//! editor.set_editor_state(&state, None).unwrap(); +//! editor +//! .update( +//! || { +//! let text = get_root().get_first_descendant().unwrap(); +//! let parts = text.split_text(&[1])?; +//! parts[1].toggle_format("bold"); +//! Ok(()) +//! }, +//! UpdateOptions { discrete: true, ..UpdateOptions::default() }, +//! ) +//! .unwrap(); +//! assert_eq!( +//! editor.get_editor_state().to_json(true).to_string(), +//! r#"{"root":{"type":"root","children":[{"type":"paragraph","children":[{"type":"text","text":"H"},{"type":"text","format":1,"text":"i"}]}]}}"#, +//! ); //! ``` mod caret_tree; @@ -30,21 +49,37 @@ pub mod constants; mod editor; mod editor_state; mod error; +mod gc; mod generated_json; pub mod json; mod node; pub mod nodes; +mod normalization; +mod ordered; +mod selection; mod serialized_export; mod updates; +pub mod utf16; mod utils; -pub use editor::{CreateEditorArgs, Editor, NodeClass, create_editor}; +pub use editor::{ + COMMAND_PRIORITY_CRITICAL, COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_HIGH, + COMMAND_PRIORITY_LOW, COMMAND_PRIORITY_NORMAL, CreateEditorArgs, Editor, HISTORY_MERGE_TAG, + LexicalCommand, NodeClass, Transform, Unregister, UpdateListenerPayload, UpdateOptions, + create_command, create_editor, +}; pub use editor_state::EditorState; pub use error::LexicalError; pub use node::{LexicalNode, NodeBase, NodeClone, NodeKey, NodeRef}; pub use nodes::element::{Direction, ElementData}; -pub use nodes::text::TextData; +pub use nodes::paragraph::create_paragraph_node; +pub use nodes::text::{TextData, create_text_node, toggle_text_format_type}; +pub use selection::{ + NodeSelection, Point, PointType, RangeSelection, Selection, SelectionRef, Which, + create_node_selection, create_range_selection, get_selection, set_selection, +}; 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}; /// The upstream Lexical release this port conforms to. The conformance diff --git a/rust/lexical-editor/src/node.rs b/rust/lexical-editor/src/node.rs index 4401928bfff..872018ce440 100644 --- a/rust/lexical-editor/src/node.rs +++ b/rust/lexical-editor/src/node.rs @@ -1,13 +1,21 @@ //! `LexicalNode.ts`: the node trait every node implements, and [`NodeRef`], //! the handle through which code outside a node reads and changes one. -use crate::caret_tree; -use crate::nodes::element::{self, ElementData}; +use crate::caret_tree::{ + detach_node_with_selection, error_on_insert_text_node_on_root, insert_node_between, + insert_sibling, select_adjacent_node, +}; +use crate::error::invariant; +use crate::nodes::element::{self, ElementData, is_root_or_shadow_root}; use crate::nodes::root::RootNode; use crate::nodes::text::TextData; -use crate::updates::get_active_editor_state; -use crate::utils::{get_node_by_key_impl, with_writable}; -use crate::{LexicalError, json::Object}; +use crate::selection::{ + PointType, SelectionRef, Which, get_selection, maybe_move_children_selection_to_parent, + move_selection_point_to_end, move_selection_point_to_sibling, set_selection, +}; +use crate::updates::{error_on_read_only, get_active_editor_state}; +use crate::utils::{get_node_by_key_impl, get_writable, mutate, with_writable}; +use crate::{LexicalError, json::Object, utf16}; use std::any::Any; use std::fmt; use std::rc::Rc; @@ -71,6 +79,12 @@ pub trait LexicalNode: NodeClone + fmt::Debug + 'static { false } + /// `ElementNode.canBeEmpty()`: whether the element stays when its last + /// child is removed. + fn can_be_empty(&self) -> bool { + true + } + /// `getTextContent()`. fn get_text_content(&self) -> String { if let Some(text) = self.text() { @@ -236,14 +250,301 @@ impl NodeRef { self.get_latest().export_json(compact) } - /// `append(...nodes)`, the insertion half of `ElementNode.splice` (with - /// `RootNode.splice`'s check), until the update API is ported. - pub(crate) fn append(&self, nodes: &[NodeRef]) -> Result<(), LexicalError> { - caret_tree::append(self, nodes) - } - /// `updateFromJSON(json)`. pub(crate) fn update_from_json(&self, json: &Object) { with_writable(&self.key, |node| node.update_from_json(json)); } + + /// `getParentOrThrow()`. + pub fn get_parent_or_throw(&self) -> Result { + self.get_parent().ok_or_else(|| { + LexicalError::new(format!("Expected node {} to have a parent.", self.key)) + }) + } + + /// `isAttached()`: whether the node's parents lead to the root. + pub fn is_attached(&self) -> bool { + let editor_state = get_active_editor_state(); + let node_map = editor_state.0.node_map.borrow(); + let mut key = self.key.as_str(); + loop { + if key == "root" { + return true; + } + match node_map.get(key).and_then(|n| n.base().parent.as_deref()) { + Some(parent) => key = parent, + None => return false, + } + } + } + + /// `getIndexWithinParent()`; `None` where JS returns -1. + pub fn get_index_within_parent(&self) -> Option { + let parent = self.get_parent()?; + let mut node = parent.get_first_child(); + let mut index = 0; + while let Some(n) = node { + if &n == self { + return Some(index); + } + index += 1; + node = n.get_next_sibling(); + } + None + } + + /// `getTextContentSize()`: the text content's length in UTF-16 code + /// units. + pub fn get_text_content_size(&self) -> usize { + if self.is_element() { + return self.get_element_text_content_size(); + } + utf16::len(&self.get_text_content()) + } + + /// `ElementNode.canBeEmpty()`. + pub fn can_be_empty(&self) -> bool { + self.get_latest().can_be_empty() + } + + /// `isSelected()` for a node selection. Range selections come with + /// `getNodes`. + fn is_selected_by_node_selection(&self, selection: &SelectionRef) -> bool { + selection.with_node(|n| n.has(&self.key)).unwrap_or(false) + } + + /// `markDirty()`: marks the node dirty, so transforms run on it. + pub fn mark_dirty(&self) { + get_writable(&self.key); + } + + /// `remove(preserveEmptyParent)`. + pub fn remove(&self, preserve_empty_parent: bool) -> Result<(), LexicalError> { + invariant!(!self.is_root(), "remove: cannot be called on root nodes"); + remove_node(self, true, preserve_empty_parent) + } + + /// `replace(replaceWith, includeChildren)`: puts `replace_with` where + /// this node is, with this node's children when `include_children`. + pub fn replace( + &self, + replace_with: &NodeRef, + include_children: bool, + ) -> Result { + invariant!(!self.is_root(), "replace: cannot be called on root nodes"); + error_on_read_only(); + let selection = get_selection().map(|s| s.clone_selection()); + error_on_insert_text_node_on_root(self, replace_with)?; + let to_replace_key = self.key.clone(); + get_writable(replace_with.get_key()); + let parent = self.get_parent_or_throw()?; + get_writable(parent.get_key()); + let size = parent.get_children_size(); + let replace_with_old_parent = replace_with.get_parent(); + let range = selection.as_ref().filter(|s| s.is_range()); + detach_node_with_selection(replace_with, range)?; + let prev_sibling = self.get_previous_sibling(); + let next_sibling = self.get_next_sibling(); + remove_node(self, false, true)?; + if let Some(p) = &prev_sibling { + get_writable(p.get_key()); + } + if let Some(n) = &next_sibling { + get_writable(n.get_key()); + } + insert_node_between( + parent.get_key(), + replace_with.get_key(), + prev_sibling.as_ref().map(NodeRef::get_key), + next_sibling.as_ref().map(NodeRef::get_key), + ); + // When replace_with was already a child of the same parent, two + // children became one. + let size = if replace_with_old_parent.as_ref() == Some(&parent) { + size - 1 + } else { + size + }; + mutate(parent.get_key(), |p| { + p.element_mut().expect("a parent is an element").size = size + }); + let mut prev_size_before_children_transfer = 0; + if include_children { + invariant!( + self.is_element() && replace_with.is_element(), + "includeChildren should only be true for ElementNodes" + ); + prev_size_before_children_transfer = replace_with.get_children_size(); + replace_with.splice(prev_size_before_children_transfer, 0, &self.get_children())?; + } + if let Some(selection) = range { + set_selection(Some(selection.clone())); + for which in [Which::Anchor, Which::Focus] { + let point = selection.point(which).expect("a range selection"); + if point.key == to_replace_key { + if include_children && point.point_type == PointType::Element { + selection.point_set( + which, + replace_with.get_key(), + prev_size_before_children_transfer + point.offset, + PointType::Element, + )?; + } else { + move_selection_point_to_end(selection, which, replace_with)?; + } + } + } + } + Ok(replace_with.clone()) + } + + /// `insertAfter(nodeToInsert, restoreSelection)`. + pub fn insert_after( + &self, + node_to_insert: &NodeRef, + restore_selection: bool, + ) -> Result { + invariant!( + !self.is_root(), + "insertAfter: cannot be called on root nodes" + ); + insert_sibling(self, true, node_to_insert, restore_selection) + } + + /// `insertBefore(nodeToInsert, restoreSelection)`. + pub fn insert_before( + &self, + node_to_insert: &NodeRef, + restore_selection: bool, + ) -> Result { + invariant!( + !self.is_root(), + "insertBefore: cannot be called on root nodes" + ); + insert_sibling(self, false, node_to_insert, restore_selection) + } + + /// `select(anchorOffset, focusOffset)` of a text node or an element; + /// `None` is JS's `undefined` (the end). + pub fn select( + &self, + anchor_offset: Option, + focus_offset: Option, + ) -> Result { + if self.is_text() { + self.select_text(anchor_offset, focus_offset) + } else if self.is_element() { + self.select_element(anchor_offset, focus_offset) + } else { + Err(LexicalError::new(format!( + "select: node {} is not a text node or an element", + self.key + ))) + } + } + + /// `selectStart()`. + pub fn select_start(&self) -> Result { + if self.is_text() { + self.select(Some(0), Some(0)) + } else if self.is_element() { + match self.get_first_descendant() { + Some(first) => first.select_start(), + None => self.select(None, None), + } + } else { + self.select_previous(None, None) + } + } + + /// `selectEnd()`. + pub fn select_end(&self) -> Result { + if self.is_text() { + let size = self.get_text_content_size(); + self.select(Some(size), Some(size)) + } else if self.is_element() { + match self.get_last_descendant() { + Some(last) => last.select_end(), + None => self.select(None, None), + } + } else { + self.select_next(Some(0), Some(0)) + } + } + + /// `selectPrevious(anchorOffset, focusOffset)`. + pub fn select_previous( + &self, + anchor_offset: Option, + focus_offset: Option, + ) -> Result { + select_adjacent_node(self, false, anchor_offset, focus_offset) + } + + /// `selectNext(anchorOffset, focusOffset)`. + pub fn select_next( + &self, + anchor_offset: Option, + focus_offset: Option, + ) -> Result { + select_adjacent_node(self, true, anchor_offset, focus_offset) + } +} + +/// `$removeNode(nodeToRemove, restoreSelection, preserveEmptyParent)`. +pub(crate) fn remove_node( + node_to_remove: &NodeRef, + restore_selection: bool, + preserve_empty_parent: bool, +) -> Result<(), LexicalError> { + error_on_read_only(); + let key = node_to_remove.get_key().to_owned(); + let Some(parent) = node_to_remove.get_parent() else { + return Ok(()); + }; + let selection = maybe_move_children_selection_to_parent(node_to_remove)?; + let mut selection_moved = false; + match &selection { + Some(range) if range.is_range() && restore_selection => { + for which in [Which::Anchor, Which::Focus] { + if range.point(which).expect("a range selection").key == key { + move_selection_point_to_sibling( + range, + which, + node_to_remove, + &parent, + node_to_remove.get_previous_sibling().as_ref(), + node_to_remove.get_next_sibling().as_ref(), + )?; + selection_moved = true; + } + } + } + Some(nodes) + if nodes.is_node() + && restore_selection + && node_to_remove.is_selected_by_node_selection(nodes) => + { + node_to_remove.select_previous(None, None)?; + } + _ => {} + } + + get_writable(&key); + let range = selection + .as_ref() + .filter(|s| restore_selection && !selection_moved && s.is_range()); + detach_node_with_selection(node_to_remove, range)?; + + if !preserve_empty_parent + && !is_root_or_shadow_root(&*parent.get_latest()) + && !parent.can_be_empty() + && parent.is_empty() + { + remove_node(&parent, restore_selection, false)?; + } + if restore_selection && selection.is_some() && parent.is_root() && parent.is_empty() { + parent.select_end()?; + } + Ok(()) } diff --git a/rust/lexical-editor/src/nodes/element.rs b/rust/lexical-editor/src/nodes/element.rs index 14b7251385b..129693d4d3d 100644 --- a/rust/lexical-editor/src/nodes/element.rs +++ b/rust/lexical-editor/src/nodes/element.rs @@ -1,9 +1,19 @@ //! `nodes/LexicalElementNode.ts`: the fields and base behavior of nodes that //! have children. -use crate::constants::{DOUBLE_LINE_BREAK, ELEMENT_TYPE_TO_FORMAT, name_of}; +use crate::caret_tree::{detach_node, detach_sibling_range, insert_node_between}; +use crate::constants::{DOUBLE_LINE_BREAK, ELEMENT_TYPE_TO_FORMAT, lookup, name_of}; +use crate::error::invariant; use crate::node::{LexicalNode, NodeKey, NodeRef}; use crate::nodes::root::RootNode; +use crate::selection::{ + Point, PointType, SelectionRef, Which, get_selection, internal_make_range_selection, + move_selection_point_to_sibling, +}; +use crate::updates::error_on_read_only; +use crate::utils::{get_writable, mutate, with_writable}; +use crate::{LexicalError, utf16}; +use std::collections::HashSet; /// `ElementNode`'s text direction (`'ltr' | 'rtl'`; `null` is `None`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -120,3 +130,302 @@ fn has_text_inside(element: &NodeRef) -> bool { .iter() .any(|child| child.is_text() || (child.is_element() && has_text_inside(child))) } + +/// `isPointRemoved(point, nodesToRemoveKeySet, nodesToInsertKeySet)`. +fn is_point_removed( + point: &Point, + removed: &HashSet<&str>, + inserted: &HashSet<&str>, +) -> Result { + let mut node = Some(point.get_node()?); + while let Some(n) = node { + if removed.contains(n.get_key()) && !inserted.contains(n.get_key()) { + return Ok(true); + } + node = n.get_parent(); + } + Ok(false) +} + +/// `ElementNode`'s methods, for a node that is an element. They panic on +/// any other node, as calling them on one is a bug. +impl NodeRef { + fn with_element(&self, f: impl FnOnce(&ElementData) -> R) -> R { + let node = self.get_latest(); + f(node.element().expect("an element")) + } + + fn set_element(&self, f: impl FnOnce(&mut ElementData)) { + with_writable(self.get_key(), |n| f(n.element_mut().expect("an element"))); + } + + /// The element's fields (`getFormat()`, `getIndent()`, ...). + pub fn element_data(&self) -> ElementData { + self.with_element(Clone::clone) + } + + /// `isEmpty()`. + pub fn is_empty(&self) -> bool { + self.get_children_size() == 0 + } + + /// `getChildAtIndex(index)`. + pub fn get_child_at_index(&self, index: usize) -> Option { + let mut node = self.get_first_child(); + for _ in 0..index { + node = node?.get_next_sibling(); + } + node + } + + /// `getFirstDescendant()`. + pub fn get_first_descendant(&self) -> Option { + let mut node = self.get_first_child()?; + while node.is_element() { + match node.get_first_child() { + Some(child) => node = child, + None => break, + } + } + Some(node) + } + + /// `getLastDescendant()`. + pub fn get_last_descendant(&self) -> Option { + let mut node = self.get_last_child()?; + while node.is_element() { + match node.get_last_child() { + Some(child) => node = child, + None => break, + } + } + Some(node) + } + + /// `ElementNode.getTextContentSize()`. + pub(crate) fn get_element_text_content_size(&self) -> usize { + let children = self.get_children(); + let length = children.len(); + let mut size = 0; + for (i, child) in children.iter().enumerate() { + size += child.get_text_content_size(); + if child.is_element() && i != length - 1 && !child.is_inline() { + size += utf16::len(DOUBLE_LINE_BREAK); + } + } + size + } + + /// `ElementNode.select(anchorOffset, focusOffset)`: an element range at + /// those child offsets, the end by default. + pub fn select_element( + &self, + anchor_offset: Option, + focus_offset: Option, + ) -> Result { + error_on_read_only(); + let selection = get_selection(); + let children_count = self.get_children_size(); + if !self.can_be_empty() { + if anchor_offset == Some(0) && focus_offset == Some(0) { + if let Some(first) = self + .get_first_child() + .filter(|c| c.is_text() || c.is_element()) + { + return first.select(Some(0), Some(0)); + } + } else if anchor_offset.is_none_or(|o| o == children_count) + && focus_offset.is_none_or(|o| o == children_count) + && let Some(last) = self + .get_last_child() + .filter(|c| c.is_text() || c.is_element()) + { + return last.select(None, None); + } + } + let anchor_offset = anchor_offset.unwrap_or(children_count); + let focus_offset = focus_offset.unwrap_or(children_count); + let key = self.get_key(); + match selection.filter(SelectionRef::is_range) { + None => Ok(internal_make_range_selection( + Point::new(key, anchor_offset, PointType::Element), + Point::new(key, focus_offset, PointType::Element), + )), + Some(selection) => { + selection.point_set(Which::Anchor, key, anchor_offset, PointType::Element)?; + selection.point_set(Which::Focus, key, focus_offset, PointType::Element)?; + selection.set_dirty(true); + Ok(selection) + } + } + } + + /// `clear()`: removes every child. + pub fn clear(&self) -> Result<(), LexicalError> { + get_writable(self.get_key()); + for child in self.get_children() { + child.remove(false)?; + } + Ok(()) + } + + /// `append(...nodesToAppend)`. + pub fn append(&self, nodes: &[NodeRef]) -> Result<(), LexicalError> { + self.splice(self.get_children_size(), 0, nodes) + } + + /// `setDirection(direction)`. + pub fn set_direction(&self, direction: Option) { + self.set_element(|e| e.dir = direction); + } + + /// `setFormat(type)`: an alignment by name (`'center'`, ...); `''` and + /// unknown names clear it. + pub fn set_element_format(&self, format_type: &str) { + let format = lookup(ELEMENT_TYPE_TO_FORMAT, format_type).unwrap_or(0); + self.set_element(|e| e.format = format); + } + + /// `setStyle(style)`. + pub fn set_element_style(&self, style: &str) { + self.set_element(|e| e.style = style.to_owned()); + } + + /// `setTextFormat(type)`. + pub fn set_text_format(&self, text_format: f64) { + self.set_element(|e| e.text_format = text_format); + } + + /// `setTextStyle(style)`. + pub fn set_text_style(&self, style: &str) { + self.set_element(|e| e.text_style = style.to_owned()); + } + + /// `setIndent(indentLevel)`. + pub fn set_indent(&self, indent: f64) { + self.set_element(|e| e.indent = indent); + } + + /// `splice(start, deleteCount, nodesToInsert)`: removes `delete_count` + /// children from `start` and inserts `nodes` there (moving any that are + /// in the tree already), repairing the selection when children are + /// removed. The root takes only elements (`RootNode.splice`). + pub fn splice( + &self, + start: usize, + delete_count: usize, + nodes_to_insert: &[NodeRef], + ) -> Result<(), LexicalError> { + if self.is_root() { + for node in nodes_to_insert { + // DecoratorNode is allowed too, once it is ported. + invariant!( + node.is_element(), + "rootNode.splice: Only element or decorator nodes can be inserted to the root node" + ); + } + } + let old_size = self.get_children_size(); + get_writable(self.get_key()); + invariant!( + start + delete_count <= old_size, + "ElementNode.splice: start + deleteCount > oldSize ({start} + {delete_count} > {old_size})" + ); + let self_key = self.get_key(); + let mut nodes_to_insert_keys: Vec = Vec::new(); + let mut nodes_to_remove_keys = Vec::new(); + let mut node_after_range = self.get_child_at_index(start + delete_count); + let mut node_before_range = None; + let mut new_size = (old_size - delete_count + nodes_to_insert.len()) as isize; + + if start != 0 { + if start == old_size { + node_before_range = self.get_last_child(); + } else if let Some(node) = self.get_child_at_index(start) { + node_before_range = node.get_previous_sibling(); + } + } + + if delete_count > 0 { + let first = match &node_before_range { + None => self.get_first_child(), + Some(before) => before.get_next_sibling(), + }; + nodes_to_remove_keys = + detach_sibling_range(self, first, delete_count, node_before_range.as_ref())?; + } + + // The writable insertion tail. + let mut writable_prev_node = match &node_before_range { + Some(before) if !nodes_to_insert.is_empty() => { + get_writable(before.get_key()); + Some(before.clone()) + } + _ => None, + }; + for node_to_insert in nodes_to_insert { + if writable_prev_node.as_ref() == Some(node_to_insert) { + node_before_range = node_to_insert.get_previous_sibling(); + writable_prev_node = node_before_range.clone(); + if let Some(prev) = &writable_prev_node { + get_writable(prev.get_key()); + } + } + if node_after_range.as_ref() == Some(node_to_insert) { + node_after_range = node_to_insert.get_next_sibling(); + } + get_writable(node_to_insert.get_key()); + if node_to_insert.get_parent().as_ref() == Some(self) { + new_size -= 1; + } + detach_node(node_to_insert); + if let Some(after) = &node_after_range { + get_writable(after.get_key()); + } + insert_node_between( + self_key, + node_to_insert.get_key(), + writable_prev_node.as_ref().map(NodeRef::get_key), + node_after_range.as_ref().map(NodeRef::get_key), + ); + invariant!( + node_to_insert.get_key() != self_key, + "append: attempting to append self" + ); + nodes_to_insert_keys.push(node_to_insert.get_key().to_owned()); + writable_prev_node = Some(node_to_insert.clone()); + } + + let new_size = usize::try_from(new_size).expect("size >= 0"); + mutate(self_key, |n| { + n.element_mut().expect("an element").size = new_size + }); + + // Removing children moves the selection out of them, and removes an + // element that can't be empty. + if !nodes_to_remove_keys.is_empty() + && let Some(selection) = get_selection().filter(SelectionRef::is_range) + { + let removed: HashSet<&str> = nodes_to_remove_keys.iter().map(String::as_str).collect(); + let inserted: HashSet<&str> = nodes_to_insert_keys.iter().map(String::as_str).collect(); + for which in [Which::Anchor, Which::Focus] { + let point = selection.point(which).expect("a range selection"); + if is_point_removed(&point, &removed, &inserted)? { + move_selection_point_to_sibling( + &selection, + which, + &point.get_node()?, + self, + node_before_range.as_ref(), + node_after_range.as_ref(), + )?; + } + } + if new_size == 0 && !self.can_be_empty() && !is_root_or_shadow_root(&*self.get_latest()) + { + self.remove(false)?; + } + } + Ok(()) + } +} diff --git a/rust/lexical-editor/src/nodes/paragraph.rs b/rust/lexical-editor/src/nodes/paragraph.rs index 65e348f816d..a3d37dcca4d 100644 --- a/rust/lexical-editor/src/nodes/paragraph.rs +++ b/rust/lexical-editor/src/nodes/paragraph.rs @@ -7,6 +7,11 @@ use crate::json::{Object, Value}; use crate::node::{LexicalNode, NodeBase, NodeRef}; use crate::nodes::element::ElementData; +/// `$createParagraphNode()`. +pub fn create_paragraph_node() -> NodeRef { + crate::utils::set_node_key(Box::new(ParagraphNode::default())) +} + #[derive(Clone, Debug, Default)] pub struct ParagraphNode { base: NodeBase, diff --git a/rust/lexical-editor/src/nodes/text.rs b/rust/lexical-editor/src/nodes/text.rs index 492e4df1d3e..25fe728b7c5 100644 --- a/rust/lexical-editor/src/nodes/text.rs +++ b/rust/lexical-editor/src/nodes/text.rs @@ -1,9 +1,23 @@ //! `nodes/LexicalTextNode.ts`. -use crate::constants::{TEXT_MODE_TO_TYPE, name_of}; +use crate::constants::{ + DETAIL_TYPE_TO_DETAIL, IS_DIRECTIONLESS, IS_NORMAL, IS_SEGMENTED, IS_TOKEN, IS_UNMERGEABLE, + TEXT_MODE_TO_TYPE, TEXT_TYPE_TO_FORMAT, lookup, name_of, +}; +use crate::error::invariant; use crate::generated_json::{export_compact_text_node, export_text_node, update_text_node}; use crate::json::Object; -use crate::node::{LexicalNode, NodeBase}; +use crate::node::{LexicalNode, NodeBase, NodeRef}; +use crate::nodes::tab::TabNode; +use crate::selection::{ + Point, PointType, SelectionRef, Which, adjust_point_offset_for_merged_sibling, get_selection, + internal_make_range_selection, update_element_selection_on_create_delete_node, +}; +use crate::updates::error_on_read_only; +use crate::utils::{ + get_writable, internal_mark_siblings_as_dirty, mutate, set_node_key, with_writable, +}; +use crate::{LexicalError, utf16}; /// The fields `TextNode` adds. `format` and `detail` are kept as the JS /// number they parsed to, so a document writes back exactly what JS writes; @@ -88,3 +102,453 @@ impl LexicalNode for TextNode { update_text_node(&mut self.text, json); } } + +impl TextNode { + /// A text node holding `text` (`new TextNode(text)`), not yet in an + /// editor state. + pub fn new(text: impl Into) -> Self { + Self { + base: NodeBase::default(), + text: TextData { + text: text.into(), + ..TextData::default() + }, + } + } +} + +/// `$createTextNode(text)`. +pub fn create_text_node(text: &str) -> NodeRef { + set_node_key(Box::new(TextNode::new(text))) +} + +/// `ToInt32(x)`, how JS's bitwise operators read a number. +pub(crate) fn to_int32(x: f64) -> i32 { + if !x.is_finite() { + return 0; + } + x.trunc().rem_euclid(4_294_967_296.0) as u32 as i32 +} + +/// `toggleTextFormatType(format, type, alignWithFormat)`. +pub fn toggle_text_format_type( + format: i32, + format_type: &str, + align_with_format: Option, +) -> i32 { + let flag = |name| lookup(TEXT_TYPE_TO_FORMAT, name).unwrap_or(0); + let active_format = flag(format_type); + if let Some(align) = align_with_format + && (format & active_format) == (align & active_format) + { + return format; + } + let mut new_format = format ^ active_format; + match format_type { + "subscript" => new_format &= !flag("superscript"), + "superscript" => new_format &= !flag("subscript"), + "lowercase" => new_format &= !(flag("uppercase") | flag("capitalize")), + "uppercase" => new_format &= !(flag("lowercase") | flag("capitalize")), + "capitalize" => new_format &= !(flag("lowercase") | flag("uppercase")), + _ => {} + } + new_format +} + +/// `text.slice(start)` with JS's negative `start` (from the end). +fn slice_from_signed(text: &str, start: i64) -> String { + let start = if start < 0 { + (utf16::len(text) as i64 + start).max(0) + } else { + start + }; + utf16::slice_from(text, start as usize) +} + +/// `TextNode`'s methods, for a node that is a text node (a `TextNode` or a +/// subclass such as `TabNode`). They panic on any other node, as calling +/// them on one is a bug. +impl NodeRef { + fn with_text(&self, f: impl FnOnce(&TextData) -> R) -> R { + let node = self.get_latest(); + f(node.text().expect("a text node")) + } + + fn set_text_data(&self, f: impl FnOnce(&mut TextData)) { + with_writable(self.get_key(), |n| f(n.text_mut().expect("a text node"))); + } + + fn is_tab(&self) -> bool { + self.get_latest().is::() + } + + /// The text node's fields (`getFormat()`, `getStyle()`, ...). + pub fn text_data(&self) -> TextData { + self.with_text(Clone::clone) + } + + /// `isSimpleText()`: a plain `text` node in normal mode. + pub fn is_simple_text(&self) -> bool { + self.get_type() == "text" && self.with_text(|t| t.mode == IS_NORMAL) + } + + /// `isUnmergeable()`. + pub fn is_unmergeable(&self) -> bool { + self.with_text(|t| to_int32(t.detail) & IS_UNMERGEABLE != 0) + } + + /// `isToken()`. + pub fn is_token(&self) -> bool { + self.with_text(|t| t.mode == IS_TOKEN) + } + + /// `isSegmented()`. + pub fn is_segmented(&self) -> bool { + self.with_text(|t| t.mode == IS_SEGMENTED) + } + + /// `hasFormat(type)`. + pub fn has_format(&self, format_type: &str) -> bool { + let flag = lookup(TEXT_TYPE_TO_FORMAT, format_type).unwrap_or(0); + self.with_text(|t| to_int32(t.format) & flag != 0) + } + + /// `setFormat(format)` with a number. + pub fn set_format(&self, format: f64) { + self.set_text_data(|t| t.format = format); + } + + /// `setFormat(type)` with a name, such as `'bold'`. + pub fn set_format_type(&self, format_type: &str) { + let format = lookup(TEXT_TYPE_TO_FORMAT, format_type).unwrap_or(0); + self.set_format(f64::from(format)); + } + + /// `setDetail(detail)` with a number. A tab only takes + /// `IS_UNMERGEABLE`, which it has. + pub fn set_detail(&self, detail: f64) -> Result<(), LexicalError> { + if self.is_tab() { + invariant!( + detail == f64::from(IS_UNMERGEABLE), + "TabNode does not support setDetail" + ); + return Ok(()); + } + self.set_text_data(|t| t.detail = detail); + Ok(()) + } + + /// `setDetail(type)` with a name: `'directionless'` or `'unmergeable'`. + pub fn set_detail_type(&self, detail_type: &str) -> Result<(), LexicalError> { + invariant!(!self.is_tab(), "TabNode does not support setDetail"); + let detail = lookup(DETAIL_TYPE_TO_DETAIL, detail_type).unwrap_or(0); + self.set_detail(f64::from(detail)) + } + + /// `setStyle(style)`. + pub fn set_style(&self, style: &str) { + self.set_text_data(|t| t.style = style.to_owned()); + } + + /// `toggleFormat(type)`. + pub fn toggle_format(&self, format_type: &str) { + let format = to_int32(self.with_text(|t| t.format)); + let new_format = toggle_text_format_type(format, format_type, None); + self.set_format(f64::from(new_format)); + } + + /// `toggleDirectionless()`. + pub fn toggle_directionless(&self) { + self.set_text_data(|t| t.detail = f64::from(to_int32(t.detail) ^ IS_DIRECTIONLESS)); + } + + /// `toggleUnmergeable()`. + pub fn toggle_unmergeable(&self) { + self.set_text_data(|t| t.detail = f64::from(to_int32(t.detail) ^ IS_UNMERGEABLE)); + } + + /// `setMode(type)`: `'normal'`, `'token'` or `'segmented'`. A tab is + /// always normal. + pub fn set_mode(&self, mode_type: &str) -> Result<(), LexicalError> { + if self.is_tab() { + invariant!(mode_type == "normal", "TabNode does not support setMode"); + return Ok(()); + } + let Some(mode) = lookup(TEXT_MODE_TO_TYPE, mode_type) else { + return Err(LexicalError::new(format!( + "setMode: unknown mode {mode_type}" + ))); + }; + if self.with_text(|t| t.mode) != mode { + self.set_text_data(|t| t.mode = mode); + } + Ok(()) + } + + /// `setTextContent(text)`. A tab keeps its `\t`. + pub fn set_text_content(&self, text: &str) { + let text = if self.is_tab() { "\t" } else { text }; + if self.with_text(|t| t.text != text) { + self.set_text_data(|t| t.text = text.to_owned()); + } + } + + /// `TextNode.select(anchorOffset, focusOffset)`: a text range at those + /// UTF-16 offsets, the end by default. + pub fn select_text( + &self, + anchor_offset: Option, + focus_offset: Option, + ) -> Result { + error_on_read_only(); + let selection = get_selection(); + let last_offset = utf16::len(&self.get_text_content()); + let anchor_offset = anchor_offset.unwrap_or(last_offset); + let focus_offset = focus_offset.unwrap_or(last_offset); + let key = self.get_key(); + match selection.filter(SelectionRef::is_range) { + None => Ok(internal_make_range_selection( + Point::new(key, anchor_offset, PointType::Text), + Point::new(key, focus_offset, PointType::Text), + )), + Some(selection) => { + selection.set_text_node_range(self, anchor_offset, self, focus_offset)?; + Ok(selection) + } + } + } + + /// `spliceText(offset, delCount, newText, moveSelection)`: replaces + /// `del_count` UTF-16 units at `offset` with `new_text`, and moves a + /// range selection after it when `move_selection`. + pub fn splice_text( + &self, + offset: i64, + del_count: i64, + new_text: &str, + move_selection: bool, + ) -> Result { + if self.is_tab() { + invariant!( + (new_text.is_empty() && del_count == 0) || (new_text == "\t" && del_count == 1), + "TabNode does not support spliceText" + ); + return Ok(self.clone()); + } + get_writable(self.get_key()); + let text = self.get_text_content(); + let handled_text_length = utf16::len(new_text) as i64; + let mut index = offset; + if index < 0 { + index = (handled_text_length + index).max(0); + } + if move_selection && let Some(selection) = get_selection().filter(SelectionRef::is_range) { + let new_offset = (offset + handled_text_length).max(0) as usize; + selection.set_text_node_range(self, new_offset, self, new_offset)?; + } + let updated_text = utf16::slice(&text, 0, index as usize) + + new_text + + &slice_from_signed(&text, index + del_count); + mutate(self.get_key(), |n| { + n.text_mut().expect("a text node").text = updated_text + }); + Ok(self.clone()) + } + + /// `splitText(...splitOffsets)`: splits the text at those UTF-16 + /// offsets into this node and new siblings with the same format, style + /// and detail, and returns the parts. A segmented node is replaced. + pub fn split_text(&self, split_offsets: &[i64]) -> Result, LexicalError> { + error_on_read_only(); + let text_content = self.get_text_content(); + if text_content.is_empty() { + return Ok(Vec::new()); + } + let key = self.get_key().to_owned(); + let text_length = utf16::len(&text_content) as i64; + let mut offsets = split_offsets.to_vec(); + offsets.sort_unstable(); + offsets.push(text_length); + let mut parts = Vec::new(); + let mut start = 0; + for &end in &offsets { + if start >= text_length { + break; + } + if end > start { + parts.push(utf16::slice(&text_content, start as usize, end as usize)); + start = end; + } + } + if parts.len() == 1 { + return Ok(vec![self.clone()]); + } + let first_part = &parts[0]; + let parent = self.get_parent(); + let TextData { + format, + style, + detail, + .. + } = self.text_data(); + + // Prepare to handle selection + let selection = get_selection().filter(SelectionRef::is_range); + let mut start_text_point = None; + let mut end_text_point = None; + if let Some(selection) = &selection { + let (start_point, end_point) = if selection.is_backward()? { + (Which::Focus, Which::Anchor) + } else { + (Which::Anchor, Which::Focus) + }; + let is_here = |which| { + let point = selection.point(which).expect("a range selection"); + point.point_type == PointType::Text && point.key == key + }; + if is_here(start_point) { + start_text_point = Some(start_point); + } + if is_here(end_point) { + end_text_point = Some(end_point); + } + } + + let copy_fields = |node: &NodeRef| { + mutate(node.get_key(), |n| { + let t = n.text_mut().expect("a text node"); + t.format = format; + t.style = style.clone(); + t.detail = detail; + }); + }; + let has_replaced_self = self.is_segmented(); + let writable_node = if has_replaced_self { + let node = create_text_node(first_part); + copy_fields(&node); + node + } else { + self.set_text_content(first_part); + self.clone() + }; + + let mut split_nodes = vec![writable_node]; + for part in &parts[1..] { + let sibling = create_text_node(part); + copy_fields(&sibling); + split_nodes.push(sibling); + } + + // Move the selection to the best location in the split string. The + // end point is always left-biased, and the start point is generally + // left-biased unless the end point would land on a later node. + let offset_of = |which: Option| { + which.map(|w| { + selection + .as_ref() + .and_then(|s| s.point(w)) + .expect("a range selection") + .offset + }) + }; + let original_start_offset = offset_of(start_text_point); + let original_end_offset = offset_of(end_text_point); + let mut start_offset = 0; + for node in &split_nodes { + if start_text_point.is_none() && end_text_point.is_none() { + break; + } + let selection = selection.as_ref().expect("a point is selected"); + let end_offset = start_offset + node.get_text_content_size(); + if let (Some(which), Some(original)) = (start_text_point, original_start_offset) + && original <= end_offset + && original >= start_offset + { + selection.point_set( + which, + node.get_key(), + original - start_offset, + PointType::Text, + )?; + if original < end_offset { + // The start isn't on a border, so we can stop checking. + start_text_point = None; + } + } + if let (Some(which), Some(original)) = (end_text_point, original_end_offset) + && original <= end_offset + && original >= start_offset + { + selection.point_set( + which, + node.get_key(), + original - start_offset, + PointType::Text, + )?; + break; + } + start_offset = end_offset; + } + + // Insert the nodes into the parent's children. + if let Some(parent) = parent { + internal_mark_siblings_as_dirty(self); + get_writable(parent.get_key()); + let insertion_index = self.get_index_within_parent().expect("a child"); + if has_replaced_self { + parent.splice(insertion_index, 0, &split_nodes)?; + self.remove(false)?; + } else { + parent.splice(insertion_index, 1, &split_nodes)?; + } + if let Some(selection) = &selection { + update_element_selection_on_create_delete_node( + selection, + &parent, + insertion_index, + parts.len() as isize - 1, + )?; + } + } + Ok(split_nodes) + } + + /// `mergeWithSibling(target)`: appends (or prepends) the text of an + /// adjacent text node and removes it, keeping the selection on the same + /// text. + pub fn merge_with_sibling(&self, target: &NodeRef) -> Result { + let is_before = self.get_previous_sibling().as_ref() == Some(target); + invariant!( + is_before || self.get_next_sibling().as_ref() == Some(target), + "mergeWithSibling: sibling must be a previous or next sibling" + ); + let key = self.get_key(); + let target_key = target.get_key(); + let text = self.get_text_content(); + let text_length = utf16::len(&text); + if let Some(selection) = get_selection().filter(SelectionRef::is_range) { + for which in [Which::Anchor, Which::Focus] { + if selection.point(which).expect("a range selection").key == target_key { + adjust_point_offset_for_merged_sibling( + &selection, + which, + is_before, + key, + target, + text_length, + )?; + } + } + } + let target_text = target.get_text_content(); + let new_text = if is_before { + target_text + &text + } else { + text + &target_text + }; + self.set_text_content(&new_text); + get_writable(key); + target.remove(false)?; + Ok(self.clone()) + } +} diff --git a/rust/lexical-editor/src/normalization.rs b/rust/lexical-editor/src/normalization.rs new file mode 100644 index 00000000000..52cb7b38733 --- /dev/null +++ b/rust/lexical-editor/src/normalization.rs @@ -0,0 +1,68 @@ +//! `LexicalNormalization.ts`: merging adjacent simple text nodes. A +//! headless editor never composes, so the composition holds don't apply. + +use crate::LexicalError; +use crate::node::NodeRef; +use crate::updates::get_active_editor; + +/// `$canSimpleTextNodesBeMerged(node1, node2)`. NodeState comes with its +/// port. +fn can_simple_text_nodes_be_merged(node1: &NodeRef, node2: &NodeRef) -> bool { + let (a, b) = (node1.get_latest(), node2.get_latest()); + let (a, b) = ( + a.text().expect("a text node"), + b.text().expect("a text node"), + ); + a.mode == b.mode && a.format == b.format && a.style == b.style +} + +/// `$mergeTextNodes(node1, node2)`. +fn merge_text_nodes(node1: &NodeRef, node2: &NodeRef) -> Result { + let writable_node1 = node1.merge_with_sibling(node2)?; + if let Some(editor) = get_active_editor() { + let mut dirty = editor.0.dirty.borrow_mut(); + dirty.normalized.add(node1.get_key()); + dirty.normalized.add(node2.get_key()); + } + Ok(writable_node1) +} + +fn is_mergeable_simple_text(node: &NodeRef) -> bool { + node.is_text() && node.is_simple_text() && !node.is_unmergeable() +} + +/// `$normalizeTextNode(textNode)`: removes the node if it is empty, else +/// merges it with an adjacent compatible text node on each side, removing +/// empty ones on the way. +pub(crate) fn normalize_text_node(text_node: &NodeRef) -> Result<(), LexicalError> { + let mut node = text_node.clone(); + if node.get_text_content().is_empty() && node.is_simple_text() && !node.is_unmergeable() { + node.remove(false)?; + return Ok(()); + } + + // Backward + while let Some(previous) = node.get_previous_sibling().filter(is_mergeable_simple_text) { + if previous.get_text_content().is_empty() { + previous.remove(false)?; + } else { + if can_simple_text_nodes_be_merged(&previous, &node) { + node = merge_text_nodes(&previous, &node)?; + } + break; + } + } + + // Forward + while let Some(next) = node.get_next_sibling().filter(is_mergeable_simple_text) { + if next.get_text_content().is_empty() { + next.remove(false)?; + } else { + if can_simple_text_nodes_be_merged(&node, &next) { + merge_text_nodes(&node, &next)?; + } + break; + } + } + Ok(()) +} diff --git a/rust/lexical-editor/src/ordered.rs b/rust/lexical-editor/src/ordered.rs new file mode 100644 index 00000000000..e7130911a8b --- /dev/null +++ b/rust/lexical-editor/src/ordered.rs @@ -0,0 +1,150 @@ +//! Insertion-ordered collections with JS `Map` and `Set` semantics: setting +//! an existing key keeps its position, a deleted key that is added again goes +//! last, and iterating by index (`get(i)` for `i = 0, 1, ...`) visits entries +//! added during the iteration, as `for...of` over a JS `Set` does. The update +//! cycle depends on that: normalizing a dirty text node can dirty others, +//! which the same pass then visits. + +use std::collections::HashMap; + +#[derive(Clone, Debug)] +pub(crate) struct OrderedMap { + entries: Vec>, + index: HashMap, +} + +impl Default for OrderedMap { + fn default() -> Self { + Self { + entries: Vec::new(), + index: HashMap::new(), + } + } +} + +impl OrderedMap { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn len(&self) -> usize { + self.index.len() + } + + pub(crate) fn is_empty(&self) -> bool { + self.index.is_empty() + } + + pub(crate) fn contains(&self, key: &str) -> bool { + self.index.contains_key(key) + } + + /// `map.set(key, value)`. + pub(crate) fn set(&mut self, key: &str, value: V) { + if let Some(&i) = self.index.get(key) { + self.entries[i].as_mut().expect("indexed").1 = value; + return; + } + self.index.insert(key.to_owned(), self.entries.len()); + self.entries.push(Some((key.to_owned(), value))); + } + + /// `map.delete(key)`. + pub(crate) fn delete(&mut self, key: &str) -> Option { + let i = self.index.remove(key)?; + self.entries[i].take().map(|(_, v)| v) + } + + /// The entry at position `i` of the iteration order, skipping deleted + /// entries; `None` past the end. Positions stay valid while entries are + /// added. + pub(crate) fn entry_at(&self, i: &mut usize) -> Option<(&str, &V)> { + while *i < self.entries.len() { + if let Some((k, v)) = &self.entries[*i] { + return Some((k, v)); + } + *i += 1; + } + None + } + + pub(crate) fn iter(&self) -> impl Iterator { + self.entries + .iter() + .filter_map(|e| e.as_ref().map(|(k, v)| (k.as_str(), v))) + } + + pub(crate) fn keys(&self) -> impl Iterator { + self.iter().map(|(k, _)| k) + } +} + +/// A JS `Set` of strings. +#[derive(Clone, Debug, Default)] +pub(crate) struct OrderedSet(OrderedMap<()>); + +impl OrderedSet { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn len(&self) -> usize { + self.0.len() + } + + pub(crate) fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub(crate) fn contains(&self, key: &str) -> bool { + self.0.contains(key) + } + + pub(crate) fn add(&mut self, key: &str) { + self.0.set(key, ()); + } + + pub(crate) fn delete(&mut self, key: &str) -> bool { + self.0.delete(key).is_some() + } + + /// See [`OrderedMap::entry_at`]. + pub(crate) fn key_at(&self, i: &mut usize) -> Option<&str> { + self.0.entry_at(i).map(|(k, _)| k) + } + + pub(crate) fn iter(&self) -> impl Iterator { + self.0.keys() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn iterates_like_a_js_set() { + // const s = new Set(['a', 'b', 'c']); s.delete('b'); s.add('a'); + // const seen = []; for (const k of s) { seen.push(k); if (k === 'a') s.add('d'); } + let mut s = OrderedSet::new(); + for k in ["a", "b", "c"] { + s.add(k); + } + s.delete("b"); + s.add("a"); + let mut seen = Vec::new(); + let mut i = 0; + while let Some(k) = s.key_at(&mut i).map(str::to_owned) { + if k == "a" { + s.add("d"); + } + seen.push(k); + i += 1; + } + assert_eq!(seen, ["a", "c", "d"]); + // A deleted key added again goes last. + s.delete("a"); + s.add("a"); + assert_eq!(s.iter().collect::>(), ["c", "d", "a"]); + } +} diff --git a/rust/lexical-editor/src/selection.rs b/rust/lexical-editor/src/selection.rs new file mode 100644 index 00000000000..341689df306 --- /dev/null +++ b/rust/lexical-editor/src/selection.rs @@ -0,0 +1,571 @@ +//! `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). + +use crate::LexicalError; +use crate::error::invariant; +use crate::node::{NodeKey, NodeRef}; +use crate::ordered::OrderedSet; +use crate::updates::{get_active_editor_state, is_currently_read_only_mode}; +use crate::utils::get_node_by_key; +use std::cell::RefCell; +use std::rc::Rc; + +/// A point's type: inside a text node (an offset in UTF-16 code units) or +/// between an element's children (a child index). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PointType { + Text, + Element, +} + +impl PointType { + pub fn as_str(self) -> &'static str { + match self { + PointType::Text => "text", + PointType::Element => "element", + } + } +} + +/// `PointType`: a node key, an offset and a type. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Point { + pub key: NodeKey, + pub offset: usize, + pub point_type: PointType, +} + +impl Point { + pub fn new(key: impl Into, offset: usize, point_type: PointType) -> Self { + Self { + key: key.into(), + offset, + point_type, + } + } + + /// `point.getNode()`. + pub fn get_node(&self) -> Result { + get_node_by_key(&self.key).ok_or_else(|| LexicalError::new("Point.getNode: node not found")) + } +} + +/// Which point of a range selection. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Which { + Anchor, + Focus, +} + +/// `RangeSelection`'s fields. +#[derive(Clone, Debug)] +pub struct RangeSelection { + pub anchor: Point, + pub focus: Point, + pub format: f64, + pub style: String, + pub(crate) dirty: bool, + pub(crate) cached_is_backward: Option, +} + +impl RangeSelection { + /// `new RangeSelection(anchor, focus, format, style)`. + pub fn new(anchor: Point, focus: Point, format: f64, style: impl Into) -> Self { + Self { + anchor, + focus, + format, + style: style.into(), + dirty: false, + cached_is_backward: None, + } + } + + pub fn point(&self, which: Which) -> &Point { + match which { + Which::Anchor => &self.anchor, + Which::Focus => &self.focus, + } + } + + fn point_mut(&mut self, which: Which) -> &mut Point { + match which { + Which::Anchor => &mut self.anchor, + Which::Focus => &mut self.focus, + } + } + + /// `is(selection)`: equal points, format and style. + pub fn is(&self, other: &RangeSelection) -> bool { + self.anchor == other.anchor + && self.focus == other.focus + && self.format == other.format + && self.style == other.style + } + + pub fn is_collapsed(&self) -> bool { + self.anchor == self.focus + } +} + +/// `NodeSelection`'s fields: the selected keys, in the order added. +#[derive(Clone, Debug, Default)] +pub struct NodeSelection { + pub(crate) nodes: OrderedSet, + pub(crate) dirty: bool, +} + +impl NodeSelection { + pub fn keys(&self) -> impl Iterator { + self.nodes.iter() + } + + pub fn add(&mut self, key: &str) { + self.dirty = true; + self.nodes.add(key); + } + + pub fn delete(&mut self, key: &str) { + self.dirty = true; + self.nodes.delete(key); + } + + pub fn has(&self, key: &str) -> bool { + self.nodes.contains(key) + } +} + +/// A selection's value. +#[derive(Clone, Debug)] +pub enum Selection { + Range(RangeSelection), + Node(NodeSelection), +} + +impl Selection { + fn dirty_mut(&mut self) -> &mut bool { + match self { + Selection::Range(s) => &mut s.dirty, + Selection::Node(s) => &mut s.dirty, + } + } + + /// `selection.is(other)`. + pub fn is(&self, other: &Selection) -> bool { + match (self, other) { + (Selection::Range(a), Selection::Range(b)) => a.is(b), + (Selection::Node(a), Selection::Node(b)) => { + a.nodes.len() == b.nodes.len() && a.nodes.iter().all(|k| b.nodes.contains(k)) + } + _ => false, + } + } +} + +/// A selection object, shared as JS shares it: `$getSelection()` returns the +/// one the editor state holds, so a change through it is a change of the +/// state's selection; [`clone_selection`](Self::clone_selection) makes a +/// separate one. +#[derive(Clone, Debug)] +pub struct SelectionRef(Rc>); + +impl SelectionRef { + pub fn new(selection: Selection) -> Self { + Self(Rc::new(RefCell::new(selection))) + } + + /// The selection's current value. + pub fn get(&self) -> Selection { + self.0.borrow().clone() + } + + pub fn is_range(&self) -> bool { + matches!(*self.0.borrow(), Selection::Range(_)) + } + + pub fn is_node(&self) -> bool { + matches!(*self.0.borrow(), Selection::Node(_)) + } + + /// The range selection's value, `None` for a node selection. + pub fn range(&self) -> Option { + match &*self.0.borrow() { + Selection::Range(r) => Some(r.clone()), + Selection::Node(_) => None, + } + } + + pub(crate) fn with_range(&self, f: impl FnOnce(&mut RangeSelection) -> R) -> Option { + match &mut *self.0.borrow_mut() { + Selection::Range(r) => Some(f(r)), + Selection::Node(_) => None, + } + } + + pub(crate) fn with_node(&self, f: impl FnOnce(&mut NodeSelection) -> R) -> Option { + match &mut *self.0.borrow_mut() { + Selection::Node(n) => Some(f(n)), + Selection::Range(_) => None, + } + } + + /// A node selection's keys, in the order added; empty for a range + /// selection. + pub fn node_keys(&self) -> Vec { + self.with_node(|n| n.keys().map(str::to_owned).collect()) + .unwrap_or_default() + } + + /// The point `which` of a range selection. + pub fn point(&self, which: Which) -> Option { + self.with_range(|r| r.point(which).clone()) + } + + pub fn is_dirty(&self) -> bool { + *self.0.borrow_mut().dirty_mut() + } + + pub(crate) fn set_dirty(&self, dirty: bool) { + *self.0.borrow_mut().dirty_mut() = dirty; + } + + /// `selection.clone()`: a separate selection with the same value, not + /// dirty. + pub fn clone_selection(&self) -> SelectionRef { + let mut value = self.get(); + match &mut value { + Selection::Range(r) => { + r.dirty = false; + r.cached_is_backward = None; + } + Selection::Node(n) => n.dirty = false, + } + SelectionRef::new(value) + } + + pub fn ptr_eq(&self, other: &SelectionRef) -> bool { + Rc::ptr_eq(&self.0, &other.0) + } + + /// `point.set(key, offset, type)` on the point `which` of a range + /// selection; nothing on a node selection. + pub fn point_set( + &self, + which: Which, + key: &str, + offset: usize, + point_type: PointType, + ) -> Result<(), LexicalError> { + // JS checks this in DEV builds, which the vectors come from. + let node = get_node_by_key(key); + let matches = match (&node, point_type) { + (Some(n), PointType::Text) => n.is_text(), + (Some(n), PointType::Element) => n.is_element(), + (None, _) => false, + }; + invariant!( + matches, + "PointType.set: node with key {key} is {} and can not be used for a {} point", + node.map_or("[not found]", |n| n.get_type()), + point_type.as_str() + ); + let read_only = is_currently_read_only_mode(); + self.with_range(|r| { + *r.point_mut(which) = Point::new(key, offset, point_type); + // The composition key follows the point once composition is + // ported; a headless editor never composes. + if !read_only { + r.cached_is_backward = None; + r.dirty = true; + } + }); + Ok(()) + } + + /// `setTextNodeRange(anchorNode, anchorOffset, focusNode, focusOffset)`. + pub(crate) fn set_text_node_range( + &self, + anchor: &NodeRef, + anchor_offset: usize, + focus: &NodeRef, + focus_offset: usize, + ) -> Result<(), LexicalError> { + self.point_set( + Which::Anchor, + anchor.get_key(), + anchor_offset, + PointType::Text, + )?; + self.point_set(Which::Focus, focus.get_key(), focus_offset, PointType::Text) + } + + /// `isBackward()`: whether the focus comes before the anchor. + pub fn is_backward(&self) -> Result { + let range = self.range().expect("a range selection"); + if let Some(cached) = range.cached_is_backward { + return Ok(cached); + } + let is_backward = is_before(&range.focus, &range.anchor)?; + if !is_currently_read_only_mode() { + self.with_range(|r| r.cached_is_backward = Some(is_backward)); + } + Ok(is_backward) + } +} + +/// `a.isBefore(b)`. Points on different nodes are ordered by carets +/// (`$comparePointCaretNext`), which come with the caret port. +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)", + )) +} + +/// `$getSelection()`. +pub fn get_selection() -> Option { + get_active_editor_state().0.selection.borrow().clone() +} + +/// `$setSelection(selection)`. +pub fn set_selection(selection: Option) { + crate::updates::error_on_read_only(); + if let Some(selection) = &selection { + selection.set_dirty(true); + } + *get_active_editor_state().0.selection.borrow_mut() = selection; +} + +/// `$createRangeSelection()`: a selection at the start of the root, not +/// set on the editor state. +pub fn create_range_selection() -> SelectionRef { + SelectionRef::new(Selection::Range(RangeSelection::new( + Point::new("root", 0, PointType::Element), + Point::new("root", 0, PointType::Element), + 0.0, + "", + ))) +} + +/// `$createNodeSelection()`. +pub fn create_node_selection() -> SelectionRef { + SelectionRef::new(Selection::Node(NodeSelection::default())) +} + +/// `$internalMakeRangeSelection`: a new range selection, set on the active +/// editor state. +pub(crate) fn internal_make_range_selection(anchor: Point, focus: Point) -> SelectionRef { + let mut range = RangeSelection::new(anchor, focus, 0.0, ""); + range.dirty = true; + let selection = SelectionRef::new(Selection::Range(range)); + *get_active_editor_state().0.selection.borrow_mut() = Some(selection.clone()); + selection +} + +/// `selectPointOnNode(point, node)`. +fn select_point_on_node( + selection: &SelectionRef, + which: Which, + node: &NodeRef, +) -> Result<(), LexicalError> { + let mut key = node.get_key().to_owned(); + let mut offset = selection.point(which).expect("a range selection").offset; + let mut point_type = PointType::Element; + if node.is_text() { + point_type = PointType::Text; + offset = offset.min(node.get_text_content_size()); + } else if !node.is_element() { + match node.get_next_sibling().filter(NodeRef::is_text) { + Some(next) => { + key = next.get_key().to_owned(); + offset = 0; + point_type = PointType::Text; + } + None => { + if let Some(parent) = node.get_parent() { + key = parent.get_key().to_owned(); + offset = node.get_index_within_parent().map_or(0, |i| i + 1); + } + } + } + } + selection.point_set(which, &key, offset, point_type) +} + +/// `$moveSelectionPointToEnd(point, node)`. +pub(crate) fn move_selection_point_to_end( + selection: &SelectionRef, + which: Which, + node: &NodeRef, +) -> Result<(), LexicalError> { + if node.is_element() { + match node.get_last_descendant() { + Some(last) if last.is_element() || last.is_text() => { + select_point_on_node(selection, which, &last) + } + _ => select_point_on_node(selection, which, node), + } + } else { + select_point_on_node(selection, which, node) + } +} + +/// `$selectionTouchesElement(selection, parentNode)`. +pub(crate) fn selection_touches_element(selection: &SelectionRef, parent: &NodeRef) -> bool { + selection + .with_range(|r| r.anchor.key == parent.get_key() || r.focus.key == parent.get_key()) + .unwrap_or(false) +} + +/// `$updateElementSelectionOnCreateDeleteNode(selection, parentNode, +/// nodeOffset, times)`. +pub(crate) fn update_element_selection_on_create_delete_node( + selection: &SelectionRef, + parent: &NodeRef, + node_offset: usize, + times: isize, +) -> Result<(), LexicalError> { + if !selection_touches_element(selection, parent) { + return Ok(()); + } + for which in [Which::Anchor, Which::Focus] { + let point = selection.point(which).expect("a range selection"); + if point.key == parent.get_key() + && ((node_offset <= point.offset && times > 0) + || (node_offset < point.offset && times < 0)) + { + let offset = (point.offset as isize + times).max(0) as usize; + selection.point_set(which, parent.get_key(), offset, PointType::Element)?; + } + let point = selection.point(which).expect("a range selection"); + let node = point.get_node()?; + if node.is_element() { + let size = node.get_children_size(); + let at_end = point.offset >= size; + let child = if at_end { + size.checked_sub(1).and_then(|i| node.get_child_at_index(i)) + } else { + node.get_child_at_index(point.offset) + }; + if let Some(child) = child.filter(NodeRef::is_text) { + let offset = if at_end { + child.get_text_content_size() + } else { + 0 + }; + selection.point_set(which, child.get_key(), offset, PointType::Text)?; + } + } + } + Ok(()) +} + +/// `moveSelectionPointToSibling(point, node, parent, prevSibling, +/// nextSibling)`. +pub(crate) fn move_selection_point_to_sibling( + selection: &SelectionRef, + which: Which, + node: &NodeRef, + parent: &NodeRef, + prev_sibling: Option<&NodeRef>, + next_sibling: Option<&NodeRef>, +) -> Result<(), LexicalError> { + let sibling = prev_sibling.or(next_sibling); + match sibling.filter(|s| s.is_text() || s.is_element()) { + Some(sibling) => { + let is_text = sibling.is_text(); + let offset = if prev_sibling.is_none() { + 0 + } else if is_text { + sibling.get_text_content_size() + } else { + sibling.get_children_size() + }; + let point_type = if is_text { + PointType::Text + } else { + PointType::Element + }; + selection.point_set(which, sibling.get_key(), offset, point_type) + } + None => { + let offset = node + .get_index_within_parent() + .unwrap_or_else(|| parent.get_children_size()); + selection.point_set(which, parent.get_key(), offset, PointType::Element) + } + } +} + +/// `adjustPointOffsetForMergedSibling(point, isBefore, key, target, +/// textLength)`. +pub(crate) fn adjust_point_offset_for_merged_sibling( + selection: &SelectionRef, + which: Which, + is_before: bool, + key: &str, + target: &NodeRef, + text_length: usize, +) -> Result<(), LexicalError> { + let point = selection.point(which).expect("a range selection"); + if point.point_type == PointType::Text { + let offset = point.offset + if is_before { 0 } else { text_length }; + selection.point_set(which, key, offset, PointType::Text) + } else if target + .get_index_within_parent() + .is_some_and(|i| point.offset > i) + { + selection.point_set(which, &point.key, point.offset - 1, PointType::Element) + } else { + Ok(()) + } +} + +/// `$hasAncestor(child, targetNode)`. +fn has_ancestor(child: &NodeRef, target: &NodeRef) -> bool { + let mut parent = child.get_parent(); + while let Some(p) = parent { + if &p == target { + return true; + } + parent = p.get_parent(); + } + false +} + +/// `$maybeMoveChildrenSelectionToParent(parentNode)`. +pub(crate) fn maybe_move_children_selection_to_parent( + parent: &NodeRef, +) -> Result, LexicalError> { + let selection = get_selection(); + let Some(range) = selection.as_ref().filter(|s| s.is_range()) else { + return Ok(selection); + }; + if !parent.is_element() { + return Ok(selection); + } + for which in [Which::Anchor, Which::Focus] { + let node = range.point(which).expect("a range selection").get_node()?; + if has_ancestor(&node, parent) { + range.point_set(which, parent.get_key(), 0, PointType::Element)?; + } + } + Ok(selection) +} + +/// `editorStateHasDirtySelection(editorState, editor)`. +pub(crate) fn has_dirty_selection( + pending: Option<&SelectionRef>, + current: Option<&SelectionRef>, +) -> bool { + match (pending, current) { + (Some(pending), current) => { + pending.is_dirty() || current.is_none_or(|c| !pending.get().is(&c.get())) + } + (None, current) => current.is_some(), + } +} diff --git a/rust/lexical-editor/src/updates.rs b/rust/lexical-editor/src/updates.rs index 842a701c6b7..b5d1b6ff9ec 100644 --- a/rust/lexical-editor/src/updates.rs +++ b/rust/lexical-editor/src/updates.rs @@ -1,27 +1,41 @@ //! `LexicalUpdates.ts`: the active editor and editor state that `$` -//! functions read, and `parseEditorState`. The update lifecycle -//! (`editor.update`, transforms, commit, listeners) comes next -//! (rust/DESIGN.md, milestones). +//! functions read, the update cycle (`editor.update`, transforms, the +//! commit and its listeners), command dispatch, and `parseEditorState`. +//! +//! A headless editor has no DOM, so the reconciler, mutation listeners, +//! selection-change notifications and the DOM selection never run here, as +//! in JS's headless editor. Where JS commits a non-discrete update in a +//! microtask, Rust commits it at the next [`Editor::read`] or discrete +//! update, which JS also does when one comes first. -use crate::editor::NodeClass; -use crate::editor_state::create_empty_editor_state; +use crate::editor::{ + DirtyType, LexicalCommand, NodeClass, Transform, UpdateListenerPayload, UpdateOptions, +}; +use crate::editor_state::{clone_editor_state, create_empty_editor_state}; use crate::error::invariant; +use crate::gc::garbage_collect_detached_nodes; use crate::json::Value; use crate::node::NodeRef; +use crate::normalization::normalize_text_node; +use crate::ordered::{OrderedMap, OrderedSet}; +use crate::selection::has_dirty_selection; +use crate::utils::{get_node_by_key, get_root}; use crate::{Editor, EditorState, LexicalError, constants::NODE_STATE_KEY}; -use std::cell::RefCell; -use std::collections::HashMap; +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, HashSet}; -/// JS's module-scope `activeEditorState` and `isReadOnlyMode`, per thread. -/// `activeEditor` comes with the update API, its first reader. +/// JS's module-scope `activeEditor`, `activeEditorState` and +/// `isReadOnlyMode`, per thread. #[derive(Clone, Default)] struct Active { + editor: Option, editor_state: Option, read_only: bool, } thread_local! { static ACTIVE: RefCell = RefCell::new(Active::default()); + static INFINITE_TRANSFORM_COUNT: Cell = const { Cell::new(0) }; } /// Runs `f` with `active` in place, restoring the previous one afterwards, @@ -50,6 +64,13 @@ pub(crate) fn get_active_editor_state() -> EditorState { }) } +/// `internalGetActiveEditor()`: the editor whose update or read is running, +/// if any. Reading an editor state on its own ([`EditorState::read`], +/// parsing) has none. +pub(crate) fn get_active_editor() -> Option { + ACTIVE.with(|a| a.borrow().editor.clone()) +} + /// `isCurrentlyReadOnlyMode()`. pub(crate) fn is_currently_read_only_mode() -> bool { ACTIVE.with(|a| a.borrow().read_only) @@ -62,10 +83,28 @@ pub(crate) fn error_on_read_only() { } } +/// `errorOnInfiniteTransforms()`. +pub(crate) fn error_on_infinite_transforms() { + if INFINITE_TRANSFORM_COUNT.with(Cell::get) > 99 { + panic!( + "One or more transforms are endlessly triggering additional transforms. May have encountered infinite recursion caused by transforms that have their preconditions too lose and/or conflict with each other." + ); + } +} + +fn bump_infinite_transform_count() { + INFINITE_TRANSFORM_COUNT.with(|c| c.set(c.get() + 1)); +} + /// `readEditorState(editor, editorState, callbackFn)`. -pub(crate) fn read_editor_state(editor_state: &EditorState, f: impl FnOnce() -> R) -> R { +pub(crate) fn read_editor_state( + editor: Option<&Editor>, + editor_state: &EditorState, + f: impl FnOnce() -> R, +) -> R { with_active( Active { + editor: editor.cloned(), editor_state: Some(editor_state.clone()), read_only: true, }, @@ -73,6 +112,143 @@ pub(crate) fn read_editor_state(editor_state: &EditorState, f: impl FnOnce() ) } +/// `$applyTransforms(editor, node, transformsCache)`. +fn apply_transforms( + editor: &Editor, + node: &NodeRef, + cache: &mut HashMap<&'static str, Vec>, +) -> Result<(), LexicalError> { + let node_type = node.get_type(); + let transforms = cache + .entry(node_type) + .or_insert_with(|| editor.transforms_of(node_type)) + .clone(); + for transform in &transforms { + transform(node)?; + if !node.is_attached() { + break; + } + } + Ok(()) +} + +/// Whether a dirty leaf is normalized: attached, simple, mergeable text. +fn should_normalize(node: &NodeRef) -> bool { + node.is_text() && node.is_attached() && node.is_simple_text() && !node.is_unmergeable() +} + +/// `$normalizeAllDirtyTextNodes(editorState, editor)`. Iterates the dirty +/// leaves as JS's `for...of` does, seeing those added meanwhile. +fn normalize_all_dirty_text_nodes(editor: &Editor) -> Result<(), LexicalError> { + let mut i = 0; + loop { + let key = editor + .0 + .dirty + .borrow() + .leaves + .key_at(&mut i) + .map(str::to_owned); + let Some(key) = key else { break }; + if let Some(node) = get_node_by_key(&key) + && should_normalize(&node) + { + normalize_text_node(&node)?; + } + i += 1; + } + Ok(()) +} + +/// `$applyAllTransforms(editorState, editor)`: transforms dirty leaves +/// first, repeating while they dirty more leaves, then the elements marked +/// dirty themselves, the root last, and again from the leaves while +/// anything new is dirty. +/// +/// JS swaps the editor's dirty sets for fresh ones to see what each round +/// dirties, and accumulates the full record in the sets it started with; +/// in the first round the full record is the editor's own set, which is +/// why `full_*` is `None` until the editor's set is first swapped out. +fn apply_all_transforms(editor: &Editor) -> Result<(), LexicalError> { + let mut cache = HashMap::new(); + let mut full_leaves: Option = None; + let mut full_elements: Option> = None; + + loop { + let (leaves_len, elements_len) = { + let dirty = editor.0.dirty.borrow(); + (dirty.leaves.len(), dirty.elements.len()) + }; + if leaves_len == 0 && elements_len == 0 { + break; + } + if leaves_len > 0 { + let untransformed = std::mem::take(&mut editor.0.dirty.borrow_mut().leaves); + let keys: Vec = untransformed.iter().map(str::to_owned).collect(); + full_leaves.get_or_insert(untransformed); + for key in &keys { + if let Some(node) = get_node_by_key(key) { + if should_normalize(&node) { + normalize_text_node(&node)?; + } + if node.is_attached() { + apply_transforms(editor, &node, &mut cache)?; + } + } + full_leaves.as_mut().expect("set above").add(key); + } + // Leaf transforms come before element transforms. + if !editor.0.dirty.borrow().leaves.is_empty() { + bump_infinite_transform_count(); + continue; + } + } + + let (previous_leaves, mut untransformed) = { + let mut dirty = editor.0.dirty.borrow_mut(); + ( + std::mem::take(&mut dirty.leaves), + std::mem::take(&mut dirty.elements), + ) + }; + full_leaves.get_or_insert(previous_leaves); + // The root is always considered intentionally dirty if any attached + // node is dirty, and moving it last applies its transforms last. + if untransformed.delete("root").is_some() { + untransformed.set("root", true); + } + full_elements.get_or_insert_with(|| untransformed.clone()); + let entries: Vec<(String, bool)> = untransformed + .iter() + .map(|(k, v)| (k.to_owned(), *v)) + .collect(); + for (key, intentionally_marked_as_dirty) in entries { + full_elements + .as_mut() + .expect("set above") + .set(&key, intentionally_marked_as_dirty); + if !intentionally_marked_as_dirty { + continue; + } + if let Some(node) = get_node_by_key(&key) + && node.is_attached() + { + apply_transforms(editor, &node, &mut cache)?; + } + } + bump_infinite_transform_count(); + } + + let mut dirty = editor.0.dirty.borrow_mut(); + if let Some(leaves) = full_leaves { + dirty.leaves = leaves; + } + if let Some(elements) = full_elements { + dirty.elements = elements; + } + Ok(()) +} + /// `$parseSerializedNodeImpl`. fn parse_serialized_node_impl( serialized_node: &Value, @@ -125,13 +301,25 @@ fn parse_serialized_node_impl( Ok(node) } -/// `parseEditorState(serializedEditorState, editor)`. +/// `$parseSerializedNode(serializedNode)`: a node, and its children, from +/// its JSON, created in the active update with the active editor's node +/// classes. +pub fn parse_serialized_node(serialized_node: &Value) -> Result { + let editor = + get_active_editor().ok_or_else(|| LexicalError::new("Unable to find an active editor."))?; + parse_serialized_node_impl(serialized_node, editor.registered_nodes()) +} + +/// `parseEditorState(serializedEditorState, editor)`. The nodes are created +/// outside any update of the editor, so nothing is marked dirty (JS saves +/// and restores the editor's dirty records instead). pub(crate) fn parse_editor_state( serialized_editor_state: &Value, editor: &Editor, ) -> Result { let editor_state = create_empty_editor_state(); let active = Active { + editor: None, editor_state: Some(editor_state.clone()), read_only: false, }; @@ -146,5 +334,329 @@ pub(crate) fn parse_editor_state( })?; // Make the editor state immutable. editor_state.0.read_only.set(true); + editor_state.0.parsed.set(true); Ok(editor_state) } + +/// `getEditorStateTextContent(editorState)`. +fn editor_state_text_content(editor_state: &EditorState) -> String { + editor_state.read(|| get_root().get_text_content()) +} + +/// `triggerListeners`'s bracket: listeners run with the editor marked as +/// updating, so an update they start is queued. +fn with_updating(editor: &Editor, f: impl FnOnce() -> R) -> R { + struct Restore<'a>(&'a Editor, bool); + impl Drop for Restore<'_> { + fn drop(&mut self) { + self.0.0.updating.set(self.1); + } + } + let _restore = Restore(editor, editor.0.updating.replace(true)); + f() +} + +/// `$commitPendingUpdates(editor)`: makes the pending state the editor's +/// state, notifies the text content and update listeners, and runs the +/// first update queued meanwhile. +pub(crate) fn commit_pending_updates(editor: &Editor) -> Result<(), LexicalError> { + let Some(pending) = editor.0.pending.borrow_mut().take() else { + return Ok(()); + }; + let current = editor.get_editor_state(); + let needs_update = editor.dirty_type() != DirtyType::None; + *editor.0.editor_state.borrow_mut() = pending.clone(); + pending.0.read_only.set(true); + + let (dirty_elements, dirty_leaves, normalized_nodes) = { + let mut dirty = editor.0.dirty.borrow_mut(); + let elements = dirty + .elements + .iter() + .map(|(k, v)| (k.to_owned(), *v)) + .collect(); + let leaves = dirty.leaves.iter().map(str::to_owned).collect(); + let normalized = dirty.normalized.iter().map(str::to_owned).collect(); + if needs_update { + *dirty = Default::default(); + } + (elements, leaves, normalized) + }; + // Reset even when nothing is dirty, so a no-op update's tags don't leak + // into the next one. + let tags: Vec = std::mem::take(&mut *editor.0.tags.borrow_mut()) + .iter() + .map(str::to_owned) + .collect(); + + let previously_dispatching = editor.0.dispatching_commit_listeners.replace(true); + with_updating(editor, || { + let listeners = editor.text_content_listeners(); + if !listeners.is_empty() { + let current_text = editor_state_text_content(¤t); + let latest_text = editor_state_text_content(&pending); + if current_text != latest_text { + for listener in &listeners { + listener(&latest_text); + } + } + } + let payload = UpdateListenerPayload { + dirty_elements, + dirty_leaves, + normalized_nodes, + tags, + editor_state: pending.clone(), + prev_editor_state: current.clone(), + }; + for listener in editor.update_listeners() { + listener(&payload); + } + }); + editor + .0 + .dispatching_commit_listeners + .set(previously_dispatching); + trigger_enqueued_updates(editor) +} + +/// `$triggerEnqueuedUpdates(editor)`: begins the next queued update, if +/// any, unless listeners have been queueing updates endlessly. +fn trigger_enqueued_updates(editor: &Editor) -> Result<(), LexicalError> { + if editor.0.updates.borrow().is_empty() { + editor.0.cascade_count.set(0); + return Ok(()); + } + let count = editor + .0 + .cascade_count + .replace(editor.0.cascade_count.get() + 1); + if count > 99 { + // JS warns through onWarn. + editor.0.updates.borrow_mut().clear(); + editor.0.cascade_count.set(0); + return Ok(()); + } + let queued = editor.0.updates.borrow_mut().pop_front(); + match queued { + Some(queued) => begin_update(editor, queued.update_fn, &queued.options), + None => Ok(()), + } +} + +/// `$processNestedUpdates(editor, initialSkipTransforms)`: runs the updates +/// queued during this one as part of it. +fn process_nested_updates(editor: &Editor, initial_skip: bool) -> Result { + let mut skip_transforms = initial_skip; + loop { + let queued = editor.0.updates.borrow_mut().pop_front(); + let Some(queued) = queued else { break }; + let pending = editor.pending(); + if queued.options.skip_transforms { + skip_transforms = true; + } + if queued.options.discrete { + invariant!( + pending.is_some(), + "Unexpected empty pending editor state on discrete nested update" + ); + pending.as_ref().expect("checked").0.flush_sync.set(true); + } + editor.add_tags(&queued.options.tags); + if pending.is_none() { + begin_update(editor, queued.update_fn, &queued.options)?; + } else { + (queued.update_fn)()?; + } + } + Ok(skip_transforms) +} + +/// `$discardPendingUpdate(editor, queuedBefore)`: the editor goes back to +/// its committed state; the updates the failed one queued are dropped. +fn discard_pending_update(editor: &Editor, queued_before: &HashSet) { + *editor.0.pending.borrow_mut() = Some(editor.get_editor_state()); + { + let mut dirty = editor.0.dirty.borrow_mut(); + dirty.dirty_type = DirtyType::FullReconcile; + dirty.leaves = OrderedSet::new(); + dirty.elements = OrderedMap::new(); + } + editor + .0 + .updates + .borrow_mut() + .retain(|u| queued_before.contains(&u.id)); +} + +/// `$beginUpdate(editor, updateFn, options)`. +pub(crate) fn begin_update( + editor: &Editor, + update_fn: impl FnOnce() -> Result<(), LexicalError>, + options: &UpdateOptions, +) -> Result<(), LexicalError> { + editor.add_tags(&options.tags); + let mut skip_transforms = options.skip_transforms; + let current = editor.get_editor_state(); + let mut was_cloned = false; + let pending = match editor.pending() { + Some(pending) if !pending.0.read_only.get() => pending, + other => { + let pending = clone_editor_state(other.as_ref().unwrap_or(¤t)); + *editor.0.pending.borrow_mut() = Some(pending.clone()); + was_cloned = true; + pending + } + }; + pending.0.flush_sync.set(options.discrete); + // The updates queued before this one (see discard_pending_update). + let queued_before: HashSet = editor.0.updates.borrow().iter().map(|u| u.id).collect(); + let previous_active_state = ACTIVE.with(|a| a.borrow().editor_state.clone()); + + struct Restore<'a>(&'a Editor, bool); + impl Drop for Restore<'_> { + fn drop(&mut self) { + self.0.0.updating.set(self.1); + INFINITE_TRANSFORM_COUNT.with(|c| c.set(0)); + } + } + let restore = Restore(editor, editor.0.updating.replace(true)); + + let active = Active { + editor: Some(editor.clone()), + editor_state: Some(pending.clone()), + read_only: false, + }; + let result = with_active(active, || { + let run = || -> Result<(), LexicalError> { + if was_cloned && let Some(selection) = current.selection() { + // A headless editor carries the selection over. + *pending.0.selection.borrow_mut() = Some(selection.clone_selection()); + } + update_fn()?; + skip_transforms = process_nested_updates(editor, skip_transforms)?; + if editor.dirty_type() != DirtyType::None { + if skip_transforms { + normalize_all_dirty_text_nodes(editor)?; + } else { + apply_all_transforms(editor)?; + } + process_nested_updates(editor, false)?; + garbage_collect_detached_nodes(¤t, &pending, editor); + } + if let Some(selection) = pending.selection() { + if let Some(range) = selection.range() { + let node_map = pending.0.node_map.borrow(); + invariant!( + node_map.contains_key(&range.anchor.key) + && node_map.contains_key(&range.focus.key), + "updateEditor: selection has been lost because the previously selected nodes have been removed and selection wasn't moved to another node. Ensure selection changes after removing/replacing a selected node." + ); + } else if selection.with_node(|n| n.nodes.is_empty()) == Some(true) { + *pending.0.selection.borrow_mut() = None; + } + } + Ok(()) + }; + match run() { + Ok(()) => Ok(()), + Err(error) => { + // An update nested in the running update whose pending state + // it shares leaves the error to that update. + if !was_cloned + && previous_active_state + .as_ref() + .is_some_and(|s| s.ptr_eq(&pending)) + { + return Err(error); + } + discard_pending_update(editor, &queued_before); + // JS reports a failure of this commit to onError too; the + // caller gets the update's error. + let _ = commit_pending_updates(editor); + Err(error) + } + } + }); + drop(restore); + result?; + + let should_update = editor.dirty_type() != DirtyType::None + || has_dirty_selection( + pending.selection().as_ref(), + editor.get_editor_state().selection().as_ref(), + ); + if should_update { + if pending.0.flush_sync.get() { + pending.0.flush_sync.set(false); + commit_pending_updates(editor)?; + } + // Otherwise the next read or discrete update commits it. + } else { + pending.0.flush_sync.set(false); + if was_cloned { + *editor.0.tags.borrow_mut() = OrderedSet::new(); + *editor.0.pending.borrow_mut() = None; + } + } + Ok(()) +} + +/// `updateEditorSync(editor, updateFn, options)`: runs `f` in the editor's +/// running update when there are no options, or in a new update. +pub(crate) fn update_editor_sync( + editor: &Editor, + f: impl FnOnce() -> Result<(), LexicalError>, + options: Option<&UpdateOptions>, +) -> Result<(), LexicalError> { + let (in_update, read_only) = ACTIVE.with(|a| { + let a = a.borrow(); + ( + a.editor.as_ref().is_some_and(|e| e.ptr_eq(editor)), + a.read_only, + ) + }); + match options { + None if in_update && !read_only => f(), + None => begin_update(editor, f, &UpdateOptions::default()), + Some(options) => begin_update(editor, f, options), + } +} + +/// `triggerCommandListeners(editor, type, payload)`: the command's +/// listeners from the highest priority down, each priority's in one update, +/// until one returns true. +pub(crate) fn trigger_command_listeners( + editor: &Editor, + command: &LexicalCommand

, + payload: &P, +) -> Result { + // A command dispatched from outside an update starts a new action. + if !editor.0.updating.get() { + editor.0.cascade_count.set(0); + } + for priority in (0..=4).rev() { + let listeners = editor.command_listeners(command, priority); + if listeners.is_empty() { + continue; + } + let mut handled = false; + update_editor_sync( + editor, + || { + for listener in &listeners { + if listener(payload, editor)? { + handled = true; + return Ok(()); + } + } + Ok(()) + }, + None, + )?; + if handled { + return Ok(true); + } + } + Ok(false) +} diff --git a/rust/lexical-editor/src/utf16.rs b/rust/lexical-editor/src/utf16.rs new file mode 100644 index 00000000000..7dd41cebd98 --- /dev/null +++ b/rust/lexical-editor/src/utf16.rs @@ -0,0 +1,50 @@ +//! Text offsets in UTF-16 code units, as JS strings count them, over Rust's +//! UTF-8 strings. A slice that splits a surrogate pair gives U+FFFD for the +//! half it keeps, the Tier 1 exception for unpaired surrogates +//! (rust/PARITY.md); it is still one unit long, so lengths and offsets agree +//! with JS. + +/// `text.length`. +pub fn len(text: &str) -> usize { + if text.is_ascii() { + text.len() + } else { + text.chars().map(char::len_utf16).sum() + } +} + +/// `text.slice(start, end)` for `0 <= start <= end`, clamped to the length. +pub fn slice(text: &str, start: usize, end: usize) -> String { + if text.is_ascii() { + let end = end.min(text.len()); + let start = start.min(end); + return text[start..end].to_owned(); + } + let units: Vec = text.encode_utf16().collect(); + let end = end.min(units.len()); + let start = start.min(end); + String::from_utf16_lossy(&units[start..end]) +} + +/// `text.slice(start)`. +pub fn slice_from(text: &str, start: usize) -> String { + slice(text, start, usize::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn counts_and_slices_like_js() { + // Each `.length` and `.slice(...)` in Node. + let text = "aé😀b"; + assert_eq!(len(text), 5); + assert_eq!(slice(text, 1, 4), "é😀"); + assert_eq!(slice_from(text, 4), "b"); + assert_eq!(slice(text, 9, 12), ""); + // Splitting the pair keeps one unit each side, as U+FFFD. + assert_eq!(slice(text, 0, 3), "aé\u{FFFD}"); + assert_eq!(len(&slice(text, 0, 3)), 3); + } +} diff --git a/rust/lexical-editor/src/utils.rs b/rust/lexical-editor/src/utils.rs index 6df9ac08ae6..22321d8cf0f 100644 --- a/rust/lexical-editor/src/utils.rs +++ b/rust/lexical-editor/src/utils.rs @@ -1,8 +1,12 @@ -//! `LexicalUtils.ts`: keys, node lookup and `getWritable`. +//! `LexicalUtils.ts`: keys, node lookup, `getWritable` and the dirty marks +//! it leaves for the update cycle. use crate::EditorState; +use crate::editor::DirtyType; use crate::node::{LexicalNode, NodeRef}; -use crate::updates::{error_on_read_only, get_active_editor_state}; +use crate::updates::{ + error_on_infinite_transforms, error_on_read_only, get_active_editor, get_active_editor_state, +}; use std::cell::Cell; use std::rc::Rc; @@ -42,34 +46,112 @@ pub fn get_root() -> NodeRef { NodeRef::new("root") } -/// `$setNodeKey(node, null)`: gives a new node a key and puts it in the -/// active editor state. +/// `$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 { error_on_read_only(); + error_on_infinite_transforms(); let key = generate_random_key(); node.base_mut().key = key.clone(); + let is_element = node.element().is_some(); get_active_editor_state() .0 .node_map .borrow_mut() .insert(key.clone(), Rc::from(node)); + if let Some(editor) = get_active_editor() { + editor.mark_dirty(&key, is_element); + } NodeRef::new(key) } -/// `node.getWritable()`, then `f` on it. The node is cloned first when -/// anything else shares it, such as the previous editor state. -/// -/// `f` runs while the node map is borrowed, so it must only change the -/// node's own fields, not read other nodes. -pub(crate) fn with_writable(key: &str, f: impl FnOnce(&mut dyn LexicalNode) -> R) -> R { +/// `internalMarkNodeAsDirty(node)`: marks the node's ancestors as dirty +/// (unintentionally, `false`) up to the first one already marked, then the +/// node itself. +pub(crate) fn internal_mark_node_as_dirty(key: &str) { + error_on_infinite_transforms(); + let Some(editor) = get_active_editor() else { + return; + }; + let editor_state = get_active_editor_state(); + let (parent, is_element) = { + let node_map = editor_state.0.node_map.borrow(); + let node = node_map + .get(key) + .unwrap_or_else(|| panic!("internalMarkNodeAsDirty: node {key} does not exist")); + (node.base().parent.clone(), node.element().is_some()) + }; + { + let node_map = editor_state.0.node_map.borrow(); + let mut dirty = editor.0.dirty.borrow_mut(); + // `internalMarkParentElementsAsDirty`. + let mut next_parent = parent; + while let Some(parent_key) = next_parent { + if dirty.elements.contains(&parent_key) { + break; + } + let Some(node) = node_map.get(&parent_key) else { + break; + }; + dirty.elements.set(&parent_key, false); + next_parent = node.base().parent.clone(); + } + if dirty.dirty_type == DirtyType::None { + dirty.dirty_type = DirtyType::HasDirtyNodes; + } + } + editor.mark_dirty(key, is_element); +} + +/// `internalMarkSiblingsAsDirty(node)`. +pub(crate) fn internal_mark_siblings_as_dirty(node: &NodeRef) { + if let Some(previous) = node.get_previous_sibling() { + internal_mark_node_as_dirty(previous.get_key()); + } + if let Some(next) = node.get_next_sibling() { + internal_mark_node_as_dirty(next.get_key()); + } +} + +/// `node.getWritable()`: marks the node dirty, and copies it into the +/// active editor state if anything else (the previous editor state) shares +/// it. Changes then go through [`mutate`]. +pub(crate) fn get_writable(key: &str) { error_on_read_only(); + unshare(key); + internal_mark_node_as_dirty(key); +} + +fn unshare(key: &str) { let editor_state = get_active_editor_state(); let mut node_map = editor_state.0.node_map.borrow_mut(); - let node = node_map - .get_mut(key) - .unwrap_or_else(|| panic!("getWritable: node {key} does not exist")); + let node = node_map.get_mut(key).unwrap_or_else(|| { + panic!( + "Lexical node does not exist in active editor state (key {key}). The reference was probably kept from another read or update." + ) + }); if Rc::get_mut(node).is_none() { *node = Rc::from(node.clone_node()); } - f(Rc::get_mut(node).expect("unshared")) +} + +/// Changes the fields of a node that [`get_writable`] already made writable +/// in this update, as JS assigns to the fields of the writable node, without +/// marking it dirty again. +/// +/// `f` runs while the node map is borrowed, so it must only change the +/// node's own fields, not read other nodes. +pub(crate) fn mutate(key: &str, f: impl FnOnce(&mut dyn LexicalNode) -> R) -> R { + error_on_read_only(); + unshare(key); + let editor_state = get_active_editor_state(); + let mut node_map = editor_state.0.node_map.borrow_mut(); + let node = node_map.get_mut(key).expect("unshared above"); + f(Rc::get_mut(node).expect("unshared above")) +} + +/// `const self = node.getWritable(); f(self)`. +pub(crate) fn with_writable(key: &str, f: impl FnOnce(&mut dyn LexicalNode) -> R) -> R { + get_writable(key); + mutate(key, f) }